diff --git a/src/as/com/threerings/util/MessageBundle.as b/src/as/com/threerings/util/MessageBundle.as new file mode 100644 index 000000000..60af788f4 --- /dev/null +++ b/src/as/com/threerings/util/MessageBundle.as @@ -0,0 +1,437 @@ +// +// $Id$ +// +// Flash Utils library - general purpose ActionScript utility code +// Copyright (C) 2007-2009 Three Rings Design, Inc., All Rights Reserved +// http://www.threerings.net/code/ooolib/ +// +// This library is free software; you can redistribute it and/or modify it +// under the terms of the GNU Lesser General Public License as published +// by the Free Software Foundation; either version 2.1 of the License, or +// (at your option) any later version. +// +// This library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public +// License along with this library; if not, write to the Free Software +// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +package com.threerings.util { + +import mx.resources.IResourceBundle; +import mx.resources.ResourceManager; +import mx.resources.IResourceManager; + +/** + * A message bundle provides an easy mechanism by which to obtain + * translated message strings from a resource bundle. It uses the {@link + * MessageFormat} class to substitute arguments into the translation + * strings. Message bundles would generally be obtained via the {@link + * MessageManager}, but could be constructed individually if so desired. + */ +public class MessageBundle +{ + /** + * Initializes the message bundle which will obtain localized messages + * from the supplied resource bundle. The path is provided purely for + * reporting purposes. + */ + public function init (msgmgr :MessageManager, path :String, parent :MessageBundle) :void + { + _msgmgr = msgmgr; + _path = path; + _parent = parent; + } + + /** + * Get the path to this bundle. + */ + public function getPath () :String + { + return _path; + } + + /** + * Returns true if we have a translation mapping for the supplied key, + * false if not. + */ + public function exists (key :String) :Boolean + { + return getResourceString(key, false) != null; + } + + /** + * Get all the messages that begin with the specified prefix. + * + * @param includeParent if true, messages from our parent bundle (and its + * parent bundle, all the way up the chain will be included). + */ + public function getAll (prefix :String, includeParent :Boolean = true) :Array + { + var messages :Array = [] + for each (var value :Object in getAllMapped(prefix, includeParent)) { + messages.push(value); + } + return messages; + } + + /** + * Get all the messages and their keys that begin with the specified prefix. + */ + public function getAllMapped (prefix :String, includeParent :Boolean = true) :Object + { + var mgr :IResourceManager = ResourceManager.getInstance(); + + // search all locales, using the first one first, but including any + // non-overridden keys from backing locales + var messages :Object = {}; + var key :String; + for each (var locale :String in mgr.getLocales()) { + var bundle :IResourceBundle = mgr.getResourceBundle(locale, _path); + if (bundle != null) { + for (key in bundle.content) { + // preserve the message found in an earlier locale + if (StringUtil.startsWith(key, prefix) && !(key in messages)) { + messages[key] = bundle.content[key]; + } + } + } + } + + if (includeParent && _parent != null) { + // don't let parent messages overwrite any we've found + var parentMap :Object = _parent.getAllMapped(prefix, includeParent); + for (key in parentMap) { + if (!(key in messages)) { + messages[key] = parentMap[key]; + } + } + } + return messages; + } + + /** + * Get a String from the resource bundle, or null if there was an + * error. + * + * @param key the resource key. + * @param reportMissing whether or not the method should log an error + * if the resource didn't exist. + */ + protected function getResourceString (key :String, reportMissing :Boolean = true) :String + { + var value :String = ResourceManager.getInstance().getString(_path, key); + + if (value == null) { + // if we have a parent, try getting the string from them + if (_parent != null) { + value = _parent.getResourceString(key, false); + } + // if we didn't find it in our parent, we want to fall + // through and report missing appropriately + if (value == null && reportMissing) { + Log.getLog(this).warning("Missing translation message", + "bundle", _path, "key", key); + } + } + + return value; + } + + /** + * Obtains the translation for the specified message key. The + * specified arguments are substituted into the translated string. + * + *

