Переглянути джерело

i18n preimplementation, library import

milan jurkulák 2 роки тому
батько
коміт
703ffeead4

+ 5 - 0
build.gradle.kts

@@ -28,6 +28,7 @@ allprojects {
         mavenCentral()
     }
     dependencies {
+        api(fileTree("src/main/libs") { include("*.jar") })
         // reflection
 //        implementation(kotlin("reflect"))
         // coroutines
@@ -78,6 +79,8 @@ allprojects {
         // dbus
         implementation(libs.dbus.java.core)
         implementation(libs.dbus.java.transport.native.unixsocket)
+        // gettext
+        implementation("org.gnu.gettext:libintl:0.18.3")
         // paths
 //        implementation("me.sujanpoudel.multiplatform.utils:multiplatform-paths:0.2.2")
         // files
@@ -104,6 +107,8 @@ allprojects {
         implementation("com.composables:core:1.12.0")
         // lib-app-indicator
         implementation("org.purejava:libappindicator-gtk3-java-full:1.4.1")
+        // internationalization
+//        implementation("org.swiftshire:ji18n-core:1.0")
         // sikulix
 //        implementation("com.sikulix:sikulixapi:2.0.5")
         // blur

+ 1682 - 0
src/main/java/com/teamunify/i18n/I.java

@@ -0,0 +1,1682 @@
+package com.teamunify.i18n;
+
+//import static org.apache.commons.lang.StringEscapeUtils.escapeJavaScript;
+
+import com.teamunify.i18n.settings.*;
+import gnu.gettext.GettextResource;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.text.DateFormat;
+import java.text.DecimalFormat;
+import java.text.DecimalFormatSymbols;
+import java.text.MessageFormat;
+import java.text.NumberFormat;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import com.teamunify.i18n.escape.EscapeFunction;
+import com.teamunify.i18n.settings.DateFormatVendor;
+import com.teamunify.i18n.wiki.SimpleWikifier;
+import com.teamunify.i18n.wiki.Wikifier;
+
+///**
+// * The central translation facility. Use the static methods like tr() to translate messages.
+// * <p/>
+// * <p/>
+// * Implementation details:
+// * <p/>
+// * <ul>
+// * <li>Uses GNU gettext java library (libintl.jar) to pull messages from ResourceBundle classes
+// * <li>The ResourceBundle classes are <b>generated</b> by the GNU utility msgfmt, and are packaged up into a JAR file by
+// * the build process for deployment. They are in gen/ on the project during development.
+// * <li>The AbstractLocaleFilter is responsible for putting the resource bundle in a place that the translation functions
+// * can find it automatically (thread local variable).
+// * <li>MessageFormat is used underneath for date, time, currency, and number formatting. @see java.text.MessageFormat.
+// * </ul>
+// * <p/>
+// * <p/>
+// * <b>IMPORTANT:</b> The language strings MUST be literals. The gettext utilities cannot extract language strings from
+// * variables. This isn't that hard. For example, this is fine:
+// * <p/>
+// * <pre>
+// * String s = I.tr(&quot;message&quot;);
+// * System.out.println(s);
+// * </pre>
+// * <p/>
+// * but this is <b>not</b>:
+// * <p/>
+// * <pre>
+// * String s = &quot;message&quot;;
+// * System.out.println(I.tr(s));
+// * </pre>
+// * <p/>
+// * The reason for this is that the extraction utilities are not doing code analysis. They are doing pattern matching on
+// * the function call tr, trc, trf, etc.
+// * <p/>
+// * To use literal strings in JSPs, escape to Java:
+// * <p/>
+// * <pre>
+// *    <html fragment>
+// *    <%= I.tr("message") %>
+// *    ...
+// * </pre>
+// * <p/>
+// * <h2>BUILD NOTES</h2>
+// * The build system has to do the following tasks in order for translations to work right:
+// * <ul>
+// * <li>Extract the strings from the java/jsp (uses GNU command line: xgettext)
+// * <li>Merge the msgid (keys) into the supported language files (GNU command line util: msgmerge) (there are msgs/*.po
+// * files)
+// * <li>Compile the .po files into ResourceBundle Java classes (GNU command line util: msgfmt -java2)
+// * <li>Deployment has to package up the generated ResourceBundle classes (as a jar file) and include them in the WAR.
+// * </ul>
+// *
+// * @author tonykay
+// * @see MessageFormat
+// * @see com.teamunify.i18n.webapp.AbstractLocaleFilter
+// * @see ThreadLocalLanguageSetting
+// * @see com.teamunify.i18n.webapp.AbstractLocaleFilter
+// */
+public final class I {
+    private static BooleanFunction<Date> nullDateTest = new BooleanFunction<Date>() {
+        public boolean apply(Date d) {
+            return d == null;
+        }
+    };
+    private static Date defaultDate = null;
+    private static Logger log = LoggerFactory.getLogger(I.class);
+    private static LanguageSettingsProvider languageProvider = new ThreadLocalLanguageSettingsProvider();
+
+    private static ConcurrentHashMap<String, Locale> defaultLocales = new ConcurrentHashMap<String, Locale>();
+
+    /**
+     * Set the locale that should be returned from getDefaultLocaleForLanguage() when the specified (plain) language
+     * (without country) is given. Also affects getLocale(String).
+     * <p/>
+     * <p>
+     * This function does nothing if language is null.
+     * </p>
+     * <p>
+     * This function removes the mapping if the locale is null.
+     * </p>
+     *
+     * @param language The language code (e.g. "en", "fr", etc)
+     * @param locale   The Locale. You can map to an alternate language if you want, but it is not recommended. Passing
+     *                 null removes the mapping for that language.
+     */
+    public static void setDefaultLocaleForLanguage(String language, Locale locale) {
+        if (language == null)
+            return;
+        if (locale == null)
+            defaultLocales.remove(language);
+        else
+            defaultLocales.put(language, locale);
+    }
+
+    /**
+     * Get the default locale for a plain language (so that currency and other country-specific support will work properly).
+     *
+     * @param language The language
+     * @return A Locale for that language, with the country set to a default if there has been a prior call to setDefaultLocaleForLanguage() with that language.
+     */
+    public static Locale getDefaultLocaleForLanguage(String language) {
+        Locale rv = defaultLocales.get(language);
+        if (rv == null)
+            return new Locale(language);
+        return rv;
+    }
+
+    /**
+     * Get a date format object for the given format ID. DateFormat.{SHORT,MEDIUM,LONG} are guaranteed to work. If you
+     * have registered custom date formats on locales, then those custom codes will work as well. If the primary is not
+     * available, it will return the altFormatID instead. It is recommended you pass SHORT/MEDIUM/LONG as the alternate to
+     * ensure you do not get null.
+     *
+     * @param formatID    The format you want
+     * @param altFormatID The DateFormat format you'll accept if formatID is not found for the current Locale
+     * @return The format, or null if neither the primary or secondary can be found.
+     */
+    public static DateFormat getDateFormat(int formatID, int altFormatID) {
+        LanguageSetting provider = languageProvider.vend();
+        return dateFormatVendor.getFormatFor(formatID, provider.locale, altFormatID);
+    }
+
+    /**
+     * Get the pattern used by the given date format.
+     *
+     * @param formatID    The formatID
+     * @param altFormatID An alternate
+     * @return The pattern (always returns a pattern, SHORT if none found).
+     */
+    public static String getDateFormatter(int formatID, int altFormatID) {
+        return ((SimpleDateFormat) getDateFormat(formatID, altFormatID)).toPattern();
+    }
+
+    public static String getDateFormatter(int formatID) {
+        return ((SimpleDateFormat) getDateFormat(formatID, DateFormat.SHORT)).toPattern();
+    }
+
+    /**
+     * Translate a literal message to the user's current language. This method is the most efficient to use, as it merely
+     * needs to look up the translation, but need not apply any extra formatting.
+     *
+     * @param msg The english-language message (which will also be the default if there is no translation).
+     * @return The translated string, or msg if there is none escaped via the default escape mechanism.
+     */
+    public static String tr(String msg) {
+        return escapeFunction.escape(tru(msg));
+    }
+
+    /**
+     * Same as tr, but use the specified escape function.
+     */
+    public static String tr(String msg, EscapeFunction f) {
+        return f.escape(tru(msg));
+    }
+
+    /**
+     * Translate a string, but do NOT escape it. This is shorthand for tr("msg", EscapeFunction.NoEscape).
+     *
+     * @param msg The string to translate.
+     * @return The translated string.
+     */
+    public static String tru(String msg) {
+        LanguageSetting s = languageProvider.vend();
+        return GettextResource.gettext(s.translation, msg);
+    }
+
+    /**
+     * Translate a message with wiki markup. These are separate functions that allow you to take the overhead of
+     * translating wiki markup only when needed. The translated message is escaped by your escape function, but
+     * the wikifier's output, of course, is not.
+     * <p/>
+     * The supported wiki markup is documented in the wikified() function.
+     * <p/>
+     * IMPORTANT: Currently this does not support combinations of modifiers. E.g. You cannot have bold and underline.
+     *
+     * @param msg A string that can contain special markup.
+     * @return The translation, with wiki markup turned into HTML
+     * @see I#wikified
+     */
+    public static String trw(String msg) {
+        return wikified(tr(msg));
+    }
+
+    /**
+     * Same as trw, but with custom escape function.
+     */
+    public static String trw(String msg, EscapeFunction f) {
+        return wikified(tr(msg, f));
+    }
+
+    /**
+     * Translate a string in the given context. Useful for resolving the possible differences in things like single words
+     * (e.g. noun vs. verb form).
+     * <p/>
+     * <p/>
+     * For example:
+     * <p/>
+     * <pre>
+     * I.trc(&quot;adjective&quot;, &quot;Running&quot;); // e.g. Running task
+     * I.trc(&quot;single word meaning 'execute a task'&quot;, &quot;Run&quot;);
+     * </pre>
+     *
+     * @param context The context (you make this up...it could be a part of speech, definition, etc. e.g. "noun", "adjective",
+     *                "execute a task")
+     * @param msg     The message to translate
+     * @return The translated message, or msg if there is none.
+     */
+    public static String trc(String context, String msg) {
+        return trc(context, msg, escapeFunction);
+    }
+
+    /**
+     * Same as trc, but with custom escape function.
+     */
+    public static String trc(String context, String msg, EscapeFunction f) {
+        LanguageSetting s = languageProvider.vend();
+        return f.escape(GettextResource.pgettext(s.translation, context, msg));
+    }
+
+    /**
+     * Just like trc, but wikified.
+     *
+     * @see I#wikified
+     */
+    public static String trcw(String context, String src) {
+        return wikified(trc(context, src, escapeFunction));
+    }
+
+    public static String trcw(String context, String src, EscapeFunction f) {
+        return wikified(trc(context, src, f));
+    }
+
+    /**
+     * Just like trcf, but wikified.
+     *
+     * @see I#wikified
+     */
+    public static String trcfw(String context, String src, Object... args) {
+        return wikified(trcf(context, src, args));
+    }
+
+    public static String trcfw(EscapeFunction f, String context, String src, Object... args) {
+        return wikified(trcf(f, context, src, args));
+    }
+
+    /**
+     * Translate a message that includes placeholders to format.
+     * <p/>
+     * This function translates the text, then uses java.text.MessageFormat to do the actual parameter substitution.
+     * <p/>
+     * <p/>
+     * Examples:
+     * <p/>
+     * <pre>
+     * int n = f();
+     * String noun = n &gt; 5 ? I.tr(&quot;noun&quot;, &quot;birds&quot;) : I.tr(&quot;noun&quot;, &quot;cats&quot;);
+     * String adj = n &gt; 3 ? I.tr(&quot;adjective&quot;, &quot;tall&quot;) : I.tr(&quot;adjective&quot;, &quot;short&quot;);
+     * double amt = 4.99;
+     * String message = I.trf(&quot;There are {0, number} {1} in the {2} tree. The {1} is worth {3, number, currency}&quot;, n, noun,
+     *                        adj, amt);
+     * </pre>
+     * <p/>
+     * <p/>
+     * <b>IMPORTANT</b>: Single quotes (apostrophe) and { are SPECIAL here! If you need a literal apostrophe in a
+     * formatted string, use two. If you need a literal {, use '{'. @see java.text.MessageFormat.
+     * <p/>
+     * <pre>
+     * String message = I.trf(&quot;Sam''s bucket contains {0, number} {1}.&quot;, n, noun);
+     * </pre>
+     *
+     * @param msg  The message
+     * @param args A comma-separated list of arguments to put in the placeholders
+     * @return The translated string, or a formatted version of msg if there is none.
+     * @see MessageFormat
+     */
+    public static String trf(String msg, Object... args) {
+        return trf(escapeFunction, msg, args);
+    }
+
+    public static String trf(EscapeFunction f, String msg, Object... args) {
+        return f.escape(trfu(msg, args));
+    }
+
+    /**
+     * Alias for trf(EscapeFunction.NoEscape, msg, args)
+     */
+    public static String trfu(String msg, Object... args) {
+        LanguageSetting s = languageProvider.vend();
+        String xlation = GettextResource.gettext(s.translation, msg);
+        s.formatter.applyPattern(xlation);
+        return s.formatter.format(args);
+    }
+
+    /**
+     * Just like trcf, but wikified.
+     *
+     * @see I#trf(String, Object...)
+     */
+    public static String trfw(String src, Object... args) {
+        return trfw(escapeFunction, src, args);
+    }
+
+    public static String trfw(EscapeFunction f, String src, Object... args) {
+        return wikified(trf(f, src, args));
+    }
+
+    /**
+     * Translate a message with context and arguments.
+     *
+     * @param context The context.
+     * @param msg     The message
+     * @param args    The argument to put in msg
+     * @return The translation
+     * @see I#trc(String, String)
+     * @see I#trf(String, Object...)
+     */
+    public static String trcf(String context, String msg, Object... args) {
+        return trcf(escapeFunction, context, msg, args);
+    }
+
+    public static String trcf(EscapeFunction f, String context, String msg, Object... args) {
+        LanguageSetting s = languageProvider.vend();
+        String xlation = GettextResource.pgettext(s.translation, context, msg);
+        s.formatter.applyPattern(xlation);
+        return f.escape(s.formatter.format(args));
+    }
+
+    /**
+     * Format a string that varies based on plural forms. It supports MessageFormat strings and wiki markup.
+     * <p/>
+     * <p/>
+     * Example:
+     * <p/>
+     * <pre>
+     * int nitems;
+     * int arg0 = nitems;
+     * I.tr_plural(&quot;There is {0} file that matches&quot;, &quot;There are {0} files that match.&quot;, nitems, arg0);
+     * </pre>
+     * <p/>
+     * <p/>
+     * When nitems is 1, it returns "There is 1 file that matches", otherwise (say for 6) "There are 6 files that match".
+     *
+     * @param singular                        The singular form (nitems is 1, for English)
+     * @param plural                          The plural form (nitems is 0 or >1, for English)
+     * @param nitems_for_plural_determination The number of items. This can be modulo 1000, since no known languages have a difference form above about
+     *                                        100. THIS ARGUMENT IS USED FOR PLURAL DETERMINATION ONLY. IT IS NOT PLACED IN THE STRING.
+     * @param args                            The arguments to use in the formatted string.
+     * @return The formatted string
+     * @see I#wikified
+     */
+    public static String tr_plural(String singular, String plural, int nitems_for_plural_determination, Object... args) {
+        return tr_plural(escapeFunction, singular, plural, nitems_for_plural_determination, args);
+    }
+
+    public static String tr_plural(EscapeFunction f, String singular, String plural, int nitems_for_plural_determination,
+                                   Object... args) {
+        LanguageSetting s = languageProvider.vend();
+        String xlation = GettextResource.ngettext(s.translation, singular, plural, nitems_for_plural_determination);
+        s.formatter.applyPattern(xlation);
+        return f.escape(s.formatter.format(args));
+    }
+
+    public static String tr_pluralw(String singular, String plural, int nitems_for_plural_determination, Object... args) {
+        return wikified(tr_plural(singular, plural, nitems_for_plural_determination, args));
+    }
+
+    public static String tr_pluralw(EscapeFunction f, String singular, String plural, int nitems_for_plural_determination,
+                                    Object... args) {
+        return wikified(tr_plural(f, singular, plural, nitems_for_plural_determination, args));
+    }
+
+    /**
+     * Set the current language. In a webapp, this is typically done via the AbstractLocaleFilter. In other places (e.g.
+     * applications, cron jobs, etc.), you will likely need to set this in main.
+     * <p/>
+     * If no country portion is in the name, the defaults are used (I.setDefaultLocaleForLanguage)
+     *
+     * @param name The language code (two letters, followed by optional _ and two-letter country). E.g. en es de en_US en_AU.
+     */
+    public static void setLanguage(String name) {
+        setLanguage(getLocale(name));
+    }
+
+    /**
+     * Get a locale using the lang_country code (e.g. en_US). If the country portion of name is missing, it uses the
+     * prior setting of default locale for language (setDefaultLocaleForLanguage(String, Locale))
+     *
+     * @param name The lang_country designator. "en_US", "fr_FR", etc. If the country is not supplied, an attempt will
+     *             be made to find a default (@see setDefaultLocaleForLanguage(String, Locale)
+     */
+    public static Locale getLocale(String name) {
+        final String langOnly = name != null && name.contains("_") ? name.substring(0, 2) : name;
+        String countryOnly = extractCountry(name, langOnly);
+
+        if (countryOnly.isEmpty()) {
+            if (defaultLocales.containsKey(langOnly))
+                return getDefaultLocaleForLanguage(langOnly);
+            return new Locale(langOnly);
+        } else
+            return new Locale(langOnly, countryOnly);
+    }
+
+    private static String extractCountry(String name, String language) {
+        String countryOnly = "";
+        countryOnly = name != null && name.contains("_") ? name.substring(3) : "";
+
+        if (!countryOnly.isEmpty()) {
+            boolean found = false;
+            for (Locale l : Locale.getAvailableLocales()) {
+                if (l.getCountry().equals(countryOnly)) {
+                    found = true;
+                    break;
+                }
+            }
+            if (!found)
+                return "";
+        }
+
+        return countryOnly;
+    }
+
+    public static void setLanguage(Locale l) {
+        languageProvider.setLocale(l);
+    }
+
+    /**
+     * Return the system-default language code for this installation.
+     * <p/>
+     * TODO: Allow application to set the "default" locale.
+     *
+     * @return Currently returns Locale.getDefault().
+     */
+    public static LanguageSetting getDefaultLanguage() {
+        return new LanguageSetting(Locale.getDefault());
+    }
+
+    public static LanguageSetting getCurrentLanguage() {
+        return languageProvider.vend();
+    }
+
+    /**
+     * Test if a language code is supported by our translation files.
+     *
+     * @param lang The language code, e.g. "de".
+     * @return True if the language has translations.
+     */
+    public static boolean supports(String lang) {
+        if (lang == null || lang.length() == 0)
+            return false;
+        String parts[] = lang.split("_");
+        if (parts.length == 1)
+            return supports(lang, "");
+        else if (parts.length == 2)
+            return supports(parts[0], parts[1]);
+
+        return false;
+    }
+
+    /**
+     * Are translations loaded that give at least language-level support for the given locale
+     */
+    public static boolean supports(Locale l) {
+        LanguageSetting setting = new LanguageSetting(l);
+        return setting.translation != LanguageSetting.emptyLanguageBundle;
+    }
+
+    /**
+     * Are translations loaded that give at least language-level support for the given locale
+     */
+    public static boolean supports(String lang, String country) {
+        return supports(new Locale(lang, country));
+    }
+
+    /**
+     * Convert a date to a string using the default date format.
+     *
+     * @param d
+     * @return
+     */
+    public static String dateToString(Date d) {
+        return dateToString(d, DateFormatVendor.DEFAULT_DATE_FORMAT_ID);
+    }
+
+    /**
+     * Convert a date to the specified style.
+     *
+     * @param d     The date to format
+     * @param style One of DateFormat formats (e.g. SHORT/LONG/MEDIUM)
+     * @return the locale-corrected string version of the date.
+     */
+    public static String dateToString(Date d, int style) {
+        if (isNullDate(d))
+            return "";
+        LanguageSetting s = languageProvider.vend();
+        DateFormat formatter = dateFormatVendor.getFormatFor(style, s.locale, DateFormat.SHORT);
+        return formatter.format(d);
+    }
+
+    /**
+     * A handy function to set the default date output type to SHORT
+     *
+     * @param d The date to format
+     * @return the locale-corrected string version of the date.
+     */
+    public static String dateToShortString(Date d) {
+        if (isNullDate(d))
+            return "";
+        else
+            return dateToString(d, DateFormat.SHORT);
+    }
+
+    /**
+     * Convert a date object (which holds significant time as well) to a string that includes the date and time.
+     *
+     * @param d The date
+     * @return A timestamp string
+     */
+    public static String timestampToString(Date d) {
+        if (isNullDate(d))
+            return "";
+        else
+            return timestampToString(d, DateFormatVendor.DEFAULT_DATE_FORMAT_ID, false, true);
+    }
+
+    public static String timestampToString(Date d, boolean timeOnly, boolean showSeconds) {
+        return timestampToString(d, DateFormatVendor.DEFAULT_DATE_FORMAT_ID, timeOnly, showSeconds, false);
+    }
+
+    public static String timestampToString(Date d, int fmtID, boolean timeOnly, boolean showSeconds) {
+        return timestampToString(d, fmtID, timeOnly, showSeconds, false);
+    }
+
+    public static String timestampToString(Date d, boolean timeOnly, boolean showSeconds, boolean showTimezone) {
+        return timestampToString(d, DateFormatVendor.DEFAULT_DATE_FORMAT_ID, timeOnly, showSeconds, showTimezone);
+    }
+
+    public static String timestampToString(Date d, int dateFmtID, boolean timeOnly, boolean showSeconds,
+                                           boolean showTimezone) {
+        if (isNullDate(d))
+            return "";
+        LanguageSetting s = languageProvider.vend();
+        DateFormat dFormatter = dateFormatVendor.getFormatFor(dateFmtID, s.locale, DateFormat.SHORT);
+        String strTime =
+                (showSeconds ? s.getLongTimeFormat().format(d) : s.getShortTimeFormat().format(d))
+                        + (showTimezone ? " " + getTimeZone().getDisplayName(getTimeZone().inDaylightTime(d), TimeZone.SHORT) : "");
+        if (timeOnly)
+            return strTime;
+        else
+            return dateToString(d, dateFmtID) + " " + strTime;
+    }
+
+    /**
+     * Attempts to parse the given date using the current language's locale, accepting any non-ambiguous date string
+     * imaginable in that locale. This function accepts any legal date format for the given locale...
+     * <p/>
+     * <p/>
+     * For example, in the US locale, this function will correctly accept ANY of 1/1/93, 01/01/93, 1/1/1993, 1993-01-01,
+     * Jan 1, 1993, January 1, 2011.
+     * <p/>
+     * <p/>
+     * ALL locales always accept the ISO standard YYYY-MM-DD as a fallback, which is useful when interacting with SQL.
+     *
+     * @param source The source date string. Can be a locale-specific string. Always accepts YYYY-MM-DD as a fallback.
+     * @return The date. If parsing fails, the date will be whatever you have your defaultDate set to
+     */
+    public static Date stringToDate(String source) {
+        Date rv = getDefaultDate();
+        if (source == null || source.isEmpty())
+            return rv;
+        ParseException e = null;
+        LanguageSetting s = languageProvider.vend();
+        DateFormat formats[] = dateFormatVendor.getInputFormats(s.locale);
+        for (DateFormat fmt : formats) {
+            try {
+                rv = fmt.parse(source);
+                return rv;
+            } catch (ParseException e1) {
+                e = e1;
+            }
+        }
+
+        // See if the user just forgot to tack on the year
+        Calendar c = Calendar.getInstance();
+        String year = String.format("%04d", c.get(Calendar.YEAR));
+        if (!source.endsWith(year)) {
+            if (source.contains("/"))
+                return stringToDate(source + "/" + year);
+            else if (source.contains("."))
+                return stringToDate(source + "." + year);
+        }
+
+        if (log.isDebugEnabled())
+            log.debug("Failed to parse date >{}< when using language settings for {}",
+                    source, s.locale.getLanguage(), e);
+
+        return rv;
+    }
+
+    /**
+     * Convert an incoming string that is composed of a date and time into a Date object. This function is designed to be
+     * very tolerant of user input. It will always accept ISO format: yyyy-MM-dd hh:mm:ss, but will also accept many
+     * localized, non-ambiguous version of a timestamp (mm/dd/yy hh:mm, MMM dd, yyyy hh:mm, etc.)
+     *
+     * @param source      The string to interpret.
+     * @param defaultDate date to return if all parsing fails (overrides the global default date for this call).
+     * @return The Date the represented in the string, to as much accuracy as can be derived from the string.
+     */
+    public static Date stringToTimestamp(String source, Date defaultDate) {
+        String date;
+        String time;
+        source = source.trim();
+
+        if (source.length() < 2 || !source.contains(" "))
+            return defaultDate;
+
+        Matcher ampmMatcher = jammedAmPm.matcher(source);
+
+        if (ampmMatcher.matches()) {
+            String ampm = source.substring(source.length() - 2);
+            source = source.substring(0, source.length() - 2) + " " + ampm;
+        }
+
+        Matcher m = timestampPattern.matcher(source);
+        if (m.matches()) {
+            date = m.group(1);
+            time = m.group(2);
+        } else { // do our best...
+            int firstSpace = source.indexOf(' ');
+            date = source.substring(0, firstSpace);
+            time = source.substring(firstSpace + 1);
+        }
+        Date d = stringToDate(date);
+        if (isNullDate(d))
+            d = defaultDate;
+
+        return stringToTime(d, time);
+    }
+
+    private final static Pattern timestampPattern = Pattern.compile("^(.*)\\s+(\\d+:\\d+(?:\\d+)?(?:\\s*\\w+)?)$");
+    private final static Pattern jammedAmPm = Pattern.compile("^.*\\d[AaPp][Mm]$");
+
+    /**
+     * Given a reference date, set the time in it using the given string. E.g. treat the date as a pure date (no time),
+     * and add the time into it.
+     *
+     * @param refDate    The date to use for the date portion
+     * @param timeString The string to parse the time from. If the time does not include an am/pm, then it is assumed to be 24-hour
+     *                   time.
+     * @return A new date object, with the date from refDate, and time from timeString. Returns defaultDate if time cannot
+     * be parsed.
+     */
+    @SuppressWarnings("deprecation")
+    public static Date stringToTime(Date refDate, String timeString) {
+        if (isNullDate(refDate)) {
+            if (defaultDate == null)
+                refDate = new Date();
+            else
+                refDate = defaultDate;
+        }
+        Date timeDate = new Date(0, 0, 0, 0, 0, 0);
+
+        LanguageSetting s = languageProvider.vend();
+        for (DateFormat fmt : new DateFormat[]{s.getLongTimeFormat(), s.getShortTimeFormat(),
+                s.getMilitaryTimeFormat(true), s.getMilitaryTimeFormat(false),
+                s.getAccurateTimeFormat(), s.getCompactMilitaryTimeFormat()}) {
+            try {
+                timeDate = fmt.parse(timeString);
+                break;
+            } catch (ParseException e) {
+            }
+        }
+
+        return new Date(refDate.getYear(), refDate.getMonth(), refDate.getDate(), timeDate.getHours(),
+                timeDate.getMinutes(), timeDate.getSeconds());
+    }
+
+    /**
+     * Returns the date format string that is preferred for date input in the current locale. (e.g. m/d/yy for English).
+     * <p/>
+     * <p/>
+     * This is useful to use on forms so that the user knows at least one legal way to type a date. For example,
+     * <p/>
+     * <pre>
+     *    &lt;input type="text" name="startDate"> <i><%= I.preferredDateFormat() %></i>
+     * </pre>
+     * <p/>
+     * would show the following in the "en" locale:
+     * <p/>
+     * <br>
+     * &nbsp;&nbsp;<input type="text">&nbsp;<i>m/d/yy</i>
+     *
+     * @return The format string, for helping the user understand input
+     */
+    public static String preferredDateFormat() {
+        return preferredDateFormat(DateFormatVendor.DEFAULT_DATE_FORMAT_ID);
+    }
+
+    /**
+     * Get the date input format accepted by the given formatID (which can be a custom date format you've installed).
+     *
+     * @param fmtID The formatID. DateFormat.SHORT/LONG/MEDIUM will always work.
+     * @return The format string, for display to users, or an empty string if it fails to obtain the pattern.
+     */
+    public static String preferredDateFormat(int fmtID) {
+        LanguageSetting s = languageProvider.vend();
+        DateFormat formatter = dateFormatVendor.getFormatFor(fmtID, s.locale, DateFormat.SHORT);
+        if (formatter != null && formatter instanceof SimpleDateFormat)
+            return ((SimpleDateFormat) formatter).toLocalizedPattern();
+        else
+            return "";
+    }
+
+    /**
+     * Assume that the input is an integer that has been multiplied by a power of 10 sufficient to not lose data. E.g. for
+     * the US, this would be dollars * 100.
+     * <p/>
+     * This function properly divides the integer, and then formats it as a currency.
+     * <p/>
+     * <b>IMPORTANT</b>: Make sure you convert the <i>amount</i> of the currency, as needed. E.g. You stored dollars, but
+     * are showing a value in Euros. This funciton assumes the amount is in the correct, current, locale money unit.
+     *
+     * @param amount The amount of money, as stored in an long (e.g. as cents)
+     * @return A string formatted in the current locale that represents the monetary amount.
+     */
+    public static String longToCurrencyString(long amount) {
+        return longToCurrencyString(amount, true);
+    }
+
+    /**
+     * Assume that the input is an integer that has been multiplied by a power of 10 sufficient to not lose data. E.g. for
+     * the US, this would be dollars * 100.
+     * <p/>
+     * This function properly divides the integer, and then formats it as a currency.
+     * <p/>
+     * <b>IMPORTANT</b>: Make sure you convert the <i>amount</i> of the currency, as needed. E.g. You stored dollars, but
+     * are showing a value in Euros. This funciton assumes the amount is in the correct, current, locale money unit.
+     *
+     * @param amount The amount of money, as stored in an long (e.g. as cents)
+     * @return A number that has be correctly divided to have the right number of fractional digits.
+     */
+    public static String longToCurrencyString(long amount, boolean bCurrencySign) {
+        int scale = getCurrencyFractionDigits();
+        int divisor = scale > 0 ? (int) Math.pow(10, scale) : 1;
+        BigDecimal b = new BigDecimal(amount);
+        b = b.divide(new BigDecimal(divisor), scale, RoundingMode.HALF_UP);
+        return numberToCurrencyString(b, bCurrencySign);
+    }
+
+    /**
+     * Returns default fraction digits according to the locale.
+     *
+     * @return The number of floating point digits. IMPORTANT: Returns 2 if the Locale country is unknown.
+     */
+    public static int getCurrencyFractionDigits() {
+        try {
+            LanguageSetting s = languageProvider.vend();
+            Currency c = Currency.getInstance(s.locale);
+            int scale = c.getDefaultFractionDigits();
+            return scale;
+        } catch (Exception e) {
+            return 2;
+        }
+    }
+
+    /**
+     * Accepts a floating-point number (usually double or Double) that represents a currency amount. This function
+     * properly rounds the amount, and returns a string formatted for the current locale (including a currency symbol).
+     *
+     * @param damount The floating-point amount to format.
+     * @return A locale-specific string rounded and formatted to look like a currency.
+     */
+    public static String numberToCurrencyString(Number damount) {
+        return numberToCurrencyString(damount, true);
+    }
+
+    public static String numberToCurrencyString(Number damount, boolean bCurrencySign, EscapeFunction f) {
+        LanguageSetting s = languageProvider.vend();
+        String rv;
+        DecimalFormat d = (DecimalFormat) NumberFormat.getCurrencyInstance(s.locale);
+        if (damount.doubleValue() < 0) {
+            if (d.getNegativePrefix().contains("("))
+                d.setNegativePrefix(d.getNegativePrefix().replace("(", "-"));
+            if (d.getNegativeSuffix().contains(")"))
+                d.setNegativeSuffix(d.getNegativeSuffix().replace(")", ""));
+        }
+
+        if (!bCurrencySign) {
+            d.setPositivePrefix("");
+            d.setPositiveSuffix("");
+            d.setNegativePrefix("-");
+            d.setNegativeSuffix("");
+        }
+        rv = d.format(damount.doubleValue());
+        rv.replace((char) 160, ' ');
+        return f.escape(rv);
+    }
+
+    /**
+     * Get a locale-specific string representing the amount of currency provided. This is identical to
+     * numberToCurrencyString(Number), but allows you to turn off the currency symbol.
+     *
+     * @param damount       The amount
+     * @param bCurrencySign whether to include the currency symbol in the string.
+     * @return The currency string.
+     */
+    public static String numberToCurrencyString(Number damount, boolean bCurrencySign) {
+        return numberToCurrencyString(damount, bCurrencySign, escapeFunction);
+    }
+
+    /**
+     * Take a string that represents a currency amount (with or without the currency symbol), mulitplies it by the correct
+     * power of 10 to push the fractional digits into an integer form, and returns the result as a long.
+     * <p/>
+     * E.g. In US: 100.34 -> 10034<br/>
+     * In France/Germany: 100,34 -> 10034<br/>
+     * etc.<br/>
+     * <p/>
+     * <b>This function is quite tolerant of user input</b>, and will accept anything that is "normal" when writing a
+     * currency in that locale.
+     * <p/>
+     * <pre>
+     * // locale is en
+     * I.currencyStringToLong(&quot;$1,345.66&quot;, 0L); // returns 134566
+     * I.currencyStringToLong(&quot;1345.66&quot;, 0L); // returns 134566
+     * // locale is fr
+     * I.currencyStringToLong(&quot;1 345,66&quot;, 0L); // returns 134566
+     * I.currencyStringToLong(&quot;1345.66 &amp;euro&quot;, 0L); // returns 134566
+     * </pre>
+     *
+     * @param amount       The string representing a user-input amount of currency.
+     * @param defaultValue The value to return if the parsing fails.
+     * @return A long, multiplied by the correct power of 10 for the current fractional storage for the currency.
+     */
+    public static long currencyStringToLong(String amount, long defaultValue) {
+        Number n = currencyStringToNumber(amount, Long.parseLong(String.valueOf(defaultValue)));
+        int scale = getCurrencyFractionDigits();
+        int multiple = scale > 0 ? (int) Math.pow(10, scale) : 1;
+        BigDecimal b = new BigDecimal(n.toString());
+        return b.multiply(new BigDecimal(multiple)).longValue();
+    }
+
+    /**
+     * Parse the given locale-specific currency string, and return a Number that represents the amount. The Number object
+     * then easily allows conversion to primitives or even BigDecimal.
+     * <p/>
+     * Use preferredCurrencyFormat() to get a help string that indicates the preferred input format for the currency.
+     * <p/>
+     * <b>This function is quite tolerant of user input</b>, and will accept anything that is "normal" when writing a
+     * currency in that locale.
+     * <p/>
+     * <pre>
+     * // locale is en
+     * I.currencyStringToNumber(&quot;$1,345.66&quot;, 0); // returns 1345.66
+     * I.currencyStringToNumber(&quot;1345.66&quot;, 0); // returns 1345.66
+     * // locale is fr
+     * I.currencyStringToNumber(&quot;1 345,66&quot;, 0); // returns 1345.66
+     * I.currencyStringToNumber(&quot;1345.66 &amp;euro&quot;, 0); // returns 1345.66
+     * </pre>
+     *
+     * @param amount       The string (e.g. 100.34) to be parsed
+     * @param defaultValue The Number to return if the parsing fails.
+     * @return A Number (e.g. rv.toDouble() == 100.34), or defaultValue if the string isn't understandable.
+     */
+    public static Number currencyStringToNumber(String amount, Number defaultValue) {
+        if (amount == null || amount.isEmpty())
+            return defaultValue;
+        LanguageSetting s = languageProvider.vend();
+        NumberFormat fmt = NumberFormat.getCurrencyInstance(s.locale);
+        try {
+            DecimalFormat d = (DecimalFormat) fmt;
+            DecimalFormatSymbols symbols = d.getDecimalFormatSymbols();
+            if (d.getNegativePrefix().length() > 0)
+                amount = amount.replace(d.getNegativePrefix(), "-").trim();
+            if (d.getNegativeSuffix().length() > 0)
+                amount = amount.replace(d.getNegativeSuffix(), "").trim();
+            if (d.getPositivePrefix().length() > 0)
+                amount = amount.replace(d.getPositivePrefix(), "").trim();
+            if (d.getPositiveSuffix().length() > 0)
+                amount = amount.replace(d.getPositiveSuffix(), "").trim();
+            d.setPositivePrefix("");
+            d.setPositiveSuffix("");
+            d.setNegativePrefix("-");
+            d.setNegativeSuffix("");
+            // In french, the official grouping separator is a Unicode thin space...convert ASCII spaces to thin
+            // spaces keeps input conversion from failing....
+            if (symbols.getGroupingSeparator() == '\u00a0')
+                amount = amount.replace(" ", "\u00a0");
+            return fmt.parse(amount);
+        } catch (ParseException e) {
+            log.debug("Failed to parse currency: {}", amount, e);
+        }
+        return defaultValue;
+    }
+
+    /**
+     * Get a help string that indicates the desired currency input format. This is useful in UI forms:
+     * <p/>
+     * <pre>
+     *    &lt;input type="text" name="amount"> &lt;%= I.preferredCurrencyFormat() %>
+     * </pre>
+     * <p/>
+     * would show something like this:<br>
+     * &nbsp;&nbsp;<input type="text">&nbsp;#,###.##
+     *
+     * @return A String of the form #,###.##
+     */
+    public static String preferredCurrencyFormat() {
+        LanguageSetting s = languageProvider.vend();
+        NumberFormat fmt = NumberFormat.getCurrencyInstance(s.locale);
+        StringBuffer rv = new StringBuffer();
+        if (fmt instanceof DecimalFormat) {
+            DecimalFormat cfmt = ((DecimalFormat) fmt);
+            int fractional = cfmt.getMaximumFractionDigits();
+            int groupSize = cfmt.getGroupingSize();
+            DecimalFormatSymbols symbols = cfmt.getDecimalFormatSymbols();
+            rv.append("#");
+            rv.append(symbols.getGroupingSeparator());
+            for (int i = 0; i < groupSize; i++)
+                rv.append("#");
+            rv.append(symbols.getDecimalSeparator());
+            for (int i = 0; i < fractional; i++)
+                rv.append("#");
+        }
+        return rv.toString();
+    }
+
+    /**
+     * Parse the given locale-specific number, and return a Number object that represents the value.
+     * <p/>
+     * Use preferredNumberFormat() to get a help string that indicates the preferred input format for numbers.
+     * <p/>
+     * In general, this function is very tolerant of user input. Digit groupings are optional, but the fractional
+     * separator must be correct.
+     * <p/>
+     * <pre>
+     * // in en locale
+     * I.stringToNumber(&quot;1,534,100.34&quot;, 0); // returns 1534100.34
+     * I.stringToNumber(&quot;1534100.34&quot;, 0); // returns 1534100.34
+     * // in fr locale
+     * I.stringToNumber(&quot;1 534 100,34&quot;, 0); // returns 1534100.34
+     * I.stringToNumber(&quot;1534100,34&quot;, 0); // returns 1534100.34
+     * </pre>
+     *
+     * @param value        The string (e.g. 100.34) to be parsed
+     * @param defaultValue The Number to return if the parsing fails.
+     * @return A Number (e.g. rv.toDouble() == 100.34), or defaultValue if the string isn't understandable.
+     */
+    public static Number stringToNumber(String value, Number defaultValue) {
+        if (value == null || value.isEmpty())
+            return defaultValue;
+        LanguageSetting s = languageProvider.vend();
+        NumberFormat fmt = NumberFormat.getInstance(s.locale);
+        try {
+            value = value.replace(" ", "");
+            return fmt.parse(value);
+        } catch (ParseException e) {
+            log.debug("Failed to parse number: " + value, e);
+        }
+        return defaultValue;
+    }
+
+    /**
+     * Returns a help string that indicates the recommended number input format for the current locale. This will be the
+     * locale-specific format. The input funcitons all tolerate plain math numbers (without groupings), though the
+     * locale-specific fraction separator is required.
+     *
+     * @param nFractional Indicate the number of fractional digits wanted. 0 means you want an integer.
+     * @return A string representing the recommended number input for the locale.
+     * @see I#preferredCurrencyFormat()
+     */
+    public static String preferredNumberFormat(int nFractional) {
+        LanguageSetting s = languageProvider.vend();
+        NumberFormat fmt = NumberFormat.getInstance(s.locale);
+        StringBuffer rv = new StringBuffer();
+        if (fmt instanceof DecimalFormat) {
+            DecimalFormat cfmt = ((DecimalFormat) fmt);
+            int groupSize = cfmt.getGroupingSize();
+            DecimalFormatSymbols symbols = cfmt.getDecimalFormatSymbols();
+            rv.append("#");
+            rv.append(symbols.getGroupingSeparator());
+            for (int i = 0; i < groupSize; i++)
+                rv.append("#");
+            if (nFractional > 0) {
+                rv.append(symbols.getDecimalSeparator());
+                for (int i = 0; i < nFractional; i++)
+                    rv.append("#");
+            }
+        }
+        return rv.toString();
+    }
+
+    /**
+     * Convert a number to a string. The returned string is formatted according to the locale to include digit groupings
+     * for easy reading.
+     * <p/>
+     * <pre>
+     * // in en locale
+     * I.numberToString(1294855.234); // returns &quot;1,294,855.234&quot;
+     * // in fr locale
+     * I.numberToString(1294855.234); // returns &quot;1 294 855,234&quot;
+     * // in de locale
+     * I.numberToString(1294855.234); // returns &quot;1.294.855,234&quot;
+     * </pre>
+     *
+     * @param d The number to format.
+     * @return The number as a string. Tolerates null input (returns 0)
+     */
+    public static String numberToString(Number d) {
+        if (d == null)
+            return "0";
+        LanguageSetting s = languageProvider.vend();
+        NumberFormat fmt = NumberFormat.getInstance(s.locale);
+        return fmt.format(d).replace("\u00a0", " ");
+    }
+
+    /**
+     * Get the currency symbol for the current locale.
+     *
+     * @return A string containing the currency symbol in the current locale.
+     */
+    public static String currencySign() {
+        LanguageSetting s = languageProvider.vend();
+        NumberFormat fmt = NumberFormat.getCurrencyInstance(s.locale);
+        DecimalFormat d = (DecimalFormat) fmt;
+        return d.getDecimalFormatSymbols().getCurrencySymbol();
+    }
+
+    /**
+     * localized the image name: xxxx_de.png; xxxx_fr.png;
+     * <p/>
+     * <p/>
+     * <b>IMPORTANT NOTE:</b> In general, text should not be embedded in images, as it makes the application much harder
+     * to localize (you must hire a graphic artist to fix all of the images, in addition to the translator needed to
+     * translate the text.
+     * <p/>
+     * <p/>
+     * A better approach is to leave the text out of the image, and use CSS (or an image API in Java) to overlay text on
+     * the image. This way, you can localize the images with simple translations.
+     * <p/>
+     * <p/>
+     * This function does NO filesystem check for the existence of images, it just morhs strings.
+     *
+     * @param url             A string ending with a . suffix (e.g. x.png)
+     * @param omitForLanguage The language code that the application uses internally, and for which no suffix should be added.
+     * @return A string with the locale inserted (e.g. x_fr.png)
+     */
+    public static String imageURL(String url, String omitForLanguage) {
+        if (languageProvider.vend().locale.getLanguage().equals(omitForLanguage) || url == null || url.length() == 0)
+            return url;
+
+        int iIdx = url.lastIndexOf(".");
+
+        if (iIdx == -1)
+            return url;
+        else
+            return url.substring(0, iIdx) + "_" + languageProvider.vend().locale.getLanguage() + url.substring(iIdx);
+    }
+
+    /**
+     * Get an image URL with language suffix (ignores Locale language "en"). See also imageURL(String,String).
+     *
+     * @param url
+     * @return
+     */
+    public static String imageURL(String url) {
+        return imageURL(url, "en");
+    }
+
+    /**
+     * Use the current locale's understanding of currency to round a double to the correct number of fractional digits.
+     * <p/>
+     * <p/>
+     * <b>CAUTION:</b> Currency calculations must be done in a way that is consistent with accounting practices. Usually,
+     * this means rounding at the end of a sequence of operations (so that rounding errors don't accumulate). The basic
+     * rule is to round any currency amount that becomes visible to the user. So, for example:
+     * <p/>
+     * <pre>
+     * double discount = 0.0123 * amount; // 1.23% discount
+     * String userMessage = I.trf(&quot;You get a {0,currency} discount!&quot;, discount); // currency formatting will show it rounded
+     * double roundedDiscount = I.roundCurrency(discount);
+     * // now use the rounded number, since that is what they expect.
+     * double price = price - roundedDiscount;
+     * </pre>
+     *
+     * @param unroundedNumber The number to round
+     * @return The same value, but rounded to the correct number of significant fractional digits for the locale's
+     * currency.
+     */
+    public static double roundCurrency(double unroundedNumber) {
+        int scale = getCurrencyFractionDigits();
+        double divisor = scale > 0 ? (int) Math.pow(10, scale) : 1.0;
+        // add in a fudge factor, or some boundary conditions round in the wrong direction
+        return Math.round(unroundedNumber * divisor + 0.00001) / divisor;
+    }
+
+    /**
+     * Translate a number (e.g. 0.35) to a locale-specific percentage (35%). This function shows up to 2 fractional digits
+     * of the percentage, but only as many as are present.
+     * <p/>
+     * e.g.
+     * <p/>
+     * <pre>
+     *   // In en locale
+     *   fractionalNumberToPercentage(0.88) -> 88%
+     *   fractionalNumberToPercentage(0.835) -> 83.5%
+     *   fractionalNumberToPercentage(0.04356) -> 4.36%
+     * </pre>
+     *
+     * @param n The number
+     * @return The string for your locale that represents the percentage form.
+     */
+    public static String fractionalNumberToPercentage(Number n) {
+        Locale l = I.languageProvider.vend().locale;
+        NumberFormat fmt = NumberFormat.getPercentInstance(l);
+        fmt.setMaximumFractionDigits(2);
+        fmt.setMinimumFractionDigits(0);
+        return fmt.format(n);
+    }
+
+    /**
+     * Convert an integer to a percentage. The second argument supports cases where the int has been pre-multiplied (say
+     * by 100) in order to store fractional parts, specify that in fractionalDigits, and it will be divided correctly to
+     * compensate.
+     * <p/>
+     * e.g.
+     * <p/>
+     * <pre>
+     * intToPercentage(88,0) -> 88%
+     * intToPercentage(8835,2) -> 88.35%
+     * intToPercentage(88351,3) -> 88.35%
+     * intToPercentage(8835678,5) -> 88.36%
+     * </pre>
+     *
+     * @param n                The integer to represent as a percentage.
+     * @param fractionalDigits The number of fractional digits in the int (using premutliplication). Useful values are 0, 1, or 2. Higher
+     *                         numbers are supported and will correctly divide, but the later digits will be rounded to 2 fractional
+     *                         places.
+     * @return The localized percentage string.
+     */
+    public static String intToPercentage(int n, int fractionalDigits) {
+        double pct = n / 100.0;
+        while (fractionalDigits-- > 0)
+            pct /= 10.0;
+        return fractionalNumberToPercentage(pct);
+    }
+
+    /**
+     * Translate a pre-multiplied integer number (e.g. 35) to a locale-specific percentage (35%).
+     * <p/>
+     * <pre>
+     * wholeNumberToPercentage(88) -> 88%
+     * </pre>
+     *
+     * @param n The number
+     * @return The string for your locale that represents the percentage form.
+     */
+    public static String wholeNumberToPercentage(int n) {
+        return I.trf("{0,number,percent}", n / 100.0);
+    }
+
+    /**
+     * Returns a full name, properly composed for the locale. NOTE: This function's behavior relies on the correct
+     * translation. Make sure you generate the translation files for new locales, and have the translator properly define
+     * this order.
+     *
+     * @param firstName The first name.
+     * @param lastName  The last name.
+     * @return A string with the name composed in the proper order.
+     */
+    public static String fullName(String firstName, String lastName) {
+        // DO NOT EDIT THIS CODE! If there is a problem, edit the translation file to set the proper format for the name.
+        return I.trcf("full_name", "{0} {1}", firstName, lastName);
+    }
+
+    /**
+     * Given a currency string in the current locale, remove all unnessary characters (group separators and currency
+     * symbols).
+     * <p/>
+     * <p/>
+     * <b>NOTE:</b> Compressed strings like this are less clear to the user, and should be avoided if at all possible. The
+     * input functions accept the normal formats, so there is no worry about the extra symbols from a functionality
+     * standpoint.
+     *
+     * @param str The currency string
+     * @return The compacted currency string
+     */
+    public static String compressCurrencyString(String str) {
+        LanguageSetting s = languageProvider.vend();
+        DecimalFormat d = (DecimalFormat) NumberFormat.getCurrencyInstance(s.locale);
+        Character c = d.getDecimalFormatSymbols().getGroupingSeparator();
+        str = str.replaceAll("\\Q" + c.toString() + "\\E", "");
+        String sym = d.getDecimalFormatSymbols().getCurrencySymbol();
+        str = str.replaceAll("\\Q" + sym + "\\E", "");
+        str = str.replaceAll("&[^;]*;", "");
+        return str;
+    }
+
+    /**
+     * Given a number string in the current locale, remove all unnessary characters (group separators).
+     * <p/>
+     * <p/>
+     * <b>NOTE:</b> Compressed strings like this are less clear to the user, and should be avoided if at all possible. The
+     * input functions accept the normal formats, so there is no worry about the extra symbols from a functionality
+     * standpoint.
+     *
+     * @param str The string
+     * @return The compacted string
+     */
+    public static String compressNumberString(String str) {
+        LanguageSetting s = languageProvider.vend();
+        DecimalFormat d = (DecimalFormat) NumberFormat.getNumberInstance(s.locale);
+        Character c = d.getDecimalFormatSymbols().getGroupingSeparator();
+        str = str.replaceAll("\\Q" + c.toString() + "\\E", "");
+        String sym = d.getDecimalFormatSymbols().getCurrencySymbol();
+        str = str.replaceAll("\\Q" + sym + "\\E", "");
+        str = str.replaceAll("&[^;]*;", "");
+        str = str.replaceAll(" ", "");
+        return str;
+    }
+
+    /**
+     * Same as longToCurrencyString, but omits all unnecessary symbols (grouping separators and currency symbol)
+     * <p/>
+     * <p/>
+     * <b>NOTE:</b> Compressed strings like this are less clear to the user, and should be avoided if at all possible. The
+     * input functions accept the normal formats, so there is no worry about the extra symbols from a functionality
+     * standpoint.
+     *
+     * @see I#longToCurrencyString(long)
+     */
+    public static String longToCompactCurrencyString(int amount) {
+        return compressCurrencyString(longToCurrencyString(amount, false));
+    }
+
+    /**
+     * Same as numberToCurrencyString, but omits all unnecessary symbols (grouping separators and currency symbol).
+     * <p/>
+     * <p/>
+     * <b>NOTE:</b> Compressed strings like this are less clear to the user, and should be avoided if at all possible. The
+     * input functions accept the normal formats, so there is no worry about the extra symbols from a functionality
+     * standpoint.
+     *
+     * @see I#numberToCurrencyString(Number)
+     */
+    public static String numberToCompactCurrencyString(Number amount) {
+        return compressCurrencyString(numberToCurrencyString(amount, false));
+    }
+
+    /**
+     * Same as numberToString, but omits all unnecessary symbols (grouping separators)
+     * <p/>
+     * <p/>
+     * <b>NOTE:</b> Compressed strings like this are less clear to the user, and should be avoided if at all possible. The
+     * input functions accept the normal formats, so there is no worry about the extra symbols from a functionality
+     * standpoint.
+     */
+    public static String numberToCompactString(Number d) {
+        return compressNumberString(numberToString(d));
+    }
+
+    /**
+     * Get the current language setting.
+     *
+     * @return languagne, for instance, en, fr, de, ...
+     */
+    public static String getLanguage() {
+        return languageProvider.vend().locale.getLanguage();
+    }
+
+    /**
+     * Languages deal with compond lists in a sentence differently. For example, in English the proper format is:
+     * <p/>
+     * <pre>
+     *  two items: "a and b"
+     *  three or more items: "a, b, c, and d"
+     * </pre>
+     * <p>
+     * However, other languages may not use comma, and may or may not have words that separate the last item from the rest
+     * of the list.
+     * <p>
+     * This function centralizes the handling of proper sentence structure for lists like this. Most list classes (e.g.
+     * ArrayList, Vector, TreeSet) have a toArray() method, so this expects an array of strings that are your list.
+     * <p/>
+     * <p>
+     * <b>NOTE: the strings you pass must have been previous translated!</b>. If you have a list of literals, use a
+     * pattern like:
+     * </p>
+     * <p/>
+     * <pre>
+     * I.localizedStringsAsList(I.tr(&quot;A&quot;), I.tr(&quot;B&quot;), I.tr(&quot;C&quot;));
+     * </pre>
+     * <p/>
+     * Remember, <b>it is impossible for the translation system to translate strings from variables.</b>
+     *
+     * @param preTranslatedWords Words you've already run through tr (as literals). May be null, empty, or singular.
+     * @param inclusive          Pass true to use "And", false to use "Or". E.g. A, B, and C vs. A, B, or C.
+     * @return A stringified list acceptable for use in the middle of a sentence. e.g. String[] { I.tr("A"), I.tr("B"),
+     * I.tr("B") } -&gt; "A, B, and C". If you pass a null list or empty list, "" is returned. If you pass a list
+     * with a single item, just that item is returned. Never returns null.
+     */
+    public static String localizedStringsAsList(String preTranslatedWords[], boolean inclusive, EscapeFunction f) {
+        String comma = I.trc("The separator for lists in a sentence (e.g. a, b, and c)", ",", f);
+        String justTwo =
+                inclusive ? I.trc("a list in a sentence with more exactly two things", "{0} and {1}", f)
+                        : I.trc("a list of options in a sentence with exactly two things", "{0} or {1}", f);
+        String compoundList =
+                inclusive ? I.trc("ending of list in a sentence with three or more things", "{0}, and {1}", f)
+                        : I.trc("ending of list of options in a sentence with three or more things", "{0}, or {1}", f);
+
+        if (preTranslatedWords == null || preTranslatedWords.length == 0)
+            return "";
+        if (preTranslatedWords.length == 1)
+            return preTranslatedWords[0];
+        if (preTranslatedWords.length == 2)
+            return I.trf(f, justTwo, preTranslatedWords[0], preTranslatedWords[1]);
+
+        StringBuilder mainList = new StringBuilder();
+        int i = 0;
+        for (i = 0; i < preTranslatedWords.length - 1; i++) {
+            mainList.append(preTranslatedWords[i]);
+            if (i < preTranslatedWords.length - 2) {
+                mainList.append(comma);
+                mainList.append(' ');
+            }
+        }
+
+        return I.trf(f, compoundList, mainList.toString(), preTranslatedWords[i]);
+    }
+
+    public static String localizedStringsAsList(String preTranslatedWords[], boolean inclusive) {
+        return localizedStringsAsList(preTranslatedWords, inclusive, escapeFunction);
+    }
+
+//    /**
+//     * Same as tr, but escaped for inclusion in JavaScript. This method uses apache-commons
+//     * StringEscapeUtils.escapeJavascript. See the documentation for that for further info.
+//     * <p/>
+//     * <p/>
+//     * Essentially, you can use this to get strings that can be embedded within any type of javascript quote. The
+//     * recommended usage is:
+//     * <p/>
+//     * <pre>
+//     *    ... import com.teamunify.i18n.I and com.teamunify.util.S ...
+//     *    &lt;script&gt;
+//     *       var a = <%= S.q(I.trj("message")) %>;
+//     *       window.alert(a);
+//     *    &lt;/script&gt;
+//     * </pre>
+//     *
+//     * @param string The string to be translated
+//     * @return The translated and javascript-escaped result
+//     */
+//    public static String trj(String string) {
+//        return escapeJavaScript(tru(string));
+//    }
+
+//    /**
+//     * Same as trf, but escaped for inclusion in JavaScript. This method uses apache-commons
+//     * StringEscapeUtils.escapeJavascript. See the documentation for that for further info.
+//     * <p/>
+//     * Remember that trf treats ' as a special character, and you must double them.
+//     * <p/>
+//     * <p/>
+//     * Essentially, you can use this to get strings that can be embedded within any type of javascript quote. The
+//     * recommended usage is:
+//     * <p/>
+//     * <pre>
+//     *    ... import com.teamunify.i18n.I and com.teamunify.util.S ...
+//     *    &lt;script&gt;
+//     *       var a = <%= S.q(I.trfj("There are {0} apples in Jim''s {1}", count, location)) %>;
+//     *       window.alert(a);
+//     *    &lt;/script&gt;
+//     * </pre>
+//     *
+//     * @param msg  The message to be translated
+//     * @param args The arguments for the format
+//     * @return The translated and javascript-escaped result
+//     */
+//    public static String trfj(String msg, Object... args) {
+//        return escapeJavaScript(trfu(msg, args));
+//    }
+
+    /*
+     * Convert a date to a full ISO timestamp with ms accuracy.
+     *
+     * @param date The date
+     *
+     * @return A String version of it.
+     */
+    public static String timestampToISOString(Date date) {
+        if (isNullDate(date))
+            return "";
+        return MessageFormat.format("{0,date,yyyy-MM-dd} {0,time,HH:mm:ss.S}", date);
+    }
+
+    public static String dateToISOString(Date date) {
+        if (isNullDate(date))
+            return "";
+        return new SimpleDateFormat("yyyy-MM-dd").format(date);
+    }
+
+    /**
+     * Convert an ISO timestamp string into a Date object.
+     *
+     * @param iso          The string, in format: yyyy-MM-dd HH:mm:ss.S
+     * @param defaultValue The date to return if parsing fails
+     * @return The parsed date, or defaultValue.
+     */
+    public static Date ISOTimestampToDate(String iso, Date defaultValue) {
+        if (iso == null || iso.isEmpty())
+            return defaultValue;
+        try {
+            SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.S", Locale.ENGLISH);
+            return df.parse(iso);
+        } catch (Exception e) {
+            log.debug("Unable to parse date: " + iso, e);
+        }
+        return defaultValue;
+    }
+
+    /**
+     * Get default timezone; note this is locale independent and is set by the machine (or vm).
+     * <p/>
+     * <br/>
+     * TODO: Allow time zone setup in LanguageSetting
+     *
+     * @return timezone object for this machine
+     */
+    public static TimeZone getTimeZone() {
+        TimeZone rv = null;
+        try {
+            rv = TimeZone.getTimeZone(System.getProperty("user.timezone"));
+            System.err.println("tz set to " + rv.getDisplayName());
+        } catch (Exception e) {
+            rv = TimeZone.getDefault();
+            System.err.println("tz not set. Defaulted to " + rv.getDisplayName());
+        }
+        return rv;
+    }
+
+    /**
+     * Test if the given Date object is what we consider to be the NULL date...
+     * <p/>
+     *
+     * @param d
+     */
+    public static boolean isNullDate(Date d) {
+        return d == null || nullDateTest.apply(d);
+    }
+
+    public static BooleanFunction<Date> getNullDateTest() {
+        return nullDateTest;
+    }
+
+    /**
+     * <p/>
+     * The nullDateTest is a BooleanFunction. You can define this test, and it will be used by the date parsing routines
+     * to determine if an incoming Date object should be treated as the lack of a date. This allows you to use a specific
+     * date to represent "Nothing" as opposed to the error-prone value null.
+     * <p/>
+     * <p/>
+     * If you set a custom function, be sure you test for null, since it is possible you will be asked if null is a Null
+     * Date.
+     */
+    public static void setNullDateTest(BooleanFunction<Date> nullDateTest) {
+        if (nullDateTest == null)
+            throw new NullPointerException("Date testing function cannot be null");
+        I.nullDateTest = nullDateTest;
+    }
+
+    /**
+     * The default date can be set globally to avoid the return of null.
+     *
+     * @return The default date object that is returned instead of null when parsing fails.
+     */
+    public static Date getDefaultDate() {
+        return defaultDate;
+    }
+
+    /**
+     * Set the default date returned from functions when they fail to parse dates.
+     * <p/>
+     * NOT THREAD SAFE!
+     *
+     * @param defaultDate
+     */
+    public static void setDefaultDate(Date defaultDate) {
+        I.defaultDate = defaultDate;
+    }
+
+    /**
+     * Set the escape function used by most translation functions. Defaults to HTMLEscapeFunction
+     * <p/>
+     * <p/>
+     * NOTE: This is a global setting that affects all thread at all times.
+     *
+     * @param f The function that is to be used to escape translations returned from tr family of functions.
+     */
+    public static void setEscapeFunction(EscapeFunction f) {
+        escapeFunction = f;
+    }
+
+    static final EscapeFunction defaultEscapeFunction = EscapeFunction.NoEscape;
+    private static EscapeFunction escapeFunction = defaultEscapeFunction;
+    private static Wikifier wikiEngine = new SimpleWikifier();
+
+    /**
+     * Get the wiki engine.
+     *
+     * @return The Wikifier that is used to convert translations to an alternate format (e.g. HTML) before return from trw
+     * family of functions.
+     */
+    public static Wikifier getWikiEngine() {
+        return wikiEngine;
+    }
+
+    /**
+     * Set the wiki support engine. Can be set to null to disable support. Defaults to SimpleWikifier.
+     *
+     * @param wikiEngine The wiki engine to use.
+     */
+    public static void setWikiEngine(Wikifier wikiEngine) {
+        I.wikiEngine = wikiEngine;
+    }
+
+    /**
+     * Run the given string through the current Wiki Engine. Use setWikiEngine to change (globally).
+     *
+     * @param s The string containing wiki notation
+     * @return The wikified output
+     */
+    public static String wikified(String s) {
+        if (wikiEngine == null)
+            return s;
+        return wikiEngine.wikified(s);
+    }
+
+    /**
+     * Set the LanguageSettingsProvider. The provider determines the language to use at each call to the main API, and
+     * typically is either a global provider, or thread local.
+     *
+     * @param p
+     */
+    public static synchronized void setLanguageSettingsProvider(LanguageSettingsProvider p) {
+        languageProvider = p;
+    }
+
+    private static DateFormatVendor dateFormatVendor = new DateFormatVendor();
+
+    /**
+     * Add support for a specific date format (for input and output) that extends the Java built-in SHORT,
+     * MEDIUM, and LONG. The custom date format follows the same resolution rules as translations. The incoming locale
+     * includes country (e.g. en_US), the the API will first look for custom date formats registered on that exact Locale.
+     * If none are found, it will try dropping the country. If there are still none found, your date/time translation will
+     * throw an exception. So be <em>sure</em> to register some kind of formatter for each of your possible languages.
+     * <p/>
+     * <p/>
+     * By adding a custom date format, you can affect input and/or output. Your formatter will be selectable on output
+     * using the formatID you specify, and will be used as an additional interpreter of dates/times on input functions
+     * (after the built-in ones are tried).
+     * <p/>
+     * <p/>
+     * IMPORTANT: If you try to set the same formatID/locale combination more than once, the first one wins. You cannot
+     * change registrations.
+     *
+     * @param formatID       Your custom format ID. MUST be greater than 10.
+     * @param lang           The two-letter language
+     * @param country        The two-letter country
+     * @param dateFormatSpec An acceptable SimpleDateFormat specification for format
+     * @param allowedOnInput True if you want users to be able to use this format for input of dates
+     */
+    public static void addCustomDateFormat(int formatID, String lang, String country, String dateFormatSpec,
+                                           boolean allowedOnInput) {
+        Locale l = new Locale(lang, country);
+        SimpleDateFormat format = new SimpleDateFormat(dateFormatSpec, l);
+        format.setLenient(true);
+        dateFormatVendor.registerFormat(formatID, l, format, allowedOnInput);
+    }
+
+    /**
+     * Set the default Date Format to use in methods that do not require an explicit style.
+     *
+     * @param lang    The ISO two-letter language code. E.g. "en"
+     * @param country The ISO two-letter country code. E.g. "AU"
+     * @param spec    The Java DateFormat format string. E.g. "d/M/yyyy"
+     */
+    public static void setDefaultDateFormat(String lang, String country, String spec) {
+        Locale l = new Locale(lang, country);
+        SimpleDateFormat format = new SimpleDateFormat(spec, l);
+        format.setLenient(true);
+        dateFormatVendor.registerFormat(DateFormatVendor.DEFAULT_DATE_FORMAT_ID, l, format, false);
+    }
+
+    /**
+     * A function for converting a date to a localized name of the weekday that date lands on.
+     *
+     * @param day         The day of the week, as returned from Calendar
+     * @param abbreviated Should the day name be abbreviated or not?
+     */
+    public static String dayOfWeek(Date day, boolean abbreviated) {
+        final Locale l = languageProvider.vend().locale;
+        final SimpleDateFormat fmt;
+
+        if (abbreviated)
+            fmt = new SimpleDateFormat("EEE", l);
+        else
+            fmt = new SimpleDateFormat("EEEE", l);
+
+        return fmt.format(day).toString();
+    }
+
+    /**
+     * Get the name of the day of the week if the supplied date were offset by the given (signed) number of days. E.g. if
+     * the supplied date is on a Monday, the locale is en_US, and offset is -2, then this function returns Saturday.
+     *
+     * @param day         The date of the reference date
+     * @param offset_days The positive or negative offset in days
+     * @param abbreviated Should the day name be abbreviated?
+     * @return The day name
+     */
+    public static String dayOfWeek(Date day, int offset_days, boolean abbreviated) {
+        final Locale l = languageProvider.vend().locale;
+        final Calendar start = Calendar.getInstance(l);
+        start.setTime(day);
+        start.add(Calendar.DAY_OF_MONTH, offset_days);
+        final Date target = start.getTime();
+
+        return dayOfWeek(target, abbreviated);
+    }
+
+    /**
+     * Convert a numeric representation of month (e.g. 1) in the current locale to a localized name for that month (e.g.
+     * January). Uses Calendar.getInstance(locale) internally to translate month numbers.
+     *
+     * @param monthNumber A legal month number for the current locale...typically Calendar.JANUARY (0) to Calendar.DECEMBER (11)
+     * @param abbreviated Do you want the three-letter, or full version?
+     * @return The localized name of the month
+     */
+    public static String monthName(int monthNumber, boolean abbreviated) {
+        final Locale l = languageProvider.vend().locale;
+        final Calendar c = Calendar.getInstance(l);
+        c.set(Calendar.MONTH, monthNumber);
+        c.set(Calendar.DAY_OF_MONTH, 2);
+        final Date d = c.getTime();
+
+        final SimpleDateFormat fmt;
+        if (abbreviated)
+            fmt = new SimpleDateFormat("MMM", l);
+        else
+            fmt = new SimpleDateFormat("MMMM", l);
+
+        return fmt.format(d).toString();
+    }
+}

+ 13 - 0
src/main/java/com/teamunify/i18n/escape/EscapeFunction.java

@@ -0,0 +1,13 @@
+package com.teamunify.i18n.escape;
+
+public interface EscapeFunction {
+    public String escape(String s);
+
+    public static EscapeFunction NoEscape = new EscapeFunction() {
+        public String escape(String s) {
+            return s;
+        }
+    };
+
+    public static EscapeFunction EscapeHTML = new HTMLEscapeFunction();
+}

+ 10 - 0
src/main/java/com/teamunify/i18n/escape/HTMLEscapeFunction.java

@@ -0,0 +1,10 @@
+package com.teamunify.i18n.escape;
+
+//import static org.apache.commons.lang.StringEscapeUtils.escapeHtml;
+//
+public class HTMLEscapeFunction implements EscapeFunction {
+    public String escape(String s) {
+//    return escapeHtml(s);
+        return s; // todo
+    }
+}

+ 5 - 0
src/main/java/com/teamunify/i18n/settings/BooleanFunction.java

@@ -0,0 +1,5 @@
+package com.teamunify.i18n.settings;
+
+public interface BooleanFunction<T> {
+    public boolean apply(T obj);
+}

+ 49 - 0
src/main/java/com/teamunify/i18n/settings/DFKey.java

@@ -0,0 +1,49 @@
+package com.teamunify.i18n.settings;
+
+import java.util.Locale;
+
+class DFKey {
+    private int formatID;
+    private String localeName;
+
+    public DFKey(int formatID, Locale l) {
+        this.formatID = formatID;
+        this.localeName = l.toString();
+    }
+
+    private DFKey(int formatID, String lname) {
+        this.formatID = formatID;
+        this.localeName = lname;
+    }
+
+    @Override
+    public int hashCode() {
+        final int prime = 31;
+        int result = 1;
+        result = prime * result + formatID;
+        result = prime * result + ((localeName == null) ? 0 : localeName.hashCode());
+        return result;
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (this == obj) return true;
+        if (obj == null) return false;
+        if (getClass() != obj.getClass()) return false;
+        DFKey other = (DFKey) obj;
+        if (formatID != other.formatID) return false;
+        if (localeName == null) {
+            if (other.localeName != null) return false;
+        } else if (!localeName.equals(other.localeName)) return false;
+        return true;
+    }
+
+    public DFKey withoutCountry() {
+        final int idx = this.localeName.indexOf("_");
+        if (idx > 0) {
+            final String lang = this.localeName.substring(0, idx);
+            return new DFKey(this.formatID, lang);
+        }
+        return this;
+    }
+}

+ 118 - 0
src/main/java/com/teamunify/i18n/settings/DateFormatVendor.java

@@ -0,0 +1,118 @@
+package com.teamunify.i18n.settings;
+
+import java.text.DateFormat;
+import java.text.SimpleDateFormat;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * A class on which you can register custom date formats.
+ */
+final public class DateFormatVendor {
+    public static final int DEFAULT_DATE_FORMAT_ID = 9;
+    private ConcurrentHashMap<DFKey, DateFormat> registry = new ConcurrentHashMap<DFKey, DateFormat>();
+    private HashMap<DFKey, DateFormat[]> inputRegistry = new HashMap<DFKey, DateFormat[]>();
+
+    /**
+     * Returns a date format object for the given (registered) formatID and locale.
+     * <p/>
+     * <p/>
+     * This method attempts to find a match for the exact locale first. If that fails, it attempts dropping the country
+     * code (if applicable) and searching for a match on just the language. If that fails, it will return Java
+     * DateFormat.getInstance(style) for the alternate specified.
+     *
+     * @param formatID  The format ID previously registered
+     * @param l         The locale
+     * @param alternate The Java locale-specific DateFormat.(SHORT, MEDIUM, LONG) to return if not found.
+     * @return The registered date format for the given locale, the alternate if necessary, and DateFormat.SHORT if all else fails.
+     */
+    public DateFormat getFormatFor(int formatID, Locale l, int alternate) {
+        DateFormat rv = null;
+        if (formatID == DateFormat.SHORT || formatID == DateFormat.LONG || formatID == DateFormat.MEDIUM)
+            rv = DateFormat.getDateInstance(formatID, l);
+        else {
+            DFKey key = new DFKey(formatID, l);
+            rv = clonedFormat(registry.get(key));
+            if (rv == null) rv = clonedFormat(registry.get(key.withoutCountry()));
+            if (rv == null && formatID == DEFAULT_DATE_FORMAT_ID)
+                return DateFormat.getDateInstance(DateFormat.SHORT, l);
+        }
+        if (rv == null) return getFormatFor(alternate, l, DateFormat.SHORT);
+        return rv;
+    }
+
+    private DateFormat clonedFormat(DateFormat f) {
+        if (f == null) return null;
+        return (DateFormat) f.clone();
+    }
+
+    /**
+     * Register the given format with this vendor. You must manually remove a registered format, as this function will
+     * refuse to overwrite.
+     *
+     * @param formatID     The format ID. MUST be > DEFAULT_DATE_FORMAT_ID
+     * @param l            The locale that this format applies to
+     * @param fmt          The format
+     * @param useWithInput Pass true if you want this format to be accepted for date input.
+     * @throws IllegalArgumentException if formatID is too small.
+     */
+    public void registerFormat(int formatID, Locale l, DateFormat fmt, boolean useWithInput) {
+        if (formatID < DEFAULT_DATE_FORMAT_ID)
+            throw new IllegalArgumentException(String.format("Custom date format IDs must be greater than DEFAULT_DATE_FORMAT_ID (%d)", DEFAULT_DATE_FORMAT_ID));
+        DFKey key = new DFKey(formatID, l);
+        registry.put(key, fmt);
+
+        if (useWithInput) this.registerInputFormat(l, fmt);
+    }
+
+    /**
+     * Remove a registered format from the vendor.
+     *
+     * @param formatID The format ID
+     * @param l        The locale to affect
+     */
+    public void unregisterFormat(int formatID, Locale l) {
+        DFKey key = new DFKey(formatID, l);
+        registry.remove(key);
+    }
+
+    public void registerInputFormat(Locale l, DateFormat fmt) {
+        DFKey key = new DFKey(0xFFFF, l);
+        synchronized (inputRegistry) {
+            DateFormat[] list = inputRegistry.get(l);
+            if (list == null) {
+                list = new DateFormat[0];
+            }
+            DateFormat[] newList = Arrays.copyOf(list, list.length + 1);
+            newList[list.length] = fmt;
+            inputRegistry.put(key, newList);
+        }
+    }
+
+    private static final DateFormat emptyList[] = new DateFormat[0];
+
+    public DateFormat[] getInputFormats(Locale l) {
+        DFKey k = new DFKey(0xFFFF, l);
+        DateFormat rv[] = null;
+        rv = inputRegistry.get(k);
+        if (rv == null) rv = inputRegistry.get(k.withoutCountry());
+        if (rv == null) rv = emptyList;
+
+        return getDateParsers(rv, l);
+    }
+
+    DateFormat[] getDateParsers(DateFormat[] customFormats, Locale locale) {
+        DateFormat[] dateParsers = new SimpleDateFormat[4 + customFormats.length];
+        dateParsers[0] = DateFormat.getDateInstance(DateFormat.SHORT, locale);
+        dateParsers[1] = DateFormat.getDateInstance(DateFormat.MEDIUM, locale);
+        dateParsers[2] = DateFormat.getDateInstance(DateFormat.LONG, locale);
+        dateParsers[3] = new SimpleDateFormat("yyyy-MM-dd", locale);
+
+        for (int i = 0; i < customFormats.length; i++)
+            dateParsers[4 + i] = clonedFormat(customFormats[i]);
+
+        return dateParsers;
+    }
+}

+ 19 - 0
src/main/java/com/teamunify/i18n/settings/GlobalLanguageSettingsProvider.java

@@ -0,0 +1,19 @@
+package com.teamunify.i18n.settings;
+
+import java.util.Locale;
+
+/**
+ * A class that can set a system-wide language settings provider. Useful for regular Java apps where all threads
+ * share a single locale that is set by the user (via a menu, etc).
+ */
+public class GlobalLanguageSettingsProvider implements LanguageSettingsProvider {
+    volatile LanguageSetting lang;
+
+    public LanguageSetting vend() {
+        return lang;
+    }
+
+    public void setLocale(Locale l) {
+        lang = new LanguageSetting(l);
+    }
+}

+ 144 - 0
src/main/java/com/teamunify/i18n/settings/LanguageSetting.java

@@ -0,0 +1,144 @@
+package com.teamunify.i18n.settings;
+
+import java.text.DateFormat;
+import java.text.DecimalFormat;
+import java.text.MessageFormat;
+import java.text.NumberFormat;
+import java.text.SimpleDateFormat;
+import java.util.Enumeration;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.ResourceBundle;
+import java.util.Vector;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This stores all of the information necessary to process locale-specific data, such as message strings, dates,
+ * currency, etc. The central I class obtains the language settings via a LanguageSettingsProvider, which must be
+ * set before any of the methods can be used.
+ * <p>
+ * Regular Java applications will use a provider that is simply a singleton. Webapps will most likely use a thread-local
+ * provider so that a given set of settings are used on each request thread, allowing the webapp to set the locale
+ * as each request is processed.
+ * </p>
+ * <p>
+ * Compiled translation files (using GNU gettext conversion to Java) MUST be placed in the com.teamunify.i18n package,
+ * and must be named according to the locale naming standards (e.g. messages_en_US). You can change the required package
+ * by setting LanguageSetting.translationPackage BEFORE using any i18n facilities.
+ * </p>
+ *
+ * @see com.teamunify.i18n.webapp.AbstractLocaleFilter
+ */
+public final class LanguageSetting {
+    private static Logger log = LoggerFactory.getLogger(LanguageSetting.class);
+    public static String translationPackage = "com.teamunify.i18n";
+    public static final ResourceBundle emptyLanguageBundle = new ResourceBundle() {
+        @Override
+        public Enumeration<String> getKeys() {
+            return new Vector<String>().elements();
+        }
+
+        @Override
+        protected Object handleGetObject(String key) {
+            return null;
+        }
+
+        @Override
+        public String toString() {
+            return "EmptyBundle";
+        }
+    };
+
+    public LanguageSetting(Locale locale) {
+        super();
+        this.locale = locale;
+        this.formatter = new MessageFormat("", locale);
+
+        translation = findBestTranslation(translationPackage, locale);
+
+        DecimalFormat d = (DecimalFormat) NumberFormat.getCurrencyInstance(locale);
+        currencySymbol = d.getDecimalFormatSymbols().getCurrencySymbol();
+    }
+
+    private static ResourceBundle loadResourceBundle(String fqcn) {
+        try {
+            return (ResourceBundle) Class.forName(fqcn).newInstance();
+        } catch (Exception e) {
+            log.debug("Could not find resource bundle: {}.", fqcn);
+        }
+        return null;
+    }
+
+    // cache for loaded resource bundles.
+    private static HashMap<String, ResourceBundle> translations = new HashMap<String, ResourceBundle>();
+
+    /**
+     * Look up the possible translation resources that should be used for the locale.
+     *
+     * @param baseClassPackage The package name of your compiled translation resources (e.g. com.mycomp.i18n)
+     * @param l                The locale you want translations for
+     * @return An array of translation resources, possibly empty
+     */
+    public static ResourceBundle findBestTranslation(String baseClassPackage, Locale l) {
+        String lang = l.getLanguage();
+        String key = l.toString();
+
+        ResourceBundle rv = translations.get(key);
+
+        if (rv != null) {
+            if (log.isDebugEnabled())
+                log.debug("Using preloaded {} for {}", rv.getClass().getSimpleName(), key);
+            return rv;
+        }
+
+        try {
+            rv = loadResourceBundle(baseClassPackage + ".messages_" + key);
+            if (rv != null)
+                return rv;
+            rv = loadResourceBundle(baseClassPackage + ".messages_" + lang);
+            if (rv != null)
+                return rv;
+            // This ensures that we don't keep retrying to load a locale that has failed
+            // to load...it also makes other bits of the API work by providing an empty
+            // bundle to look things up against.
+            log.warn("Could not find candidate bundle for {}", key);
+            rv = emptyLanguageBundle;
+        } finally {
+            if (rv != null) {
+                if (log.isDebugEnabled())
+                    log.debug("Saving bundle {} for {}", rv.getClass().getSimpleName(), key);
+                translations.put(key, rv);
+            }
+        }
+
+        return rv;
+    }
+
+    public final Locale locale;
+    public final ResourceBundle translation;
+    public final MessageFormat formatter;
+    public final String currencySymbol;
+
+
+    public DateFormat getShortTimeFormat() {
+        return (SimpleDateFormat) DateFormat.getTimeInstance(DateFormat.SHORT);
+    }
+
+    public DateFormat getLongTimeFormat() {
+        return (SimpleDateFormat) DateFormat.getTimeInstance(DateFormat.MEDIUM);
+    }
+
+    public DateFormat getMilitaryTimeFormat(boolean withSeconds) {
+        return withSeconds ? new SimpleDateFormat("H:m:s") : new SimpleDateFormat("H:m");
+    }
+
+    public DateFormat getCompactMilitaryTimeFormat() {
+        return new SimpleDateFormat("HHmm");
+    }
+
+    public DateFormat getAccurateTimeFormat() {
+        return new SimpleDateFormat("H:m:s.S");
+    }
+}

+ 35 - 0
src/main/java/com/teamunify/i18n/settings/LanguageSettingsProvider.java

@@ -0,0 +1,35 @@
+package com.teamunify.i18n.settings;
+
+import java.util.Locale;
+
+/**
+ * A factory that creates LanguageSetting objects for use by the translation functions. Implementations of this can
+ * provide anything from thread-local settings, to global settings.
+ *
+ * <p>
+ * The basic functionality is as follows:
+ * <ol>
+ * <li>You create an instance of a provider</li>
+ * <li>You give that provider to the I.setLanguageSettingsProvider</li>
+ * <li>Any time the target language is set via I.setLanguage, the request is sent to the setting provider</li>
+ * <li>Translation functions then ask the provide to vend a LangaugeSetting whenever a translation is requested.</li>
+ * </ol>
+ *
+ * <p>
+ * Implementations should therefore do a minimal amount of work during vend(). Normally, a provider will be using a
+ * instance variable (global setting for the app's locale) or thread-local variable to hold the language setting
+ * (webapp), so it is very fast.
+ */
+public interface LanguageSettingsProvider {
+    /**
+     * Get the current language settings
+     *
+     * @return
+     */
+    public LanguageSetting vend();
+
+    /**
+     * Set the locale using a Locale
+     */
+    public void setLocale(Locale l);
+}

+ 15 - 0
src/main/java/com/teamunify/i18n/settings/ThreadLocalLanguageSetting.java

@@ -0,0 +1,15 @@
+package com.teamunify.i18n.settings;
+
+import com.teamunify.i18n.I;
+
+/**
+ * Thread local variable for holding the current request thread's language preferences, so we can more succinctly call
+ * translation functions. Without this, we'd have to pass the session to each and every call of tr(). @see
+ * AbstractLocaleFilter.
+ */
+public class ThreadLocalLanguageSetting extends ThreadLocal<LanguageSetting> {
+    @Override
+    protected LanguageSetting initialValue() {
+        return I.getDefaultLanguage();
+    }
+}

+ 29 - 0
src/main/java/com/teamunify/i18n/settings/ThreadLocalLanguageSettingsProvider.java

@@ -0,0 +1,29 @@
+package com.teamunify.i18n.settings;
+
+import java.util.Locale;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A language settings provider that can manage language settings on a per-thread basis. Usually what you want if you
+ * are writing a webapp (each request serves a specific user on a thread).
+ *
+ * <p>
+ * Of course, you'll need to call I.setLanguage at the start of each request, which will update the provider's
+ * thread-local idea of the current language for that thread.
+ */
+public class ThreadLocalLanguageSettingsProvider implements LanguageSettingsProvider {
+    private static Logger log = LoggerFactory.getLogger(ThreadLocalLanguageSetting.class);
+    ThreadLocalLanguageSetting currentLanguage = new ThreadLocalLanguageSetting();
+
+    public LanguageSetting vend() {
+        return currentLanguage.get();
+    }
+
+    public void setLocale(Locale l) {
+        LanguageSetting setting = new LanguageSetting(l);
+        log.debug("Setting language bundle to {}", setting.translation.getClass().getName());
+        currentLanguage.set(setting);
+    }
+}

+ 7 - 0
src/main/java/com/teamunify/i18n/settings/TimeFormatVendor.java

@@ -0,0 +1,7 @@
+package com.teamunify.i18n.settings;
+
+/**
+ * Created by Tony Kay on 2/3/14.
+ */
+public interface TimeFormatVendor {
+}

+ 48 - 0
src/main/java/com/teamunify/i18n/wiki/SimpleWikifier.java

@@ -0,0 +1,48 @@
+package com.teamunify.i18n.wiki;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Convert simple wiki markup into HTML markup.
+ * <p>
+ * The support in this class honors:
+ *
+ * <ul>
+ * <li>bold (surround with **)
+ * <li>italics (surround with //)
+ * <li>underline (surround with __)
+ * <li>linebreak (_br_)
+ * <li>links ([[URL|text]]).
+ * </ul>
+ */
+public class SimpleWikifier implements Wikifier {
+    private static Pattern boldPattern = Pattern.compile("\\*\\*([^*/_]*)\\*\\*");
+    private static Pattern italicPattern = Pattern.compile("//([^/*_]*)//");
+    private static Pattern underlinePattern = Pattern.compile("__([^*/_]*)__");
+    private static Pattern linebreak = Pattern.compile("_br_");
+    private static Pattern redfont = Pattern.compile("_r_([^*/_]*)_r_");
+    private static Pattern linkPattern = Pattern.compile("\\[\\[([^|]*)\\|([^]]*)\\]\\]");
+
+    public String wikified(String msg) {
+        Matcher m = boldPattern.matcher(msg);
+        if (m.find())
+            msg = m.replaceAll("<b>$1</b>");
+        m = italicPattern.matcher(msg);
+        if (m.find())
+            msg = m.replaceAll("<i>$1</i>");
+        m = underlinePattern.matcher(msg);
+        if (m.find())
+            msg = m.replaceAll("<u>$1</u>");
+        m = redfont.matcher(msg);
+        if (m.find())
+            msg = m.replaceAll("<font color=red>$1</font>");
+        m = linebreak.matcher(msg);
+        if (m.find())
+            msg = m.replaceAll("<br>");
+        m = linkPattern.matcher(msg);
+        if (m.find())
+            msg = m.replaceAll("<a href=\"$1\">$2</a>");
+        return msg;
+    }
+}

+ 16 - 0
src/main/java/com/teamunify/i18n/wiki/Wikifier.java

@@ -0,0 +1,16 @@
+package com.teamunify.i18n.wiki;
+
+/**
+ * Defines an interface for objects that can turn wiki markup into your desired output (e.g. HTML)
+ *
+ * @author tonykay
+ */
+public interface Wikifier {
+    /**
+     * Pass in a string with Wiki markup
+     *
+     * @param s The wiki text
+     * @return The desired output
+     */
+    public String wikified(String s);
+}