Refactoring: extracted i18n.Translator.Helpers into i18n.Messages.
[aprog.git] / Aprog / src / net / sourceforge / aprog / i18n / Translator.java
blob9c91c78eabda07f9955306f1a43355a4eca16ba9
1 /*
2 * The MIT License
4 * Copyright 2010 The Codist Monk.
6 * Permission is hereby granted, free of charge, to any person obtaining a copy
7 * of this software and associated documentation files (the "Software"), to deal
8 * in the Software without restriction, including without limitation the rights
9 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 * copies of the Software, and to permit persons to whom the Software is
11 * furnished to do so, subject to the following conditions:
13 * The above copyright notice and this permission notice shall be included in
14 * all copies or substantial portions of the Software.
16 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22 * THE SOFTWARE.
25 package net.sourceforge.aprog.i18n;
27 import static net.sourceforge.aprog.tools.Tools.*;
29 import java.io.UnsupportedEncodingException;
30 import java.lang.reflect.Method;
31 import java.text.MessageFormat;
32 import java.util.ArrayList;
33 import java.util.Collection;
34 import java.util.HashSet;
35 import java.util.Iterator;
36 import java.util.Locale;
37 import java.util.MissingResourceException;
38 import java.util.ResourceBundle;
39 import java.util.Set;
40 import java.util.logging.Level;
42 import net.sourceforge.aprog.tools.Tools;
44 /**
45 * Instances of this class can translate messages using locales and resource bundles.
46 * <br>The easiest way to add translation to a Swing program with this class is by using the static methods in {@link Translator.Helpers}.
47 * <br>To improve performance, call {@code this.setAutoCollectingLocales(false)} after all available locales have been collected.
48 * <br>You can manually collect locales with {@link #collectAvailableLocales(String)}.
49 * <br>Instances of this class are thread-safe as long as the listeners don't cause synchronization problems.
51 * @author codistmonk (2010-05-11)
54 public class Translator {
56 private final Collection<Listener> listeners;
58 private final Set<Autotranslator> autotranslators;
60 private final Set<Locale> availableLocales;
62 private Locale locale;
64 private boolean autoCollectingLocales;
66 public Translator() {
67 this.listeners = new ArrayList<Listener>();
68 this.autotranslators = new HashSet<Autotranslator>();
69 this.availableLocales = new HashSet<Locale>();
70 this.locale = Locale.getDefault();
71 this.autoCollectingLocales = true;
74 /**
76 * @param listener
77 * <br>Should not be null
78 * <br>Shared parameter
80 public final synchronized void addTranslatorListener(final Listener listener) {
81 this.listeners.add(listener);
84 /**
86 * @param listener
87 * <br>Can be null
89 public final synchronized void removeTranslatorListener(final Listener listener) {
90 this.listeners.remove(listener);
93 /**
95 * @return {@code true} if {@link #collectAvailableLocales(String)} is called automatically each time a translation is performed
97 public final boolean isAutoCollectingLocales() {
98 return this.autoCollectingLocales;
103 * @param autoCollectingLocales {@code true} if {@link #collectAvailableLocales(String)} should be called automatically each time a translation is performed
105 public final void setAutoCollectingLocales(final boolean autoCollectingLocales) {
106 this.autoCollectingLocales = autoCollectingLocales;
111 * @return
112 * <br>A non-null value
113 * <br>A new value
115 public final synchronized Listener[] getTranslatorListeners() {
116 return this.listeners.toArray(new Listener[this.listeners.size()]);
121 * TODO doc
122 * @param <T> the actual type of {@code object}
123 * @param object
124 * <br>Should not be null
125 * <br>Input-output parameter
126 * <br>Shared parameter
127 * @param textPropertyName
128 * <br>Should not be null
129 * <br>Shared parameter
130 * @param translationKey
131 * <br>Should not be null
132 * <br>Shared parameter
133 * @param messagesBase
134 * <br>Should not be null
135 * <br>Shared parameter
136 * @param parameters
137 * <br>Should not be null
138 * @return {@code object}
139 * <br>A non-null value
140 * <br>A shared value
142 public final synchronized <T> T translate(final T object, final String textPropertyName, final String translationKey, final String messagesBase, final Object... parameters) {
143 this.autoCollectLocales(messagesBase);
145 final Autotranslator autotranslator = this.new Autotranslator(object, textPropertyName, translationKey, messagesBase, parameters);
147 autotranslator.translate();
149 // If there is already another autotranslator with the same object and textPropertyName
150 // remove it before adding the new autotranslator
151 this.autotranslators.remove(autotranslator);
152 this.autotranslators.add(autotranslator);
154 return object;
158 * Removes {@code object} from the list of autotranslatables and resets its text property to the translation key.
159 * <br>That means that subsequent calls to {@link #setLocale(Locale)} won't update {@code object} anymore.
161 * @param object
162 * <br>Should not be null
163 * <br>Input-output parameter
164 * <br>Shared parameter
165 * @param textPropertyName
166 * <br>Should not be null
167 * <br>Shared parameter
169 public final synchronized void untranslate(final Object object, final String textPropertyName) {
170 for (final Iterator<Autotranslator> iterator = this.autotranslators.iterator(); iterator.hasNext();) {
171 final Autotranslator autotranslator = iterator.next();
173 if (autotranslator.getObject().equals(object) && autotranslator.getTextPropertyName().equals(textPropertyName)) {
174 iterator.remove();
176 autotranslator.untranslate();
178 return;
185 * @return
186 * <br>A non-null value
187 * <br>A shared value
189 public final synchronized Locale getLocale() {
190 return this.locale;
194 * If {@code this.getLocale()} is not equal to {@code locale},
195 * then the locale is changed, the autotranslators are updated and the listeners are notified.
197 * @param locale
198 * <br>Should not be null
199 * <br>Shared parameter
201 public final synchronized void setLocale(final Locale locale) {
202 if (!this.getLocale().equals(locale)) {
203 final Locale oldLocale = this.getLocale();
205 this.locale = locale;
207 for (final Autotranslator autotranslator : this.autotranslators) {
208 autotranslator.translate();
211 for (final Listener listener : this.getTranslatorListeners()) {
212 listener.localeChanged(oldLocale, this.getLocale());
218 * The set of available locales can be augmented with {@link #getAvailableLocales()}.
219 * <br>{@link #getAvailableLocales()} is called each time a translation is performed.
221 * @return
222 * <br>A new value
223 * <br>A non-null value
225 public final synchronized Locale[] getAvailableLocales() {
226 return this.availableLocales.toArray(new Locale[this.availableLocales.size()]);
231 * TODO doc
232 * @param translationKey
233 * <br>Should not be null
234 * @param messagesBase
235 * <br>Should not be null
236 * @param parameters
237 * <br>Should not be null
238 * @return
239 * <br>A non-null value
241 public final synchronized String translate(final String translationKey, final String messagesBase, final Object... parameters) {
242 this.autoCollectLocales(messagesBase);
244 String translatedMessage = translationKey;
246 try {
247 final ResourceBundle messages = ResourceBundle.getBundle(messagesBase, this.getLocale());
249 translatedMessage = iso88591ToUTF8(messages.getString(translationKey));
250 } catch (final MissingResourceException exception) {
251 System.err.println(Tools.debug(2, exception.getMessage()));
252 getLoggerForThisMethod().log(Level.WARNING, "Missing translation for locale (" + Translator.this.getLocale() + ") of " + translationKey);
255 final Object[] localizedParameters = parameters.clone();
257 for (int i = 0; i < localizedParameters.length; ++i) {
258 if (localizedParameters[i] instanceof Throwable) {
259 localizedParameters[i] = ((Throwable) localizedParameters[i]).getLocalizedMessage();
263 return MessageFormat.format(translatedMessage, localizedParameters);
267 * Scans {@code messagesBase} using {@link Locale#getAvailableLocales()} and adds the available locales to {@code this}.
268 * <br>A locale is "available" to the translator if an appropriate resource bundle is found.
270 * @param messagesBase
271 * <br>Should not be null
273 public final synchronized void collectAvailableLocales(final String messagesBase) {
274 // TODO don't rely on Locale.getAvailableLocales(), use only messagesBase if possible
275 for (final Locale predefinedLocale : Locale.getAvailableLocales()) {
276 try {
277 if (predefinedLocale.equals(ResourceBundle.getBundle(messagesBase, predefinedLocale).getLocale())) {
278 this.availableLocales.add(predefinedLocale);
280 } catch (final Exception exception) {
281 // Do nothing
287 * Calls {@link #collectAvailableLocales(String)} if {@code this.isAutoCollectingLocales()}.
289 * @param messagesBase
290 * <br>Should not be null
292 private final void autoCollectLocales(final String messagesBase) {
293 if (this.isAutoCollectingLocales()) {
294 this.collectAvailableLocales(messagesBase);
300 * This class defines a property translation operation.
302 * @author codistmonk (creation 2010-05-11)
305 private final class Autotranslator {
307 private final Object object;
309 private final String textPropertyName;
311 private final String translationKey;
313 private final String messagesBase;
315 private final Object[] parameters;
317 private final Method setter;
321 * @param object
322 * <br>Should not be null
323 * <br>Shared parameter
324 * @param textPropertyName
325 * <br>Should not be null
326 * <br>Shared parameter
327 * @param translationKey
328 * <br>Should not be null
329 * <br>Shared parameter
330 * @param messagesBase
331 * <br>Should not be null
332 * <br>Shared parameter
333 * @param parameters
334 * <br>Should not be null
335 * <br>Shared parameter
336 * @throws RuntimeException if a setter cannot be retrieved for the property.
338 public Autotranslator(final Object object, final String textPropertyName,
339 final String translationKey, final String messagesBase, final Object... parameters) {
340 this.object = object;
341 this.textPropertyName = textPropertyName;
342 this.translationKey = translationKey;
343 this.messagesBase = messagesBase;
344 this.parameters = parameters;
345 this.setter = getSetter(object, textPropertyName, String.class);
350 * @return
351 * <br>A non-null value
352 * <br>A shared value
354 public final Object getObject() {
355 return this.object;
360 * @return
361 * <br>A non-null value
362 * <br>A shared value
364 public final String getTextPropertyName() {
365 return this.textPropertyName;
368 public final void translate() {
369 this.set(Translator.this.translate(this.translationKey, this.messagesBase, this.parameters));
373 * Sets the property with the translation key.
375 public final void untranslate() {
376 this.set(this.translationKey);
379 @Override
380 public final boolean equals(final Object object) {
381 final Autotranslator that = castToCurrentClass(object);
383 return this == that ||
384 that != null &&
385 this.getObject().equals(that.getObject()) &&
386 this.getTextPropertyName().equals(that.getTextPropertyName());
389 @Override
390 public final int hashCode() {
391 return this.object.hashCode() + this.textPropertyName.hashCode();
395 * Calls {@code this.setter} with parameter {@code text}.
397 * @param text
398 * <br>Should not be null
399 * <br>Shared parameter
401 private final void set(final String text) {
402 try {
403 this.setter.invoke(this.getObject(), text);
404 } catch (final Exception exception) {
405 getLoggerForThisMethod().log(Level.WARNING, "", exception);
411 private static Translator defaultTranslator;
414 * This method creates the default translator if necessary, and then always returns the same value.
416 * @return
417 * <br>A non-null value
418 * <br>A shared value
420 public static final synchronized Translator getDefaultTranslator() {
421 if (defaultTranslator == null) {
422 defaultTranslator = new Translator();
425 return defaultTranslator;
429 * This method gets or creates a {@link Locale} corresponding to {@code languageCountryVariant}.
430 * <br>{@code languageCountryVariant} is a String made of 1 to 3 elements separated by "_":
431 * <br>language ("" or ISO 639 2-letter code) ["_" country ("" or ISO 3166 2-letter code) ["_" variant (can be "")]]
432 * @param languageCountryVariant
433 * <br>Should not be null
434 * @return
435 * <br>A possibly new value
436 * <br>A non-null value
438 public static final Locale createLocale(final String languageCountryVariant) {
439 final String[] tmp = languageCountryVariant.split("_");
440 final String language = tmp[0];
441 final String country = tmp.length > 1 ? tmp[1] : "";
442 final String variant = tmp.length > 2 ? tmp[2] : "";
444 for (final Locale locale : Locale.getAvailableLocales()) {
445 if (locale.getLanguage().equals(language) && locale.getCountry().equals(country) && locale.getVariant().equals(variant)) {
446 return locale;
450 return new Locale(language, country, variant);
454 * This method does the opposite of {@link #createLocale(String)}.
456 * @param locale
457 * <br>Should not be null
458 * @return
459 * <br>A new value
460 * <br>A non-null value
462 public static final String getLanguageCountryVariant(final Locale locale) {
463 String result = locale.getLanguage();
465 if (locale.getCountry().length() > 0) {
466 result += "_" + locale.getCountry();
469 if (locale.getVariant().length() > 0) {
470 result += "_" + locale.getVariant();
473 return result;
477 * This method reinterprets strings read from property files using UTF-8.
478 * <br>{@link ResourceBundle} interprets the contents of .properties files as if they used ISO-8859-1 encoding.
479 * <br>If UTF-8 is used to encode these files, the retrieved messages will present bad characters.
480 * <br>For instance, the character 'Ω' is encoded as {@code 0xCEA9} in UTF-8 but cannot be directly encoded in ISO-8859-1.
481 * <br>Instead, the code \u03A9 would have to be used so that {@link ResourceBundle} retrieves the character 'Ω'.
482 * <br>If a file contains 'Ω' in UTF-8, {@link ResourceBundle} will interpret it using ISO-8859-1 as "Ω".
483 * because {@code 0xCE} is 'Î' and {@code 0xA9} is '©' in this encoding.
484 * <br>If {@code s = "Ω"} is the string retrieved from a file containing 'Ω' in UTF-8,
485 * then {@code !s.equals("Ω")} but {@code iso88591ToUTF8(s).equals("Ω")}.
487 * @param translatedMessage
488 * <br>Should not be null
489 * <br>Shared parameter
490 * @return a new string or {@code translatedMessage} if the conversion fails
491 * <br>A non-null value
492 * <br>Shared value
494 public static final String iso88591ToUTF8(final String translatedMessage) {
495 try {
496 return new String(translatedMessage.getBytes("ISO-8859-1"), "UTF-8");
497 } catch (final UnsupportedEncodingException exception) {
498 getLoggerForThisMethod().log(Level.WARNING, "", exception);
500 return translatedMessage;
506 * Listener interface for translator events.
508 * @author codistmonk (creation 2009-09-28)
511 public static interface Listener {
514 * Called whenever the translator's locale has been changed, and after the registered
515 * objects have been translated.
517 * @param oldLocale
518 * <br>Should not be null
519 * @param newLocale
520 * <br>Should not be null
521 * <br>Shared parameter
523 public abstract void localeChanged(Locale oldLocale, Locale newLocale);