messages.scala

package ru.circumflex
package core

import collection.{Iterator, Map}
import collection.JavaConversions._
import collection.mutable.HashMap
import java.util.{ResourceBundle, Locale, Properties, Enumeration => JEnum, Date}
import java.text.MessageFormat
import java.io._
import org.apache.commons.io.FilenameUtils

Messages API

Messages API offers you a convenient way to internationalize your application.

Generally, all strings which should be presented to user are stored in separate .properties-files as suggested by Java Internationalization.

Circumflex Messages API goes beyond this simple approach and offers delegating resolving, messages grouping, parameters interpolation and formatting.

The usage is pretty simple: you use the msg method of package object ru.circumflex.core which returns an implementation of MessageResolver used to retrieve messages. This instance is also referred to as global messages resolver. By default, the PropertyFileResolver is used. You can set cx.messages configuration parameter to use your own MessageResolver implementation as global resolver.

The resolve method is responsible for resolving a message by key.

Circumflex Messages API features very robust ranged resolving. The message is searched using the range of keys, from the most specific to the most general ones: if the message is not resolved with given key, then the key is truncated from the left side to the first dot (.) and the message is searched again. For example, if you are looking for a message with the key com.myapp.model.Account.name.empty (possibly while performing domain model validation), then following keys will be used to lookup an appropriate message (until first success):

com.myapp.model.Account.name.empty
myapp.model.Account.name.empty
model.Account.name.empty
Account.name.empty
name.empty
empty

You can use the methods of Scala Map to retrieve messages from resolver. Default implementation also reports missing messages into Circumflex debug log.

The locale is taken from cx.locale context variable (see Context for more details). If no such variable found in the context, then the platform's default locale is used.

Messages can also be formatted. We support both classic MessageFormat style (you know, with {0}s in text and varargs) and parameters interpolation (key-value pairs are passed as arguments to fmt method, each {key} in message is replaced by corresponding value).

You can use ResourceBundleMessageResolver to resolve messages from Java ResourceBundles.

The default implementation (the msg method in package ru.circumflex.core) uses uses property files with base name Messages to lookup messages. You can override the default implementation by setting cx.messages configuration parameter. Unlike Java ResourceBundle it effectively caches property files and allows hot editing (cache is based on last modified dates).

You can set cx.messages.root to point to different directory (for example, to your webapp root) and cx.messages.name to change the default base name of property files.

If you need to search messages in different sources, you can use DelegatingMessageResolver: it tries to resolve a message using specified resolvers list, the first successively resolved message is returned.

trait MessageResolver extends Map[String, String] {
  protected var _lastModified = new Date()
  def lastModified = _lastModified

  def -(key: String): Map[String, String] = this
  def +[B1 >: String](kv: (String, B1)): Map[String, B1] = this

  protected def resolve(key: String): Option[String]

  protected def resolveRange(key: String): Option[String] = resolve(key) orElse {
    if (!key.contains(".")) None
    else resolveRange(key.substring(key.indexOf(".") + 1))
  }

  def get(key: String): Option[String] = resolveRange(key) orElse {
    CX_LOG.debug("Message with key '" + key + "' is missing.")
    None
  }

  def locale: Locale = ctx.get("cx.locale") match {
    case Some(l: Locale) => l
    case Some(l: String) => new Locale(l)
    case _ => Locale.getDefault
  }

  def fmt(key: String, params: (String, Any)*): String =
    params.foldLeft(getOrElse(key, key)) { (result, p) =>
      result.replaceAll("\\{" + p._1 + "\\}", p._2.toString)
    }
  def format(key: String, params: AnyRef*): String =
    MessageFormat.format(getOrElse(key, key), params: _*)
}

