View Javadoc

1   /*
2    * ModeShape (http://www.modeshape.org)
3    * See the COPYRIGHT.txt file distributed with this work for information
4    * regarding copyright ownership.  Some portions may be licensed
5    * to Red Hat, Inc. under one or more contributor license agreements.
6   * See the AUTHORS.txt file in the distribution for a full listing of 
7   * individual contributors.
8    *
9    * ModeShape is free software. Unless otherwise indicated, all code in ModeShape
10   * is licensed to you under the terms of the GNU Lesser General Public License as
11   * published by the Free Software Foundation; either version 2.1 of
12   * the License, or (at your option) any later version.
13   *
14   * ModeShape is distributed in the hope that it will be useful,
15   * but WITHOUT ANY WARRANTY; without even the implied warranty of
16   * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
17   * Lesser General Public License for more details.
18   *
19   * You should have received a copy of the GNU Lesser General Public
20   * License along with this software; if not, write to the Free
21   * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
22   * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
23   */
24  package org.modeshape.common.i18n;
25  
26  import java.io.IOException;
27  import java.io.InputStream;
28  import java.lang.reflect.Field;
29  import java.lang.reflect.Modifier;
30  import java.net.URL;
31  import java.util.Collections;
32  import java.util.HashSet;
33  import java.util.Locale;
34  import java.util.Map;
35  import java.util.Properties;
36  import java.util.Set;
37  import java.util.Map.Entry;
38  import java.util.concurrent.ConcurrentHashMap;
39  import java.util.concurrent.ConcurrentMap;
40  import java.util.concurrent.CopyOnWriteArraySet;
41  import net.jcip.annotations.ThreadSafe;
42  import org.modeshape.common.CommonI18n;
43  import org.modeshape.common.SystemFailureException;
44  import org.modeshape.common.util.CheckArg;
45  import org.modeshape.common.util.ClassUtil;
46  import org.modeshape.common.util.StringUtil;
47  
48  /**
49   * An internalized string object, which manages the initialization of internationalization (i18n) files, substitution of values
50   * within i18n message placeholders, and dynamically reading properties from i18n property files.
51   */
52  @ThreadSafe
53  public final class I18n {
54  
55      private static final LocalizationRepository DEFAULT_LOCALIZATION_REPOSITORY = new ClasspathLocalizationRepository();
56  
57      /**
58       * The first level of this map indicates whether an i18n class has been localized to a particular locale. The second level
59       * contains any problems encountered during localization.
60       */
61      static final ConcurrentMap<Locale, Map<Class<?>, Set<String>>> LOCALE_TO_CLASS_TO_PROBLEMS_MAP = new ConcurrentHashMap<Locale, Map<Class<?>, Set<String>>>();
62  
63      private static LocalizationRepository localizationRepository = DEFAULT_LOCALIZATION_REPOSITORY;
64  
65      /**
66       * Note, calling this method will <em>not</em> trigger localization of the supplied internationalization class.
67       * 
68       * @param i18nClass The internalization class for which localization problem locales should be returned.
69       * @return The locales for which localization problems were encountered while localizing the supplied internationalization
70       *         class; never <code>null</code>.
71       */
72      public static Set<Locale> getLocalizationProblemLocales( Class<?> i18nClass ) {
73          CheckArg.isNotNull(i18nClass, "i18nClass");
74          Set<Locale> locales = new HashSet<Locale>(LOCALE_TO_CLASS_TO_PROBLEMS_MAP.size());
75          for (Entry<Locale, Map<Class<?>, Set<String>>> localeEntry : LOCALE_TO_CLASS_TO_PROBLEMS_MAP.entrySet()) {
76              for (Entry<Class<?>, Set<String>> classEntry : localeEntry.getValue().entrySet()) {
77                  if (!classEntry.getValue().isEmpty()) {
78                      locales.add(localeEntry.getKey());
79                      break;
80                  }
81              }
82          }
83          return locales;
84      }
85  
86      /**
87       * Note, calling this method will <em>not</em> trigger localization of the supplied internationalization class.
88       * 
89       * @param i18nClass The internalization class for which localization problems should be returned.
90       * @return The localization problems encountered while localizing the supplied internationalization class to the default
91       *         locale; never <code>null</code>.
92       */
93      public static Set<String> getLocalizationProblems( Class<?> i18nClass ) {
94          return getLocalizationProblems(i18nClass, null);
95      }
96  
97      /**
98       * Note, calling this method will <em>not</em> trigger localization of the supplied internationalization class.
99       * 
100      * @param i18nClass The internalization class for which localization problems should be returned.
101      * @param locale The locale for which localization problems should be returned. If <code>null</code>, the default locale will
102      *        be used.
103      * @return The localization problems encountered while localizing the supplied internationalization class to the supplied
104      *         locale; never <code>null</code>.
105      */
106     public static Set<String> getLocalizationProblems( Class<?> i18nClass,
107                                                        Locale locale ) {
108         CheckArg.isNotNull(i18nClass, "i18nClass");
109         Map<Class<?>, Set<String>> classToProblemsMap = LOCALE_TO_CLASS_TO_PROBLEMS_MAP.get(locale == null ? Locale.getDefault() : locale);
110         if (classToProblemsMap == null) {
111             return Collections.emptySet();
112         }
113         Set<String> problems = classToProblemsMap.get(i18nClass);
114         if (problems == null) {
115             return Collections.emptySet();
116         }
117         return problems;
118     }
119 
120     /**
121      * Get the repository of localized messages. By default, this instance uses a {@link ClasspathLocalizationRepository} that
122      * uses this class' classloader.
123      * 
124      * @return localizationRepository
125      */
126     public static LocalizationRepository getLocalizationRepository() {
127         return localizationRepository;
128     }
129 
130     /**
131      * Set the repository of localized messages. If <code>null</code>, a {@link ClasspathLocalizationRepository} instance that
132      * uses this class loader will be used.
133      * 
134      * @param localizationRepository the localization repository to use; may be <code>null</code> if the default repository should
135      *        be used.
136      */
137     public static void setLocalizationRepository( LocalizationRepository localizationRepository ) {
138         I18n.localizationRepository = localizationRepository != null ? localizationRepository : DEFAULT_LOCALIZATION_REPOSITORY;
139     }
140 
141     /**
142      * Initializes the internationalization fields declared on the supplied class. Internationalization fields must be public,
143      * static, not final, and of type <code>I18n</code>. The supplied class must not be an interface (of course), but has no
144      * restrictions as to what class it may extend or what interfaces it must implement.
145      * 
146      * @param i18nClass A class declaring one or more public, static, non-final fields of type <code>I18n</code>.
147      */
148     public static void initialize( Class<?> i18nClass ) {
149         CheckArg.isNotNull(i18nClass, "i18nClass");
150         if (i18nClass.isInterface()) {
151             throw new IllegalArgumentException(CommonI18n.i18nClassInterface.text(i18nClass.getName()));
152         }
153 
154         synchronized (i18nClass) {
155             // Find all public static non-final String fields in the supplied class and instantiate an I18n object for each.
156             try {
157                 for (Field fld : i18nClass.getDeclaredFields()) {
158 
159                     // Ensure field is of type I18n
160                     if (fld.getType() == I18n.class) {
161 
162                         // Ensure field is public
163                         if ((fld.getModifiers() & Modifier.PUBLIC) != Modifier.PUBLIC) {
164                             throw new SystemFailureException(CommonI18n.i18nFieldNotPublic.text(fld.getName(), i18nClass));
165                         }
166 
167                         // Ensure field is static
168                         if ((fld.getModifiers() & Modifier.STATIC) != Modifier.STATIC) {
169                             throw new SystemFailureException(CommonI18n.i18nFieldNotStatic.text(fld.getName(), i18nClass));
170                         }
171 
172                         // Ensure field is not final
173                         if ((fld.getModifiers() & Modifier.FINAL) == Modifier.FINAL) {
174                             throw new SystemFailureException(CommonI18n.i18nFieldFinal.text(fld.getName(), i18nClass));
175                         }
176 
177                         // Ensure we can access field even if it's in a private class
178                         ClassUtil.makeAccessible(fld);
179 
180                         // Initialize field. Do this every time the class is initialized (or re-initialized)
181                         fld.set(null, new I18n(fld.getName(), i18nClass));
182                     }
183                 }
184 
185                 // Remove all entries for the supplied i18n class to indicate it has not been localized.
186                 for (Entry<Locale, Map<Class<?>, Set<String>>> entry : LOCALE_TO_CLASS_TO_PROBLEMS_MAP.entrySet()) {
187                     entry.getValue().remove(i18nClass);
188                 }
189             } catch (IllegalAccessException err) {
190                 // If this happens, it will happen with the first field visited in the above loop
191                 throw new IllegalArgumentException(CommonI18n.i18nClassNotPublic.text(i18nClass));
192             }
193         }
194     }
195 
196     /**
197      * Synchronized on the supplied internalization class.
198      * 
199      * @param i18nClass The internalization class being localized
200      * @param locale The locale to which the supplied internationalization class should be localized.
201      */
202     private static void localize( final Class<?> i18nClass,
203                                   final Locale locale ) {
204         assert i18nClass != null;
205         assert locale != null;
206         // Create a class-to-problem map for this locale if one doesn't exist, else get the existing one.
207         Map<Class<?>, Set<String>> classToProblemsMap = new ConcurrentHashMap<Class<?>, Set<String>>();
208         Map<Class<?>, Set<String>> existingClassToProblemsMap = LOCALE_TO_CLASS_TO_PROBLEMS_MAP.putIfAbsent(locale,
209                                                                                                             classToProblemsMap);
210         if (existingClassToProblemsMap != null) {
211             classToProblemsMap = existingClassToProblemsMap;
212         }
213         // Check if already localized outside of synchronization block for 99% use-case
214         if (classToProblemsMap.get(i18nClass) != null) {
215             return;
216         }
217         synchronized (i18nClass) {
218             // Return if the supplied i18n class has already been localized to the supplied locale, despite the check outside of
219             // the synchronization block (1% use-case), else create a class-to-problems map for the class.
220             Set<String> problems = classToProblemsMap.get(i18nClass);
221             if (problems == null) {
222                 problems = new CopyOnWriteArraySet<String>();
223                 classToProblemsMap.put(i18nClass, problems);
224             } else {
225                 return;
226             }
227             // Get the URL to the localization properties file ...
228             final LocalizationRepository repos = getLocalizationRepository();
229             final String localizationBaseName = i18nClass.getName();
230             URL url = repos.getLocalizationBundle(localizationBaseName, locale);
231             if (url == null) {
232                 // Nothing was found, so try the default locale
233                 Locale defaultLocale = Locale.getDefault();
234                 if (!defaultLocale.equals(locale)) {
235                     url = repos.getLocalizationBundle(localizationBaseName, defaultLocale);
236                 }
237                 // Return if no applicable localization file could be found
238                 if (url == null) {
239                     problems.add(CommonI18n.i18nLocalizationFileNotFound.text(localizationBaseName));
240                     return;
241                 }
242             }
243             // Initialize i18n map
244             final URL finalUrl = url;
245             final Set<String> finalProblems = problems;
246             Properties props = new Properties() {
247 
248                 /**
249                  */
250                 private static final long serialVersionUID = 3920620306881072843L;
251 
252                 @Override
253                 public synchronized Object put( Object key,
254                                                 Object value ) {
255                     String id = (String)key;
256                     String text = (String)value;
257 
258                     try {
259                         Field fld = i18nClass.getDeclaredField(id);
260                         if (fld.getType() != I18n.class) {
261                             // Invalid field type
262                             finalProblems.add(CommonI18n.i18nFieldInvalidType.text(id, finalUrl, getClass().getName()));
263                         } else {
264                             I18n i18n = (I18n)fld.get(null);
265                             if (i18n.localeToTextMap.putIfAbsent(locale, text) != null) {
266                                 // Duplicate id encountered
267                                 String prevProblem = i18n.localeToProblemMap.putIfAbsent(locale,
268                                                                                          CommonI18n.i18nPropertyDuplicate.text(id,
269                                                                                                                                finalUrl));
270                                 assert prevProblem == null;
271                             }
272                         }
273                     } catch (NoSuchFieldException err) {
274                         // No corresponding field exists
275                         finalProblems.add(CommonI18n.i18nPropertyUnused.text(id, finalUrl));
276                     } catch (IllegalAccessException notPossible) {
277                         // Would have already occurred in initialize method, but allowing for the impossible...
278                         finalProblems.add(notPossible.getMessage());
279                     }
280 
281                     return null;
282                 }
283             };
284 
285             try {
286                 InputStream propStream = url.openStream();
287                 try {
288                     props.load(propStream);
289                     // Check for uninitialized fields
290                     for (Field fld : i18nClass.getDeclaredFields()) {
291                         if (fld.getType() == I18n.class) {
292                             try {
293                                 I18n i18n = (I18n)fld.get(null);
294                                 if (i18n.localeToTextMap.get(locale) == null) {
295                                     i18n.localeToProblemMap.put(locale, CommonI18n.i18nPropertyMissing.text(fld.getName(), url));
296                                 }
297                             } catch (IllegalAccessException notPossible) {
298                                 // Would have already occurred in initialize method, but allowing for the impossible...
299                                 finalProblems.add(notPossible.getMessage());
300                             }
301                         }
302                     }
303                 } finally {
304                     propStream.close();
305                 }
306             } catch (IOException err) {
307                 finalProblems.add(err.getMessage());
308             }
309         }
310     }
311 
312     private final String id;
313     private final Class<?> i18nClass;
314     final ConcurrentHashMap<Locale, String> localeToTextMap = new ConcurrentHashMap<Locale, String>();
315     final ConcurrentHashMap<Locale, String> localeToProblemMap = new ConcurrentHashMap<Locale, String>();
316 
317     private I18n( String id,
318                   Class<?> i18nClass ) {
319         this.id = id;
320         this.i18nClass = i18nClass;
321     }
322 
323     /**
324      * @return This internationalization object's ID, which will match both the name of the relevant static field in the
325      *         internationalization class and the relevant property name in the associated localization files.
326      */
327     public String id() {
328         return id;
329     }
330 
331     /**
332      * @return <code>true</code> if a problem was encountered while localizing this internationalization object to the default
333      *         locale.
334      */
335     public boolean hasProblem() {
336         return (problem() != null);
337     }
338 
339     /**
340      * @param locale The locale for which to check whether a problem was encountered.
341      * @return <code>true</code> if a problem was encountered while localizing this internationalization object to the supplied
342      *         locale.
343      */
344     public boolean hasProblem( Locale locale ) {
345         return (problem(locale) != null);
346     }
347 
348     /**
349      * @return The problem encountered while localizing this internationalization object to the default locale, or
350      *         <code>null</code> if none was encountered.
351      */
352     public String problem() {
353         return problem(null);
354     }
355 
356     /**
357      * @param locale The locale for which to return the problem.
358      * @return The problem encountered while localizing this internationalization object to the supplied locale, or
359      *         <code>null</code> if none was encountered.
360      */
361     public String problem( Locale locale ) {
362         if (locale == null) {
363             locale = Locale.getDefault();
364         }
365         localize(i18nClass, locale);
366         // Check for field/property error
367         String problem = localeToProblemMap.get(locale);
368         if (problem != null) {
369             return problem;
370         }
371         // Check if text exists
372         if (localeToTextMap.get(locale) != null) {
373             // If so, no problem exists
374             return null;
375         }
376         // If we get here, which will be at most once, there was at least one global localization error, so just return a message
377         // indicating to look them up.
378         problem = CommonI18n.i18nLocalizationProblems.text(i18nClass, locale);
379         localeToProblemMap.put(locale, problem);
380         return problem;
381     }
382 
383     private String rawText( Locale locale ) {
384         assert locale != null;
385         localize(i18nClass, locale);
386         // Check if text exists
387         String text = localeToTextMap.get(locale);
388         if (text != null) {
389             return text;
390         }
391         // If not, there was a problem, so throw it within an exception so upstream callers can tell the difference between normal
392         // text and problem text.
393         throw new SystemFailureException(problem(locale));
394     }
395 
396     /**
397      * Get the localized text for the {@link Locale#getDefault() current (default) locale}, replacing the parameters in the text
398      * with those supplied.
399      * 
400      * @param arguments the arguments for the parameter replacement; may be <code>null</code> or empty
401      * @return the localized text
402      */
403     public String text( Object... arguments ) {
404         return text(null, arguments);
405     }
406 
407     /**
408      * Get the localized text for the supplied locale, replacing the parameters in the text with those supplied.
409      * 
410      * @param locale the locale, or <code>null</code> if the {@link Locale#getDefault() current (default) locale} should be used
411      * @param arguments the arguments for the parameter replacement; may be <code>null</code> or empty
412      * @return the localized text
413      */
414     public String text( Locale locale,
415                         Object... arguments ) {
416         try {
417             String rawText = rawText(locale == null ? Locale.getDefault() : locale);
418             return StringUtil.createString(rawText, arguments);
419         } catch (IllegalArgumentException err) {
420             throw new IllegalArgumentException(CommonI18n.i18nRequiredToSuppliedParameterMismatch.text(id,
421                                                                                                        i18nClass,
422                                                                                                        err.getMessage()));
423         } catch (SystemFailureException err) {
424             return '<' + err.getMessage() + '>';
425         }
426     }
427 
428     /**
429      * {@inheritDoc}
430      */
431     @Override
432     public String toString() {
433         try {
434             return rawText(Locale.getDefault());
435         } catch (SystemFailureException err) {
436             return '<' + err.getMessage() + '>';
437         }
438     }
439 }