Custom i18n module with HOCON translation files, CLDR-aware pluralization for 40+ locales, namespace::group.item key resolution, :name/:Name/:NAME parameter interpolation, and locale fallback chains. No effect system deps.
202 lines
6.5 KiB
Scala
202 lines
6.5 KiB
Scala
package summer.phrasebook
|
|
|
|
import java.nio.file.Path
|
|
import java.util.concurrent.ConcurrentHashMap
|
|
|
|
/**
|
|
* Full implementation of the [[Translator]] trait.
|
|
*
|
|
* Ported from Laravel's `Illuminate\Translation\Translator` +
|
|
* WinterCMS `Winter\Storm\Translation\Translator`.
|
|
*
|
|
* Thread-safe: uses ConcurrentHashMap for loaded translations and overrides.
|
|
*/
|
|
final class PhrasebookTranslator(
|
|
initialLocale: LocaleTag,
|
|
initialFallback: Option[LocaleTag] = None,
|
|
) extends Translator:
|
|
|
|
// --- Mutable state ---
|
|
|
|
@volatile private var currentLocale: LocaleTag = initialLocale
|
|
@volatile private var fallbackLocale: Option[LocaleTag] = initialFallback
|
|
|
|
/** namespace -> filesystem path */
|
|
private val namespaces = new ConcurrentHashMap[String, Path]()
|
|
|
|
/** alias -> real namespace */
|
|
private val aliases = new ConcurrentHashMap[String, String]()
|
|
|
|
/**
|
|
* Loaded translations: (namespace, group, locale) -> flat Map.
|
|
* Using a composite string key "ns|group|locale" for simplicity.
|
|
*/
|
|
private val loaded = new ConcurrentHashMap[String, Map[String, String]]()
|
|
|
|
/** Runtime overrides: (locale, fullKey) -> value */
|
|
private val overrides = new ConcurrentHashMap[String, String]()
|
|
|
|
// --- Public API ---
|
|
|
|
override def get(
|
|
key: String,
|
|
replace: Map[String, String],
|
|
locale: Option[LocaleTag],
|
|
): String =
|
|
val effectiveLocale = locale.getOrElse(currentLocale)
|
|
val parsed = KeyParser.parse(key)
|
|
val ns = resolveNamespace(parsed.namespace)
|
|
|
|
// Check runtime overrides first
|
|
val overrideKey = s"${effectiveLocale.value}|$key"
|
|
val overridden = overrides.get(overrideKey)
|
|
if overridden != null then
|
|
return Interpolator.replace(overridden, replace)
|
|
|
|
// WinterCMS compat: validation.* without namespace -> system::validation.*
|
|
val (finalNs, finalParsed) =
|
|
if ns.isEmpty && parsed.group == "validation" then
|
|
(Some("system"), parsed.copy(namespace = Some("system")))
|
|
else
|
|
(ns, parsed)
|
|
|
|
// Walk fallback chain
|
|
val chain = FallbackChain.build(effectiveLocale, fallbackLocale)
|
|
val result = chain.iterator.flatMap { loc =>
|
|
getLine(finalNs, finalParsed.group, loc, finalParsed.item)
|
|
}.nextOption()
|
|
|
|
result match
|
|
case Some(line) => Interpolator.replace(line, replace)
|
|
case None => key // Return raw key if not found (Laravel behavior)
|
|
|
|
override def choice(
|
|
key: String,
|
|
number: Int,
|
|
replace: Map[String, String],
|
|
locale: Option[LocaleTag],
|
|
): String =
|
|
val effectiveLocale = locale.getOrElse(currentLocale)
|
|
val line = get(key, Map.empty, Some(effectiveLocale))
|
|
|
|
// If get() returned the raw key (not found), return it with replacements
|
|
if line == key then
|
|
return Interpolator.replace(key, replace + ("count" -> number.toString))
|
|
|
|
val chosen = PluralRules.choose(line, number, effectiveLocale)
|
|
Interpolator.replace(chosen, replace + ("count" -> number.toString))
|
|
|
|
override def has(key: String, locale: Option[LocaleTag]): Boolean =
|
|
val effectiveLocale = locale.getOrElse(currentLocale)
|
|
val result = get(key, Map.empty, Some(effectiveLocale))
|
|
result != key
|
|
|
|
override def allForLocale(
|
|
locale: LocaleTag,
|
|
namespace: Option[String],
|
|
): Map[String, String] =
|
|
import scala.jdk.CollectionConverters.*
|
|
val prefix = namespace match
|
|
case Some(ns) => s"${resolveNamespace(Some(ns)).getOrElse(ns)}|"
|
|
case None => ""
|
|
|
|
val result = Map.newBuilder[String, String]
|
|
for entry <- loaded.entrySet().asScala do
|
|
val compositeKey = entry.getKey.nn
|
|
if compositeKey.endsWith(s"|${locale.value}") &&
|
|
(prefix.isEmpty || compositeKey.startsWith(prefix))
|
|
then
|
|
val translations = entry.getValue.nn
|
|
result ++= translations
|
|
result.result()
|
|
|
|
override def setLocale(locale: LocaleTag): Unit =
|
|
require(!locale.value.contains("/") && !locale.value.contains("\\"),
|
|
s"Invalid locale: ${locale.value}")
|
|
currentLocale = locale
|
|
|
|
override def getLocale: LocaleTag = currentLocale
|
|
|
|
override def getFallback: Option[LocaleTag] = fallbackLocale
|
|
|
|
override def setFallback(locale: LocaleTag): Unit =
|
|
fallbackLocale = Some(locale)
|
|
|
|
override def addNamespace(namespace: String, path: Path): Unit =
|
|
namespaces.put(namespace.toLowerCase.nn, path)
|
|
|
|
override def addAlias(alias: String, namespace: String): Unit =
|
|
aliases.put(alias.toLowerCase.nn, namespace.toLowerCase.nn)
|
|
|
|
override def set(key: String, value: String, locale: Option[LocaleTag]): Unit =
|
|
val effectiveLocale = locale.getOrElse(currentLocale)
|
|
overrides.put(s"${effectiveLocale.value}|$key", value)
|
|
|
|
override def clearCache(): Unit =
|
|
loaded.clear()
|
|
overrides.clear()
|
|
KeyParser.clearCache()
|
|
|
|
// --- Internal ---
|
|
|
|
/**
|
|
* Resolve a namespace through aliases.
|
|
* Returns None for no-namespace keys.
|
|
*/
|
|
private def resolveNamespace(ns: Option[String]): Option[String] =
|
|
ns.map { n =>
|
|
val lower = n.toLowerCase.nn
|
|
val aliased = aliases.get(lower)
|
|
if aliased != null then aliased else lower
|
|
}
|
|
|
|
/**
|
|
* Get a translation line for a specific (namespace, group, locale, item) combo.
|
|
*/
|
|
private def getLine(
|
|
namespace: Option[String],
|
|
group: String,
|
|
locale: LocaleTag,
|
|
item: Option[String],
|
|
): Option[String] =
|
|
val translations = loadGroup(namespace, group, locale)
|
|
item match
|
|
case Some(itemKey) => translations.get(itemKey)
|
|
case None => None // Requesting a whole group returns None for get()
|
|
|
|
/**
|
|
* Load translations for a (namespace, group, locale) triple.
|
|
* Lazy-loads from disk on first access.
|
|
*/
|
|
private def loadGroup(
|
|
namespace: Option[String],
|
|
group: String,
|
|
locale: LocaleTag,
|
|
): Map[String, String] =
|
|
val cacheKey = s"${namespace.getOrElse("*")}|$group|${locale.value}"
|
|
val cached = loaded.get(cacheKey)
|
|
if cached != null then cached
|
|
else
|
|
val translations = doLoad(namespace, group, locale)
|
|
loaded.put(cacheKey, translations)
|
|
translations
|
|
|
|
/**
|
|
* Actually load translations from disk.
|
|
*/
|
|
private def doLoad(
|
|
namespace: Option[String],
|
|
group: String,
|
|
locale: LocaleTag,
|
|
): Map[String, String] =
|
|
namespace match
|
|
case None =>
|
|
// No namespace - would need a default path. Return empty.
|
|
Map.empty
|
|
case Some(ns) =>
|
|
val basePath = namespaces.get(ns)
|
|
if basePath == null then Map.empty
|
|
else
|
|
val confFile = basePath.resolve(locale.value).resolve(s"$group.conf")
|
|
HoconLoader.load(confFile)
|