class ResourceBundleMessageResolver(val bundleName: String) extends MessageResolver {
  protected def bundle = ResourceBundle.getBundle(
    bundleName, locale, Thread.currentThread.getContextClassLoader)
  def iterator: Iterator[(String, String)] = bundle.getKeys
      .map(k => (k -> bundle.getString(k)))
  protected def resolve(key: String): Option[String] =
    try { Some(bundle.getString(key)) } catch { case e: Exception => None }
}

class DelegatingMessageResolver(initialResolvers: MessageResolver*) extends MessageResolver {
  protected var _resolvers: Seq[MessageResolver] = initialResolvers
  def resolvers = _resolvers
  def addResolver(r: MessageResolver): this.type = {
    _resolvers ++= List(r)
    _lastModified = new Date()
    this
  }
  def iterator: Iterator[(String, String)] =
    resolvers.map(_.iterator).reduceLeft((a, b) => a ++ b)
  protected def resolve(key: String): Option[String] = {
    resolvers.foreach(r => r.get(key).map(msg => return Some(msg)))
    None
  }
}

class PropertyFileResolver extends MessageResolver {
  val propsRoot = new File(
    FilenameUtils.separatorsToSystem(
      cx.getOrElse("cx.messages.root", "src/main/resources").toString))
  val resourceName = cx.getOrElse("cx.messages.name", "Messages").toString
  protected val _cache = new HashMap[String, (Properties, Long)]

  protected def getFile(suffix: String) =
    new File(propsRoot, resourceName + suffix + ".properties")

  protected def getProps(suffix: String): Option[Properties] = {
    val f = getFile(suffix)
    _cache.get(suffix) match {
      case Some((props: Properties, lm: Long)) =>
        if (!f.isFile) {    // previously cached file does not exist anymore
          _cache.remove(suffix)
          _lastModified = new Date()
          getProps(suffix)
        } else {
          if (f.lastModified > lm) {  // cached file has been modified
            _lastModified = new Date()
            loadProps(f) match {
              case Some(p: Properties) =>
                _cache(suffix) = (p, f.lastModified)
                Some(p)
              case _ =>    // previously cached file does not exist anymore
                _cache.remove(suffix)
                getProps(suffix)
            }
          } else Some(props)      // not modified -- return cached
        }
      case _ => loadProps(f) map { p =>
        _cache(suffix) = (p, f.lastModified)
        p
      }
    }
  }

  protected def loadProps(file: File): Option[Properties] = {
    if (!file.isFile) None
    else {
      val is = new FileInputStream(file)
      val props = new Properties
      try {
        props.load(is)
      } finally {
        is.close()
      }
      Some(props)
    }
  }

  def fallbackSuffix(suffix: String): String = {
    val i = suffix.lastIndexOf("_")
    if (i == -1) ""
    else suffix.substring(0, i)
  }

  def localeSuffix = "_" + locale.toString

  def iterator: Iterator[(String, String)] = {
    var suffix = ""
    var result: Iterator[(String, String)] = iteratorInternal(suffix)
    localeSuffix.split("_").filter(_ != "").foreach { part =>
      suffix += "_" + part
      getProps(suffix) map { props => result ++= iteratorInternal(suffix) }
    }
    result
  }

  protected def iteratorInternal(suffix: String): Iterator[(String, String)] =
    getProps(suffix).map { props =>
      props.keys.asInstanceOf[JEnum[String]].map(k => k -> props.getProperty(k))
    }.getOrElse(Iterator.empty)

  protected def resolve(key: String): Option[String] = resolveInternal(key, localeSuffix)

  protected def resolveInternal(key: String, suffix: String): Option[String] =
    getProps(suffix).flatMap(props => any2option(props.getProperty(key))).orElse {
      if (suffix == "") None
      else resolveInternal(key, fallbackSuffix(suffix))
    }

}

case class Msg(key: String, params: (String, Any)*) {
  def param(key: String): Option[Any] = params.find(_._1 == key).map(_._2)
  def hasParam(key: String): Boolean = !params.find(_._1 == key).isEmpty
  override def toString: String = msg.fmt(key, params: _*)
}