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