/*
* Chrysalix
* See the COPYRIGHT.txt file distributed with this work for information
* regarding copyright ownership. Some portions may be licensed
* to Red Hat, Inc. under one or more contributor license agreements.
* See the AUTHORS.txt file in the distribution for a full listing of
* individual contributors.
*
* Chrysalix is free software. Unless otherwise indicated, all code in Chrysalix
* is licensed to you under the terms of the GNU Lesser General Public License as
* published by the Free Software Foundation; either version 2.1 of
* the License, or (at your option) any later version.
*
* Chrysalix is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this software; if not, write to the Free
* Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
* 02110-1301 USA, or see the FSF site: http://www.fsf.org.
*/
package org.chrysalix.common;
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.IllegalFormatException;
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.chrysalix.common.i18n.ClasspathLocalizationRepository;
/**
* 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.
*/
public final class I18n {
private static final Logger LOGGER = Logger.logger( CommonI18n.class );
private static String i18nBundleNotFoundInClasspath =
"None of the bundle variants for %s in locale \"%s\" can be located in the classpath: %s";
private static String i18nUsingUsLocale = "Using default U.S. localization for %s";
/**
* 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.
*/
static final ConcurrentMap< Locale, Map< Class< ? >, Set< String >>> LOCALE_TO_CLASS_TO_PROBLEMS_MAP =
new ConcurrentHashMap<>();
private static final ConcurrentMap< String, I18n > I18NS_BY_TEXT = new ConcurrentHashMap<>();
/**
* 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 > localizationProblemLocales( final Class< ? > i18nClass ) {
CheckArg.notNull( i18nClass, "i18nClass" );
final Set< Locale > locales = new HashSet<>( LOCALE_TO_CLASS_TO_PROBLEMS_MAP.size() );
for ( final Entry< Locale, Map< Class< ? >, Set< String >>> localeEntry : LOCALE_TO_CLASS_TO_PROBLEMS_MAP.entrySet() ) {
for ( final 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 > localizationProblems( final Class< ? > i18nClass ) {
return localizationProblems( 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 > localizationProblems( final Class< ? > i18nClass,
final Locale locale ) {
CheckArg.notNull( i18nClass, "i18nClass" );
final Map< Class< ? >, Set< String >> classToProblemsMap =
LOCALE_TO_CLASS_TO_PROBLEMS_MAP.get( locale == null ? Locale.getDefault() : locale );
if ( classToProblemsMap == null ) {
return Collections.emptySet();
}
final Set< String > problems = classToProblemsMap.get( i18nClass );
if ( problems == null ) {
return Collections.emptySet();
}
return problems;
}
/**
* Synchronized on the supplied internationalization class.
*
* @param i18nClass
* The internationalization class being localized
* @param locale
* The locale to which the supplied internationalization class should be localized.
* @return the resulting locale, which will be the {@link Locale#US U.S. Locale} even if the supplied locale is different, but
* no bundle is found
*/
private static Locale 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<>();
final 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 locale;
}
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<>();
classToProblemsMap.put( i18nClass, problems );
} else {
return locale;
}
// Get the URL to the localization properties file ...
final String localizationBaseName = i18nClass.getName();
URL bundleUrl;
if ( Locale.US.equals( locale ) )
// If US English locale, then use values already defined in constants
bundleUrl = null;
else {
bundleUrl =
ClasspathLocalizationRepository.getLocalizationBundle( i18nClass.getClassLoader(), localizationBaseName, locale );
if ( bundleUrl == null ) {
LOGGER.warn( i18nBundleNotFoundInClasspath, i18nClass, locale,
ClasspathLocalizationRepository.getPathsToSearchForBundle( localizationBaseName, locale ) );
// Nothing was found, so try the default locale
final Locale defaultLocale = Locale.getDefault();
if ( defaultLocale == Locale.US ) {
LOGGER.warn( i18nUsingUsLocale, i18nClass );
return Locale.US;
}
if ( !defaultLocale.equals( locale ) )
bundleUrl =
ClasspathLocalizationRepository.getLocalizationBundle( i18nClass.getClassLoader(), localizationBaseName, defaultLocale );
// Return if no applicable localization file could be found
if ( bundleUrl == null ) {
LOGGER.warn( i18nBundleNotFoundInClasspath, i18nClass, defaultLocale,
ClasspathLocalizationRepository.getPathsToSearchForBundle( localizationBaseName,
defaultLocale ) );
LOGGER.warn( i18nUsingUsLocale, i18nClass );
problems.add( CommonI18n.localize( i18nUsingUsLocale, i18nClass ) );
return Locale.US;
}
}
// Initialize i18n map
final Properties props = prepareBundleLoading( i18nClass, locale, bundleUrl, problems );
try {
try ( InputStream propStream = bundleUrl.openStream() ) {
props.load( propStream );
// Check for uninitialized fields
for ( final Field fld : i18nClass.getDeclaredFields() )
if ( fld.getType() == I18n.class )
try {
if ( ( ( I18n ) fld.get( null ) ).localeToTextMap.get( locale ) == null ) return Locale.US;
} catch ( final IllegalAccessException notPossible ) {
// Would have already occurred in initialize method, but allowing for the impossible...
problems.add( notPossible.getMessage() );
}
}
} catch ( final IOException err ) {
problems.add( err.getMessage() );
}
}
}
return locale;
}
/**
* @param i18nClass
* the internationalization class used to localize the supplied text.
* @param locale
* the locale, or <code>null</code> if the {@link Locale#getDefault() current (default) locale} should be used
* @param text
* the text to be localized
* @param arguments
* optional arguments applied to the supplied text as described in {@link String#format(String, Object...)}
* @return the localized form of the supplied text
*/
public static String localize( final Class< ? > i18nClass,
final Locale locale,
final String text,
final Object... arguments ) {
I18n i18n = I18NS_BY_TEXT.get( text );
if ( i18n == null ) {
i18n = new I18n( text, i18nClass );
I18NS_BY_TEXT.put( text, i18n );
}
return i18n.text( locale, arguments );
}
/**
* @param i18nClass
* the internationalization class used to localize the supplied text.
* @param text
* the text to be localized
* @param arguments
* optional arguments applied to the supplied text as described in {@link String#format(String, Object...)}
* @return the localized form of the supplied text
*/
public static String localize( final Class< ? > i18nClass,
final String text,
final Object... arguments ) {
return localize( i18nClass, null, text, arguments );
}
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( final Object key,
final Object value ) {
try {
( ( I18n ) i18nClass.getDeclaredField( key.toString() ).get( null ) ).localeToTextMap.putIfAbsent( locale,
value.toString() );
} catch ( final IllegalAccessException | NoSuchFieldException | SecurityException 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<>();
final ConcurrentHashMap< Locale, String > localeToProblemMap = new ConcurrentHashMap<>();
/**
* @param text
* the text to be localized
* @deprecated Use {@link #localize(Class, String, Object...)}
*/
@Deprecated
public I18n( final String text ) {
CheckArg.notEmpty( text, "text" );
String id = null;
Class< ? > i18nClass = null;
final StackTraceElement elem = Thread.currentThread().getStackTrace()[ 2 ];
try {
i18nClass = Class.forName( elem.getClassName() );
for ( final Field field : i18nClass.getDeclaredFields() ) {
ClassUtil.makeAccessible( field );
if ( Modifier.isStatic( field.getModifiers() ) ) {
final Object val = field.get( null );
if ( val == null ) {
id = field.getName();
break;
}
}
}
} catch ( final ClassNotFoundException | IllegalArgumentException | IllegalAccessException e ) {
throw new RuntimeException( e );
}
if ( id == null )
throw new IllegalStateException( CommonI18n.localize( "Internationalization object is not assigned to a static member variable\n\tat %s",
elem ) );
this.id = id;
this.i18nClass = i18nClass;
localeToTextMap.put( Locale.US, text );
}
private I18n( final String text,
final Class< ? > i18nClass ) {
CheckArg.notEmpty( text, "text" );
this.id = text;
this.i18nClass = i18nClass;
localeToTextMap.put( Locale.US, text );
}
/**
* @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( final 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();
}
locale = 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.localize( locale,
"Problems were encountered while localizing internationalization %s to locale \"%s\"",
i18nClass, locale );
localeToProblemMap.put( locale, problem );
return problem;
}
private String rawText( Locale locale ) throws CommonException {
assert locale != null;
locale = localize( i18nClass, locale );
// Check if text exists
final 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 CommonException( problem( locale ) );
}
/**
* 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
*/
public String text( final Locale locale,
final Object... arguments ) {
try {
final String rawText = rawText( locale == null ? Locale.getDefault() : locale );
return String.format( rawText, arguments );
} catch ( final IllegalFormatException err ) {
throw new IllegalArgumentException( CommonI18n.localize( "Internationalization field \"%s\" in %s: %s",
id, i18nClass, err.getMessage() ) );
} catch ( final CommonException err ) {
return '<' + err.getMessage() + '>';
}
}
/**
* 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
*/
public String text( final Object... arguments ) {
return text( null, arguments );
}
/**
* {@inheritDoc}
*
* @see java.lang.Object#toString()
*/
@Override
public String toString() {
try {
return rawText( Locale.getDefault() );
} catch ( final CommonException err ) {
return '<' + err.getMessage() + '>';
}
}
}