If the first argument in the array is an {@link Integer} + * object, a translation will be selected accounting for plurality in + * the following manner. Assume a message key of + * m.widgets, the following translations should be + * defined: + *

+     * m.widgets.0 = no widgets.
+     * m.widgets.1 = {0} widget.
+     * m.widgets.n = {0} widgets.
+     * 
+ * + * The specified argument is substituted into the translated string as + * appropriate. Consider using: + * + *
+     * m.widgets.n = {0,number,integer} widgets.
+     * 
+ * + * to obtain proper insertion of commas and dots as appropriate for + * the locale. + * + *

See {@link MessageFormat} for more information on how the + * substitution is performed. If a translation message does not exist + * for the specified key, an error is logged and the key itself (plus + * the arguments) is returned so that the caller need not worry about + * handling a null response. + */ + public function get (key :String, ... args) :String + { + // if this string is tainted, we don't translate it, instead we + // simply remove the taint character and return it to the caller + if (key.charAt(0) === TAINT_CHAR) { + return key.substring(1); + } + + args = Util.unfuckVarargs(args); + + // if this is a qualified key, we need to pass the buck to the + // appropriate message bundle + if (key.indexOf(QUAL_PREFIX) == 0) { + var qbundle :MessageBundle = _msgmgr.getBundle(getBundle(key)); + return qbundle.get(getUnqualifiedKey(key), args); + } + + // Select the proper suffix if our first argument can be coaxed into an integer + var suffix :String = getSuffix(args); + var msg :String = getResourceString(key + suffix, false); + + if (msg == null) { + if (suffix != "") { + // Try the original key + msg = getResourceString(key, false); + } + + // if the msg is still missing, we have a problem + if (msg == null) { + Log.getLog(this).warning("Missing translation message", + "bundle", _path, "key", key); + + // return something bogus + return (key + args); + } + } + + return StringUtil.substitute(msg, args); + } + + /** + * A helper function for {@link #get(String,Object[])} that allows us + * to automatically perform plurality processing if our first argument + * can be coaxed to an {@link Integer}. + */ + protected function getSuffix (args :Array) :String + { + if (args.length > 0 && args[0] != null) { + try { + var count :int = (args[0] is int) ? int(args[0]) : + StringUtil.parseInteger(String(args[0])); + switch (count) { + case 0: return ".0"; + case 1: return ".1"; + default: return ".n"; + } + } catch (err :ArgumentError) { + // fall out + } + } + return ""; + } + + /** + * Obtains the translation for the specified compound message key. A + * compound key contains the message key followed by a tab separated + * list of message arguments which will be subsituted into the + * translation string. + * + *

See {@link MessageFormat} for more information on how the + * substitution is performed. If a translation message does not exist + * for the specified key, an error is logged and the key itself (plus + * the arguments) is returned so that the caller need not worry about + * handling a null response. + */ + public function xlate (compoundKey :String) :String + { + // if this is a qualified key, we need to pass the buck to the + // appropriate message bundle; we have to do it here because we + // want the compound arguments of this key to be translated in the + // context of the containing message bundle qualification + if (compoundKey.indexOf(QUAL_PREFIX) == 0) { + var qbundle :MessageBundle = _msgmgr.getBundle( + getBundle(compoundKey)); + return qbundle.xlate(getUnqualifiedKey(compoundKey)); + } + + // to be more efficient about creating unnecessary objects, we + // do some checking before splitting + var tidx :int = compoundKey.indexOf("|"); + if (tidx == -1) { + return get(compoundKey); + + } else { + var key :String = compoundKey.substring(0, tidx); + var argstr :String = compoundKey.substring(tidx+1); + var args :Array = argstr.split("|"); + // unescape and translate the arguments + for (var ii :int = 0; ii < args.length; ii++) { + // if the argument is tainted, do no further translation + // (it might contain |s or other fun stuff) + if (args[ii].indexOf(TAINT_CHAR) == 0) { + args[ii] = unescape(args[ii].substring(1)); + } else { + args[ii] = xlate(unescape(args[ii])); + } + } + return get(key, args); + } + } + + /** + * Call this to "taint" any string that has been entered by an entity + * outside the application so that the translation code knows not to + * attempt to translate this string when doing recursive translations + * (see {@link #xlate}). + */ + public static function taint (text :Object) :String + { + return TAINT_CHAR + text; + } + + /** + * Composes a message key with an array of arguments. The message can + * subsequently be translated in a single call using {@link #xlate}. + */ + public static function compose (key :String, ... args) :String + { + args = Util.unfuckVarargs(args); + + var buf :StringBuilder = new StringBuilder(); + buf.append(key, "|"); + for (var ii :int = 0; ii < args.length; ii++) { + if (ii > 0) { + buf.append("|"); + } + var arg :String = String(args[ii]); + for (var p :int = 0; p < arg.length; p++) { + var ch :String = arg.charAt(p); + if (ch == "|") { + buf.append("\\!"); + } else if (ch == "\\") { + buf.append("\\\\"); + } else { + buf.append(ch); + } + } + } + return buf.toString(); + } + + /** + * A convenience method for calling {@link #compose(String,Object[])} + * with a single argument that will be automatically tainted (see + * {@link #taint}). + */ + public static function tcompose (key :String, ... args) :String + { + args = Util.unfuckVarargs(args); + + for (var ii :int = 0; ii < args.length; ii++) { + args[ii] = taint(args[ii]); + } + return compose(key, args); + } + + /** + * Returns a fully qualified message key which, when translated by + * some other bundle, will know to resolve and utilize the supplied + * bundle to translate this particular key. + */ + public static function qualify (bundle :String, key :String) :String + { + if (bundle.indexOf(QUAL_PREFIX) != -1 || + bundle.indexOf(QUAL_SEP) != -1) { + throw new Error("Message bundle may not contain " + QUAL_PREFIX + + " or " + QUAL_SEP); + } + + return QUAL_PREFIX + bundle + QUAL_SEP + key; + } + + /** + * Returns the bundle name from a fully qualified message key. + * + * @see #qualify + */ + public static function getBundle (qualifiedKey :String) :String + { + if (qualifiedKey.indexOf(QUAL_PREFIX) != 0) { + throw new Error(qualifiedKey + + " is not a fully qualified message key."); + } + var qsidx :int = qualifiedKey.indexOf(QUAL_SEP); + if (qsidx == -1) { + throw new Error(qualifiedKey + + " is not a valid fully qualified key."); + } + + return qualifiedKey.substring(QUAL_PREFIX.length, qsidx); + } + + /** + * Returns the unqualified portion of the key from a fully qualified + * message key. + * + * @see #qualify + */ + public static function getUnqualifiedKey (qualifiedKey :String) :String + { + if (qualifiedKey.indexOf(QUAL_PREFIX) != 0) { + throw new Error(qualifiedKey + + " is not a fully qualified message key."); + } + var qsidx :int = qualifiedKey.indexOf(QUAL_SEP); + if (qsidx == -1) { + throw new Error(qualifiedKey + + " is not a fully qualified message key."); + } + return qualifiedKey.substring(qsidx + 1); + } + + /** + * Unescapes characters that are escaped in a call to compose. + */ + public static function unescape (val :String) :String + { + var bsidx :int = val.indexOf("\\"); + if (bsidx == -1) { + return val; + } + + var buf :StringBuilder = new StringBuilder(); + for (var ii :int = 0; ii < val.length; ii++) { + var ch :String = val.charAt(ii); + if (ch != "\\" || ii == val.length-1) { + buf.append(ch); + } else { + // look at the next character + ch = val.charAt(++ii); + buf.append((ch == "!") ? "|" : ch); + } + } + + return buf.toString(); + } + + /** The message manager via whom we'll resolve fully qualified + * translation strings. */ + protected var _msgmgr :MessageManager; + + /** The path that identifies the resource bundle we are using to + * obtain our messages. */ + protected var _path :String; + + /** Our parent bundle if we're not the global bundle. */ + protected var _parent :MessageBundle; + + protected static const TAINT_CHAR :String = "~"; + protected static const QUAL_PREFIX :String = "%"; + protected static const QUAL_SEP :String = ":"; +} +} diff --git a/src/as/com/threerings/util/MessageManager.as b/src/as/com/threerings/util/MessageManager.as new file mode 100644 index 000000000..537272304 --- /dev/null +++ b/src/as/com/threerings/util/MessageManager.as @@ -0,0 +1,125 @@ +// +// $Id$ +// +// Flash Utils library - general purpose ActionScript utility code +// Copyright (C) 2007-2009 Three Rings Design, Inc., All Rights Reserved +// http://www.threerings.net/code/ooolib/ +// +// This library is free software; you can redistribute it and/or modify it +// under the terms of the GNU Lesser General Public License as published +// by the Free Software Foundation; either version 2.1 of the License, or +// (at your option) any later version. +// +// This library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public +// License along with this library; if not, write to the Free Software +// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +package com.threerings.util { + +import mx.resources.IResourceBundle; +import mx.resources.Locale; +import mx.resources.ResourceManager; + +/** + * The message manager provides a thin wrapper around Java's built-in + * localization support, supporting a policy of dividing up localization + * resources into logical units, all of the translations for which are + * contained in a single messages file. + * + *

The message manager assumes that the locale remains constant for + * the duration of its operation. If the locale were to change during the + * operation of the client, a call to {@link #setLocale} should be made to + * inform the message manager of the new locale (which will clear the + * message bundle cache). + */ +public class MessageManager +{ + /** The name of the global resource bundle (which other bundles revert + * to if they can't locate a message within themselves). It must be + * named global.properties and live at the top of the + * bundle hierarchy. */ + public static const GLOBAL_BUNDLE :String = "global"; + + /** + * Constructs a message manager with the supplied resource prefix and + * the default locale. The prefix will be prepended to the path of all + * resource bundles prior to their resolution. For example, if a + * prefix of rsrc.messages was provided and a message + * bundle with the name game.chess was later requested, + * the message manager would attempt to load a resource bundle with + * the path rsrc.messages.game.chess and would eventually + * search for a file in the classpath with the path + * rsrc/messages/game/chess.properties. + * + *

See the documentation for {@link + * ResourceBundle#getBundle(String,Locale,ClassLoader)} for a more + * detailed explanation of how resource bundle paths are resolved. + */ + public function MessageManager () + { + // load up the global bundle + _global = getBundle(GLOBAL_BUNDLE); + } + + /** + * Fetches the message bundle for the specified path. If no bundle can + * be located with the specified path, a special bundle is returned + * that returns the untranslated message identifiers instead of an + * associated translation. This is done so that error code to handle a + * failed bundle load need not be replicated wherever bundles are + * used. Instead an error will be logged and the requesting service + * can continue to function in an impaired state. + */ + public function getBundle (path :String) :MessageBundle + { + // first look in the cache + var bundle :MessageBundle = (_cache.get(path) as MessageBundle); + if (bundle != null) { + return bundle; + } + + // see if we should use a custom class for the bundle + var mbclass :String = ResourceManager.getInstance().getString(path, MBUNDLE_CLASS_KEY); + if (mbclass != null) { + try { + var clazz :Class = ClassUtil.getClassByName(String(mbclass)); + bundle = new clazz(); + } catch (ee :Error) { + Log.getLog(this).warning("Failure instantiating custom message bundle", + "mbclass", mbclass, ee); + } + } + // if there was no custom class, or we failed to instantiate the + // custom class, use a standard message bundle + if (bundle == null) { + bundle = new MessageBundle(); + } + + // initialize our message bundle, cache it and return it (if we + // couldn't resolve the bundle, the message bundle will cope with + // it's null resource bundle) + bundle.init(this, path, _global); + _cache.put(path, bundle); + return bundle; + } + + /** The locale for which we're obtaining message bundles. */ + protected var _locale :String; + + /** A cache of instantiated message bundles. */ + protected var _cache :Map = Maps.newMapOf(String); + + /** Our top-level message bundle, from which others obtain messages if + * they can't find them within themselves. */ + protected var _global :MessageBundle; + + /** A key that can contain the classname of a custom message bundle + * class to be used to handle messages for a particular bundle. */ + protected static const MBUNDLE_CLASS_KEY :String = "msgbundle_class"; +} +} diff --git a/src/as/com/threerings/util/TimeUtil.as b/src/as/com/threerings/util/TimeUtil.as new file mode 100644 index 000000000..1c6e9eab4 --- /dev/null +++ b/src/as/com/threerings/util/TimeUtil.as @@ -0,0 +1,139 @@ +// +// $Id$ +// +// Flash Utils library - general purpose ActionScript utility code +// Copyright (C) 2007-2009 Three Rings Design, Inc., All Rights Reserved +// http://www.threerings.net/code/ooolib/ +// +// This library is free software; you can redistribute it and/or modify it +// under the terms of the GNU Lesser General Public License as published +// by the Free Software Foundation; either version 2.1 of the License, or +// (at your option) any later version. +// +// This library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public +// License along with this library; if not, write to the Free Software +// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +package com.threerings.util { + +/** + * Utility for times. + */ +public class TimeUtil +{ + /** Time unit constant. */ + public static const MILLISECOND :int = 0; + + /** Time unit constant. */ + public static const SECOND :int = 1; + + /** Time unit constant. */ + public static const MINUTE :int = 2; + + /** Time unit constant. */ + public static const HOUR :int = 3; + + /** Time unit constant. */ + public static const DAY :int = 4; + + // TODO: Weeks?, months? + protected static const MAX_UNIT :int = DAY; + + /** + * Get a translatable string specifying the magnitude of the specified + * duration. Results will be between "1 second" and "X hours", with + * all times rounded to the nearest unit. "0 units" will never be + * displayed, the minimum is 1. + */ + public static function getTimeOrderString ( + duration :Number, minUnit :int, maxUnit :int = -1) :String + { + // enforce sanity + if (maxUnit == -1) { + maxUnit = MAX_UNIT; + } + minUnit = Math.min(minUnit, maxUnit); + maxUnit = Math.min(maxUnit, MAX_UNIT); + + for (var uu :int = MILLISECOND; uu <= MAX_UNIT; uu++) { + var quantity :int = getQuantityPerUnit(uu); + if ((minUnit <= uu) && (duration < quantity || maxUnit == uu)) { + duration = Math.max(1, duration); + return MessageBundle.tcompose(getTransKey(uu), + String(duration)); + } + duration = Math.round(duration / quantity); + } + + // will not happen, because eventually uu will be MAX_UNIT + return null; + } + + /** + * Get a translatable string specifying the duration, down to the + * minimum granularity. + */ + public static function getTimeString (duration :Number, minUnit :int) :String + { + // sanity + minUnit = Math.min(minUnit, MAX_UNIT); + duration = Math.abs(duration); + + var list :Array = new Array(); + var parts :int = 0; // how many parts are in the translation string? + for (var uu :int = MILLISECOND; uu <= MAX_UNIT; uu++) { + var quantity :int = getQuantityPerUnit(uu); + if (minUnit <= uu) { + list.push(MessageBundle.tcompose(getTransKey(uu), String(duration % quantity))); + parts++; + } + duration /= quantity; + if (duration <= 0 && parts > 0) { + break; + } + } + + if (parts == 1) { + return (list[0] as String); + } else { + return MessageBundle.compose("m.times_" + parts, list); + } + } + + /** + * Internal method to get the quantity for the specified unit. + * (Not very OO) + */ + protected static function getQuantityPerUnit (unit :int) :int + { + switch (unit) { + case MILLISECOND: return 1000; + case SECOND: case MINUTE: return 60; + case HOUR: return 24; + case DAY: return int.MAX_VALUE; + default: return -1; + } + } + + /** + * Internal method to get the translation key for the specified unit. + * (Not very OO) + */ + protected static function getTransKey (unit :int) :String + { + switch (unit) { + case MILLISECOND: return "m.millisecond"; + case SECOND: return "m.second"; + case MINUTE: return "m.minute"; + case HOUR: return "m.hour"; + case DAY: return "m.day"; + default: return null; + } + } +} +}