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    }