001 /*
002 * JBoss, Home of Professional Open Source.
003 * Copyright 2008, Red Hat Middleware LLC, and individual contributors
004 * as indicated by the @author tags. See the copyright.txt file in the
005 * distribution for a full listing of individual contributors.
006 *
007 * This is free software; you can redistribute it and/or modify it
008 * under the terms of the GNU Lesser General Public License as
009 * published by the Free Software Foundation; either version 2.1 of
010 * the License, or (at your option) any later version.
011 *
012 * This software is distributed in the hope that it will be useful,
013 * but WITHOUT ANY WARRANTY; without even the implied warranty of
014 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
015 * Lesser General Public License for more details.
016 *
017 * You should have received a copy of the GNU Lesser General Public
018 * License along with this software; if not, write to the Free
019 * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
020 * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
021 */
022 package org.jboss.dna.common.i18n;
023
024 import java.io.IOException;
025 import java.io.InputStream;
026 import java.lang.reflect.Field;
027 import java.lang.reflect.Modifier;
028 import java.net.URL;
029 import java.util.Collections;
030 import java.util.HashSet;
031 import java.util.Locale;
032 import java.util.Map;
033 import java.util.Properties;
034 import java.util.Set;
035 import java.util.Map.Entry;
036 import java.util.concurrent.ConcurrentHashMap;
037 import java.util.concurrent.ConcurrentMap;
038 import java.util.concurrent.CopyOnWriteArraySet;
039 import net.jcip.annotations.ThreadSafe;
040 import org.jboss.dna.common.CommonI18n;
041 import org.jboss.dna.common.SystemFailureException;
042 import org.jboss.dna.common.util.CheckArg;
043 import org.jboss.dna.common.util.ClassUtil;
044 import org.jboss.dna.common.util.StringUtil;
045
046 /**
047 * Manages the initialization of internationalization (i18n) files, substitution of values within i18n message placeholders, and
048 * dynamically reading properties from i18n property files.
049 *
050 * @author John Verhaeg
051 * @author Randall Hauch
052 */
053 @ThreadSafe
054 public final class I18n {
055
056 private static final LocalizationRepository DEFAULT_LOCALIZATION_REPOSITORY = new ClasspathLocalizationRepository();
057
058 /**
059 * The first level of this map indicates whether an i18n class has been localized to a particular locale. The second level
060 * contains any problems encountered during localization.
061 */
062 static final ConcurrentMap<Locale, Map<Class<?>, Set<String>>> LOCALE_TO_CLASS_TO_PROBLEMS_MAP = new ConcurrentHashMap<Locale, Map<Class<?>, Set<String>>>();
063
064 private static LocalizationRepository localizationRepository = DEFAULT_LOCALIZATION_REPOSITORY;
065
066 /**
067 * Note, calling this method will <em>not</em> trigger localization of the supplied internationalization class.
068 *
069 * @param i18nClass The internalization class for which localization problem locales should be returned.
070 * @return The locales for which localization problems were encountered while localizing the supplied internationalization
071 * class; never <code>null</code>.
072 */
073 public static Set<Locale> getLocalizationProblemLocales( Class<?> i18nClass ) {
074 CheckArg.isNotNull(i18nClass, "i18nClass");
075 Set<Locale> locales = new HashSet<Locale>(LOCALE_TO_CLASS_TO_PROBLEMS_MAP.size());
076 for (Entry<Locale, Map<Class<?>, Set<String>>> localeEntry : LOCALE_TO_CLASS_TO_PROBLEMS_MAP.entrySet()) {
077 for (Entry<Class<?>, Set<String>> classEntry : localeEntry.getValue().entrySet()) {
078 if (!classEntry.getValue().isEmpty()) {
079 locales.add(localeEntry.getKey());
080 break;
081 }
082 }
083 }
084 return locales;
085 }
086
087 /**
088 * Note, calling this method will <em>not</em> trigger localization of the supplied internationalization class.
089 *
090 * @param i18nClass The internalization class for which localization problems should be returned.
091 * @return The localization problems encountered while localizing the supplied internationalization class to the default
092 * locale; never <code>null</code>.
093 */
094 public static Set<String> getLocalizationProblems( Class<?> i18nClass ) {
095 return getLocalizationProblems(i18nClass, null);
096 }
097
098 /**
099 * Note, calling this method will <em>not</em> trigger localization of the supplied internationalization class.
100 *
101 * @param i18nClass The internalization class for which localization problems should be returned.
102 * @param locale The locale for which localization problems should be returned. If <code>null</code>, the default locale
103 * will be used.
104 * @return The localization problems encountered while localizing the supplied internationalization class to the supplied
105 * locale; never <code>null</code>.
106 */
107 public static Set<String> getLocalizationProblems( Class<?> i18nClass,
108 Locale locale ) {
109 CheckArg.isNotNull(i18nClass, "i18nClass");
110 Map<Class<?>, Set<String>> classToProblemsMap = LOCALE_TO_CLASS_TO_PROBLEMS_MAP.get(locale == null ? Locale.getDefault() : locale);
111 if (classToProblemsMap == null) {
112 return Collections.emptySet();
113 }
114 Set<String> problems = classToProblemsMap.get(i18nClass);
115 if (problems == null) {
116 return Collections.emptySet();
117 }
118 return problems;
119 }
120
121 /**
122 * Get the repository of localized messages. By default, this instance uses a {@link ClasspathLocalizationRepository} that
123 * uses this class' classloader.
124 *
125 * @return localizationRepository
126 */
127 public static LocalizationRepository getLocalizationRepository() {
128 return localizationRepository;
129 }
130
131 /**
132 * Set the repository of localized messages. If <code>null</code>, a {@link ClasspathLocalizationRepository} instance that
133 * uses this class loader will be used.
134 *
135 * @param localizationRepository the localization repository to use; may be <code>null</code> if the default repository
136 * should be used.
137 */
138 public static void setLocalizationRepository( LocalizationRepository localizationRepository ) {
139 I18n.localizationRepository = localizationRepository != null ? localizationRepository : DEFAULT_LOCALIZATION_REPOSITORY;
140 }
141
142 /**
143 * Initializes the internationalization fields declared on the supplied class. Internationalization fields must be public,
144 * static, not final, and of type <code>I18n</code>. The supplied class must not be an interface (of course), but has no
145 * restrictions as to what class it may extend or what interfaces it must implement.
146 *
147 * @param i18nClass A class declaring one or more public, static, non-final fields of type <code>I18n</code>.
148 */
149 public static void initialize( Class<?> i18nClass ) {
150 CheckArg.isNotNull(i18nClass, "i18nClass");
151 if (i18nClass.isInterface()) {
152 throw new IllegalArgumentException(CommonI18n.i18nClassInterface.text(i18nClass.getName()));
153 }
154
155 synchronized (i18nClass) {
156 // Find all public static non-final String fields in the supplied class and instantiate an I18n object for each.
157 try {
158 for (Field fld : i18nClass.getDeclaredFields()) {
159
160 // Ensure field is of type I18n
161 if (fld.getType() == I18n.class) {
162
163 // Ensure field is public
164 if ((fld.getModifiers() & Modifier.PUBLIC) != Modifier.PUBLIC) {
165 throw new SystemFailureException(CommonI18n.i18nFieldNotPublic.text(fld.getName(), i18nClass));
166 }
167
168 // Ensure field is static
169 if ((fld.getModifiers() & Modifier.STATIC) != Modifier.STATIC) {
170 throw new SystemFailureException(CommonI18n.i18nFieldNotStatic.text(fld.getName(), i18nClass));
171 }
172
173 // Ensure field is not final
174 if ((fld.getModifiers() & Modifier.FINAL) == Modifier.FINAL) {
175 throw new SystemFailureException(CommonI18n.i18nFieldFinal.text(fld.getName(), i18nClass));
176 }
177
178 // Ensure we can access field even if it's in a private class
179 ClassUtil.makeAccessible(fld);
180
181 // Initialize field. Do this every time the class is initialized (or re-initialized)
182 fld.set(null, new I18n(fld.getName(), i18nClass));
183 }
184 }
185
186 // Remove all entries for the supplied i18n class to indicate it has not been localized.
187 for (Entry<Locale, Map<Class<?>, Set<String>>> entry : LOCALE_TO_CLASS_TO_PROBLEMS_MAP.entrySet()) {
188 entry.getValue().remove(i18nClass);
189 }
190 } catch (IllegalAccessException err) {
191 // If this happens, it will happen with the first field visited in the above loop
192 throw new IllegalArgumentException(CommonI18n.i18nClassNotPublic.text(i18nClass));
193 }
194 }
195 }
196
197 /**
198 * Synchronized on the supplied internalization class.
199 *
200 * @param i18nClass The internalization class being localized
201 * @param locale The locale to which the supplied internationalization class should be localized.
202 */
203 private static void localize( final Class<?> i18nClass,
204 final Locale locale ) {
205 assert i18nClass != null;
206 assert locale != null;
207 // Create a class-to-problem map for this locale if one doesn't exist, else get the existing one.
208 Map<Class<?>, Set<String>> classToProblemsMap = new ConcurrentHashMap<Class<?>, Set<String>>();
209 Map<Class<?>, Set<String>> existingClassToProblemsMap = LOCALE_TO_CLASS_TO_PROBLEMS_MAP.putIfAbsent(locale,
210 classToProblemsMap);
211 if (existingClassToProblemsMap != null) {
212 classToProblemsMap = existingClassToProblemsMap;
213 }
214 // Check if already localized outside of synchronization block for 99% use-case
215 if (classToProblemsMap.get(i18nClass) != null) {
216 return;
217 }
218 synchronized (i18nClass) {
219 // Return if the supplied i18n class has already been localized to the supplied locale, despite the check outside of
220 // the synchronization block (1% use-case), else create a class-to-problems map for the class.
221 Set<String> problems = classToProblemsMap.get(i18nClass);
222 if (problems == null) {
223 problems = new CopyOnWriteArraySet<String>();
224 classToProblemsMap.put(i18nClass, problems);
225 } else {
226 return;
227 }
228 // Get the URL to the localization properties file ...
229 final LocalizationRepository repos = getLocalizationRepository();
230 final String localizationBaseName = i18nClass.getName();
231 URL url = repos.getLocalizationBundle(localizationBaseName, locale);
232 if (url == null) {
233 // Nothing was found, so try the default locale
234 Locale defaultLocale = Locale.getDefault();
235 if (!defaultLocale.equals(locale)) {
236 url = repos.getLocalizationBundle(localizationBaseName, defaultLocale);
237 }
238 // Return if no applicable localization file could be found
239 if (url == null) {
240 problems.add(CommonI18n.i18nLocalizationFileNotFound.text(localizationBaseName));
241 return;
242 }
243 }
244 // Initialize i18n map
245 final URL finalUrl = url;
246 final Set<String> finalProblems = problems;
247 Properties props = new Properties() {
248
249 /**
250 */
251 private static final long serialVersionUID = 3920620306881072843L;
252
253 @Override
254 public synchronized Object put( Object key,
255 Object value ) {
256 String id = (String)key;
257 String text = (String)value;
258
259 try {
260 Field fld = i18nClass.getDeclaredField(id);
261 if (fld.getType() != I18n.class) {
262 // Invalid field type
263 finalProblems.add(CommonI18n.i18nFieldInvalidType.text(id, finalUrl, getClass().getName()));
264 } else {
265 I18n i18n = (I18n)fld.get(null);
266 if (i18n.localeToTextMap.putIfAbsent(locale, text) != null) {
267 // Duplicate id encountered
268 String prevProblem = i18n.localeToProblemMap.putIfAbsent(locale,
269 CommonI18n.i18nPropertyDuplicate.text(id,
270 finalUrl));
271 assert prevProblem == null;
272 }
273 }
274 } catch (NoSuchFieldException err) {
275 // No corresponding field exists
276 finalProblems.add(CommonI18n.i18nPropertyUnused.text(id, finalUrl));
277 } catch (IllegalAccessException notPossible) {
278 // Would have already occurred in initialize method, but allowing for the impossible...
279 finalProblems.add(notPossible.getMessage());
280 }
281
282 return null;
283 }
284 };
285
286 try {
287 InputStream propStream = url.openStream();
288 try {
289 props.load(propStream);
290 // Check for uninitialized fields
291 for (Field fld : i18nClass.getDeclaredFields()) {
292 if (fld.getType() == I18n.class) {
293 try {
294 I18n i18n = (I18n)fld.get(null);
295 if (i18n.localeToTextMap.get(locale) == null) {
296 i18n.localeToProblemMap.put(locale, CommonI18n.i18nPropertyMissing.text(fld.getName(), url));
297 }
298 } catch (IllegalAccessException notPossible) {
299 // Would have already occurred in initialize method, but allowing for the impossible...
300 finalProblems.add(notPossible.getMessage());
301 }
302 }
303 }
304 } finally {
305 propStream.close();
306 }
307 } catch (IOException err) {
308 finalProblems.add(err.getMessage());
309 }
310 }
311 }
312
313 private final String id;
314 private final Class<?> i18nClass;
315 final ConcurrentHashMap<Locale, String> localeToTextMap = new ConcurrentHashMap<Locale, String>();
316 final ConcurrentHashMap<Locale, String> localeToProblemMap = new ConcurrentHashMap<Locale, String>();
317
318 private I18n( String id,
319 Class<?> i18nClass ) {
320 this.id = id;
321 this.i18nClass = i18nClass;
322 }
323
324 /**
325 * @return This internationalization object's ID, which will match both the name of the relevant static field in the
326 * internationalization class and the relevant property name in the associated localization files.
327 */
328 public String id() {
329 return id;
330 }
331
332 /**
333 * @return <code>true</code> if a problem was encountered while localizing this internationalization object to the default
334 * locale.
335 */
336 public boolean hasProblem() {
337 return (problem() != null);
338 }
339
340 /**
341 * @param locale The locale for which to check whether a problem was encountered.
342 * @return <code>true</code> if a problem was encountered while localizing this internationalization object to the supplied
343 * locale.
344 */
345 public boolean hasProblem( Locale locale ) {
346 return (problem(locale) != null);
347 }
348
349 /**
350 * @return The problem encountered while localizing this internationalization object to the default locale, or
351 * <code>null</code> if none was encountered.
352 */
353 public String problem() {
354 return problem(null);
355 }
356
357 /**
358 * @param locale The locale for which to return the problem.
359 * @return The problem encountered while localizing this internationalization object to the supplied locale, or
360 * <code>null</code> if none was encountered.
361 */
362 public String problem( Locale locale ) {
363 if (locale == null) {
364 locale = Locale.getDefault();
365 }
366 localize(i18nClass, locale);
367 // Check for field/property error
368 String problem = localeToProblemMap.get(locale);
369 if (problem != null) {
370 return problem;
371 }
372 // Check if text exists
373 if (localeToTextMap.get(locale) != null) {
374 // If so, no problem exists
375 return null;
376 }
377 // If we get here, which will be at most once, there was at least one global localization error, so just return a message
378 // indicating to look them up.
379 problem = CommonI18n.i18nLocalizationProblems.text(i18nClass, locale);
380 localeToProblemMap.put(locale, problem);
381 return problem;
382 }
383
384 private String rawText( Locale locale ) {
385 assert locale != null;
386 localize(i18nClass, locale);
387 // Check if text exists
388 String text = localeToTextMap.get(locale);
389 if (text != null) {
390 return text;
391 }
392 // If not, there was a problem, so throw it within an exception so upstream callers can tell the difference between normal
393 // text and problem text.
394 throw new SystemFailureException(problem(locale));
395 }
396
397 /**
398 * Get the localized text for the {@link Locale#getDefault() current (default) locale}, replacing the parameters in the text
399 * with those supplied.
400 *
401 * @param arguments the arguments for the parameter replacement; may be <code>null</code> or empty
402 * @return the localized text
403 */
404 public String text( Object... arguments ) {
405 return text(null, arguments);
406 }
407
408 /**
409 * Get the localized text for the supplied locale, replacing the parameters in the text with those supplied.
410 *
411 * @param locale the locale, or <code>null</code> if the {@link Locale#getDefault() current (default) locale} should be used
412 * @param arguments the arguments for the parameter replacement; may be <code>null</code> or empty
413 * @return the localized text
414 */
415 public String text( Locale locale,
416 Object... arguments ) {
417 try {
418 String rawText = rawText(locale == null ? Locale.getDefault() : locale);
419 return StringUtil.createString(rawText, arguments);
420 } catch (IllegalArgumentException err) {
421 throw new IllegalArgumentException(CommonI18n.i18nRequiredToSuppliedParameterMismatch.text(id,
422 i18nClass,
423 err.getMessage()));
424 } catch (SystemFailureException err) {
425 return '<' + err.getMessage() + '>';
426 }
427 }
428
429 /**
430 * {@inheritDoc}
431 */
432 @Override
433 public String toString() {
434 try {
435 return rawText(Locale.getDefault());
436 } catch (SystemFailureException err) {
437 return '<' + err.getMessage() + '>';
438 }
439 }
440 }