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)