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    }