Implement Phase 1 core translation engine (95 tests passing)
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.
This commit is contained in:
201
src/main/scala/summer/phrasebook/PhrasebookTranslator.scala
Normal file
201
src/main/scala/summer/phrasebook/PhrasebookTranslator.scala
Normal file
@@ -0,0 +1,201 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user