/* * ModeShape (http://www.modeshape.org) * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.modeshape.common.i18n; import java.io.IOException; import java.io.InputStream; import java.lang.reflect.Field; import java.lang.reflect.Modifier; import java.net.URL; import java.util.Collections; import java.util.HashSet; import java.util.Locale; import java.util.Map; import java.util.Map.Entry; import java.util.Properties; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.CopyOnWriteArraySet; import org.modeshape.common.CommonI18n; import org.modeshape.common.SystemFailureException; import org.modeshape.common.annotation.ThreadSafe; import org.modeshape.common.logging.Logger; import org.modeshape.common.util.CheckArg; import org.modeshape.common.util.ClassUtil; import org.modeshape.common.util.StringUtil; /** * An internalized string object, which manages the initialization of internationalization (i18n) files, substitution of values * within i18n message placeholders, and dynamically reading properties from i18n property files. */ @ThreadSafe public final class I18n implements I18nResource { /** * The first level of this map indicates whether an i18n class has been localized to a particular locale. The second level * contains any problems encountered during localization. * * Make sure this is always the first member in the class because it must be initialized *before* the Logger (see below). * Otherwise it's possible to trigger a NPE because of nested initializers. */ static final ConcurrentMap<Locale, Map<Class<?>, Set<String>>> LOCALE_TO_CLASS_TO_PROBLEMS_MAP = new ConcurrentHashMap<Locale, Map<Class<?>, Set<String>>>(); private static final Logger LOGGER = Logger.getLogger(I18n.class); /** * Note, calling this method will <em>not</em> trigger localization of the supplied internationalization class. * * @param i18nClass The internalization class for which localization problem locales should be returned. * @return The locales for which localization problems were encountered while localizing the supplied internationalization * class; never <code>null</code>. */ public static Set<Locale> getLocalizationProblemLocales( Class<?> i18nClass ) { CheckArg.isNotNull(i18nClass, "i18nClass"); Set<Locale> locales = new HashSet<Locale>(LOCALE_TO_CLASS_TO_PROBLEMS_MAP.size()); for (Entry<Locale, Map<Class<?>, Set<String>>> localeEntry : LOCALE_TO_CLASS_TO_PROBLEMS_MAP.entrySet()) { for (Entry<Class<?>, Set<String>> classEntry : localeEntry.getValue().entrySet()) { if (!classEntry.getValue().isEmpty()) { locales.add(localeEntry.getKey()); break; } } } return locales; } /** * Note, calling this method will <em>not</em> trigger localization of the supplied internationalization class. * * @param i18nClass The internalization class for which localization problems should be returned. * @return The localization problems encountered while localizing the supplied internationalization class to the default * locale; never <code>null</code>. */ public static Set<String> getLocalizationProblems( Class<?> i18nClass ) { return getLocalizationProblems(i18nClass, Locale.getDefault()); } /** * Note, calling this method will <em>not</em> trigger localization of the supplied internationalization class. * * @param i18nClass The internalization class for which localization problems should be returned. * @param locale The locale for which localization problems should be returned. If <code>null</code>, the default locale will * be used. * @return The localization problems encountered while localizing the supplied internationalization class to the supplied * locale; never <code>null</code>. */ public static Set<String> getLocalizationProblems( Class<?> i18nClass, Locale locale ) { CheckArg.isNotNull(i18nClass, "i18nClass"); Map<Class<?>, Set<String>> classToProblemsMap = LOCALE_TO_CLASS_TO_PROBLEMS_MAP.get(locale == null ? Locale.getDefault() : locale); if (classToProblemsMap == null) { return Collections.emptySet(); } Set<String> problems = classToProblemsMap.get(i18nClass); if (problems == null) { return Collections.emptySet(); } return problems; } /** * Initializes the internationalization fields declared on the supplied class. Internationalization fields must be public, * static, not final, and of type <code>I18n</code>. The supplied class must not be an interface (of course), but has no * restrictions as to what class it may extend or what interfaces it must implement. * * @param i18nClass A class declaring one or more public, static, non-final fields of type <code>I18n</code>. */ public static void initialize( Class<?> i18nClass ) { validateI18nClass(i18nClass); synchronized (i18nClass) { // Find all public static non-final String fields in the supplied class and instantiate an I18n object for each. try { for (Field fld : i18nClass.getDeclaredFields()) { // Ensure field is of type I18n if (fld.getType() == I18n.class) { initializeI18nField(fld); } } cleanupPreviousProblems(i18nClass); } catch (IllegalAccessException err) { // If this happens, it will happen with the first field visited in the above loop throw new IllegalArgumentException(CommonI18n.i18nClassNotPublic.text(i18nClass)); } } } private static void cleanupPreviousProblems( Class<?> i18nClass ) { // Remove all entries for the supplied i18n class to indicate it has not been localized. for (Entry<Locale, Map<Class<?>, Set<String>>> entry : LOCALE_TO_CLASS_TO_PROBLEMS_MAP.entrySet()) { entry.getValue().remove(i18nClass); } } private static void initializeI18nField( Field fld ) throws IllegalAccessException { // Ensure field is public if ((fld.getModifiers() & Modifier.PUBLIC) != Modifier.PUBLIC) { throw new SystemFailureException(CommonI18n.i18nFieldNotPublic.text(fld.getName(), fld.getDeclaringClass())); } // Ensure field is static if ((fld.getModifiers() & Modifier.STATIC) != Modifier.STATIC) { throw new SystemFailureException(CommonI18n.i18nFieldNotStatic.text(fld.getName(), fld.getDeclaringClass())); } // Ensure field is not final if ((fld.getModifiers() & Modifier.FINAL) == Modifier.FINAL) { throw new SystemFailureException(CommonI18n.i18nFieldFinal.text(fld.getName(), fld.getDeclaringClass())); } // Ensure we can access field even if it's in a private class ClassUtil.makeAccessible(fld); // Initialize field. Do this every time the class is initialized (or re-initialized) fld.set(null, new I18n(fld.getName(), fld.getDeclaringClass())); } private static void validateI18nClass( Class<?> i18nClass ) { CheckArg.isNotNull(i18nClass, "i18nClass"); if (i18nClass.isInterface()) { throw new IllegalArgumentException(CommonI18n.i18nClassInterface.text(i18nClass.getName())); } } /** * Synchronized on the supplied internalization class. * * @param i18nClass The internalization class being localized * @param locale The locale to which the supplied internationalization class should be localized. */ private static void localize( final Class<?> i18nClass, final Locale locale ) { assert i18nClass != null; assert locale != null; // Create a class-to-problem map for this locale if one doesn't exist, else get the existing one. Map<Class<?>, Set<String>> classToProblemsMap = new ConcurrentHashMap<Class<?>, Set<String>>(); Map<Class<?>, Set<String>> existingClassToProblemsMap = LOCALE_TO_CLASS_TO_PROBLEMS_MAP.putIfAbsent(locale, classToProblemsMap); if (existingClassToProblemsMap != null) { classToProblemsMap = existingClassToProblemsMap; } // Check if already localized outside of synchronization block for 99% use-case if (classToProblemsMap.get(i18nClass) != null) { return; } synchronized (i18nClass) { // Return if the supplied i18n class has already been localized to the supplied locale, despite the check outside of // the synchronization block (1% use-case), else create a class-to-problems map for the class. Set<String> problems = classToProblemsMap.get(i18nClass); if (problems == null) { problems = new CopyOnWriteArraySet<String>(); classToProblemsMap.put(i18nClass, problems); } else { return; } // Get the URL to the localization properties file ... final String localizationBaseName = i18nClass.getName(); URL bundleUrl = ClasspathLocalizationRepository.getLocalizationBundle(i18nClass.getClassLoader(), localizationBaseName, locale); if (bundleUrl == null && i18nClass == CommonI18n.class) { throw new SystemFailureException("CommonI18n.properties file not found in classpath !"); } if (bundleUrl == null) { LOGGER.warn(CommonI18n.i18nBundleNotFoundInClasspath, ClasspathLocalizationRepository.getPathsToSearchForBundle(localizationBaseName, locale)); // Nothing was found, so try the default locale Locale defaultLocale = Locale.getDefault(); if (!defaultLocale.equals(locale)) { bundleUrl = ClasspathLocalizationRepository.getLocalizationBundle(i18nClass.getClassLoader(), localizationBaseName, defaultLocale); } // Return if no applicable localization file could be found if (bundleUrl == null) { LOGGER.error(CommonI18n.i18nBundleNotFoundInClasspath, ClasspathLocalizationRepository.getPathsToSearchForBundle(localizationBaseName, defaultLocale)); LOGGER.error(CommonI18n.i18nLocalizationFileNotFound, localizationBaseName); problems.add(CommonI18n.i18nLocalizationFileNotFound.text(localizationBaseName)); return; } } // Initialize i18n map Properties props = prepareBundleLoading(i18nClass, locale, bundleUrl, problems); try { InputStream propStream = bundleUrl.openStream(); try { props.load(propStream); // Check for uninitialized fields for (Field fld : i18nClass.getDeclaredFields()) { if (fld.getType() == I18n.class) { try { I18n i18n = (I18n)fld.get(null); if (i18n.localeToTextMap.get(locale) == null) { i18n.localeToProblemMap.put(locale, CommonI18n.i18nPropertyMissing.text(fld.getName(), bundleUrl)); } } catch (IllegalAccessException notPossible) { // Would have already occurred in initialize method, but allowing for the impossible... problems.add(notPossible.getMessage()); } } } } finally { propStream.close(); } } catch (IOException err) { problems.add(err.getMessage()); } } } private static Properties prepareBundleLoading( final Class<?> i18nClass, final Locale locale, final URL bundleUrl, final Set<String> problems ) { return new Properties() { private static final long serialVersionUID = 3920620306881072843L; @Override public synchronized Object put( Object key, Object value ) { String id = (String)key; String text = (String)value; try { Field fld = i18nClass.getDeclaredField(id); if (fld.getType() != I18n.class) { // Invalid field type problems.add(CommonI18n.i18nFieldInvalidType.text(id, bundleUrl, getClass().getName())); } else { I18n i18n = (I18n)fld.get(null); if (i18n.localeToTextMap.putIfAbsent(locale, text) != null) { // Duplicate id encountered String prevProblem = i18n.localeToProblemMap.putIfAbsent(locale, CommonI18n.i18nPropertyDuplicate.text(id, bundleUrl)); assert prevProblem == null; } } } catch (NoSuchFieldException err) { // No corresponding field exists problems.add(CommonI18n.i18nPropertyUnused.text(id, bundleUrl)); } catch (IllegalAccessException notPossible) { // Would have already occurred in initialize method, but allowing for the impossible... problems.add(notPossible.getMessage()); } return null; } }; } private final String id; private final Class<?> i18nClass; final ConcurrentHashMap<Locale, String> localeToTextMap = new ConcurrentHashMap<Locale, String>(); final ConcurrentHashMap<Locale, String> localeToProblemMap = new ConcurrentHashMap<Locale, String>(); private I18n( String id, Class<?> i18nClass ) { this.id = id; this.i18nClass = i18nClass; } /** * @return This internationalization object's ID, which will match both the name of the relevant static field in the * internationalization class and the relevant property name in the associated localization files. */ public String id() { return id; } /** * @return <code>true</code> if a problem was encountered while localizing this internationalization object to the default * locale. */ public boolean hasProblem() { return (problem() != null); } /** * @param locale The locale for which to check whether a problem was encountered. * @return <code>true</code> if a problem was encountered while localizing this internationalization object to the supplied * locale. */ public boolean hasProblem( Locale locale ) { return (problem(locale) != null); } /** * @return The problem encountered while localizing this internationalization object to the default locale, or * <code>null</code> if none was encountered. */ public String problem() { return problem(null); } /** * @param locale The locale for which to return the problem. * @return The problem encountered while localizing this internationalization object to the supplied locale, or * <code>null</code> if none was encountered. */ public String problem( Locale locale ) { if (locale == null) { locale = Locale.getDefault(); } localize(i18nClass, locale); // Check for field/property error String problem = localeToProblemMap.get(locale); if (problem != null) { return problem; } // Check if text exists if (localeToTextMap.get(locale) != null) { // If so, no problem exists return null; } // If we get here, which will be at most once, there was at least one global localization error, so just return a message // indicating to look them up. problem = CommonI18n.i18nLocalizationProblems.text(i18nClass, locale); localeToProblemMap.put(locale, problem); return problem; } private String rawText( Locale locale ) { assert locale != null; localize(i18nClass, locale); // Check if text exists String text = localeToTextMap.get(locale); if (text != null) { return text; } // If not, there was a problem, so throw it within an exception so upstream callers can tell the difference between normal // text and problem text. throw new SystemFailureException(problem(locale)); } /** * Get the localized text for the {@link Locale#getDefault() current (default) locale}, replacing the parameters in the text * with those supplied. * * @param arguments the arguments for the parameter replacement; may be <code>null</code> or empty * @return the localized text */ @Override public String text( Object... arguments ) { return text(null, arguments); } /** * Get the localized text for the supplied locale, replacing the parameters in the text with those supplied. * * @param locale the locale, or <code>null</code> if the {@link Locale#getDefault() current (default) locale} should be used * @param arguments the arguments for the parameter replacement; may be <code>null</code> or empty * @return the localized text */ @Override public String text( Locale locale, Object... arguments ) { try { String rawText = rawText(locale == null ? Locale.getDefault() : locale); return StringUtil.createString(rawText, arguments); } catch (IllegalArgumentException err) { throw new IllegalArgumentException(CommonI18n.i18nRequiredToSuppliedParameterMismatch.text(id, i18nClass, err.getMessage())); } catch (SystemFailureException err) { return '<' + err.getMessage() + '>'; } } @Override public String toString() { try { return rawText(Locale.getDefault()); } catch (SystemFailureException err) { return '<' + err.getMessage() + '>'; } } }