I18nService.java

/*
 * Copyright (c) 2002-2022, City of Paris
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions
 * are met:
 *
 *  1. Redistributions of source code must retain the above copyright notice
 *     and the following disclaimer.
 *
 *  2. Redistributions in binary form must reproduce the above copyright notice
 *     and the following disclaimer in the documentation and/or other materials
 *     provided with the distribution.
 *
 *  3. Neither the name of 'Mairie de Paris' nor 'Lutece' nor the names of its
 *     contributors may be used to endorse or promote products derived from
 *     this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
 * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
 * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
 * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
 * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
 * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
 * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
 * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
 * POSSIBILITY OF SUCH DAMAGE.
 *
 * License 1.0
 */
package fr.paris.lutece.portal.service.i18n;

import fr.paris.lutece.portal.service.util.AppException;
import fr.paris.lutece.portal.service.util.AppLogService;
import fr.paris.lutece.portal.service.util.AppPathService;
import fr.paris.lutece.portal.service.util.AppPropertiesService;
import fr.paris.lutece.util.ReferenceList;

import java.io.File;

import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLClassLoader;

import java.text.DateFormat;
import java.text.MessageFormat;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.MissingResourceException;
import java.util.ResourceBundle;
import java.util.StringTokenizer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * This class provides services for internationalization (i18n)
 * 
 * @since v1.4.1
 */
public final class I18nService
{
    private static final String FORMAT_PACKAGE_PORTAL_RESOURCES_LOCATION = "fr.paris.lutece.portal.resources.{0}_messages";
    private static final String FORMAT_PACKAGE_PLUGIN_RESOURCES_LOCATION = "fr.paris.lutece.plugins.{0}.resources.{0}_messages";
    private static final String FORMAT_PACKAGE_MODULE_RESOURCES_LOCATION = "fr.paris.lutece.plugins.{0}.modules.{1}.resources.{1}_messages";
    private static final Pattern PATTERN_LOCALIZED_KEY = Pattern.compile( "#i18n\\{(.*?)\\}" );
    private static final String PROPERTY_AVAILABLES_LOCALES = "lutece.i18n.availableLocales";
    private static final Locale LOCALE_DEFAULT = new Locale( "", "", "" );
    private static final String PROPERTY_DEFAULT_LOCALE = "lutece.i18n.defaultLocale";
    private static final String PROPERTY_FORMAT_DATE_SHORT_LIST = "lutece.format.date.short";
    private static Map<String, String> _pluginBundleNames = Collections.synchronizedMap( new HashMap<String, String>( ) );
    private static Map<String, String> _moduleBundleNames = Collections.synchronizedMap( new HashMap<String, String>( ) );
    private static Map<String, String> _portalBundleNames = Collections.synchronizedMap( new HashMap<String, String>( ) );
    private static final String PROPERTY_PATH_OVERRIDE = "path.i18n.override";
    private static final ClassLoader _overrideLoader;
    private static final Map<String, ResourceBundle> _resourceBundleCache = Collections.synchronizedMap( new HashMap<String, ResourceBundle>( ) );

    static
    {
        File overridePath = null;

        try
        {
            overridePath = new File( AppPathService.getPath( PROPERTY_PATH_OVERRIDE ) );
        }
        catch( AppException e )
        {
            // the key is unknown. Message override will be deactivated
            AppLogService.error( "property {} is undefined. Message overriding will be disabled.", PROPERTY_PATH_OVERRIDE );
        }

        URL [ ] overrideURL = null;

        if ( overridePath != null )
        {
            try
            {
                overrideURL = new URL [ ] {
                        overridePath.toURI( ).toURL( )
                };
            }
            catch( MalformedURLException e )
            {
                AppLogService.error( "Error initializing message overriding: {}", e.getMessage( ), e );
            }
        }

        if ( overrideURL != null )
        {
            _overrideLoader = new URLClassLoader( overrideURL, null );
        }
        else
        {
            _overrideLoader = null;
        }
    }

    /**
     * Private constructor
     */
    private I18nService( )
    {
    }

    /**
     * This method localize a string. It scans for localization keys and replace them by localized values.<br>
     * The localization key structure is : #{bundle.key}.<br>
     * bundle's values should be 'portal' or a plugin name.
     * 
     * @param strSource
     *            The string that contains localization keys
     * @param locale
     *            The locale
     * @return The localized string
     */
    public static String localize( String strSource, Locale locale )
    {
        String result = strSource;

        if ( strSource != null )
        {
            Matcher matcher = PATTERN_LOCALIZED_KEY.matcher( strSource );

            if ( matcher.find( ) )
            {
                StringBuffer sb = new StringBuffer( );

                do
                {
                    matcher.appendReplacement( sb, getLocalizedString( matcher.group( 1 ), locale ) );
                }
                while ( matcher.find( ) );

                matcher.appendTail( sb );
                result = sb.toString( );
            }
        }

        return result;
    }

    /**
     * Returns the string corresponding to a given key for a given locale <br>
     * <ul>
     * <li>Core key structure :<br>
     * <code>portal.[admin, features, insert, rbac, search, site, style, system, users, util].key</code></li>
     * <li>Plugin key structure :<br>
     * <code>[plugin].key </code></li>
     * <li>Module key structure :<br>
     * <code>module.[plugin].[module].key </code></li>
     * </ul>
     * 
     * @param strKey
     *            The key of the string
     * @param theLocale
     *            The locale
     * @return The string corresponding to the key
     */
    public static String getLocalizedString( String strKey, Locale theLocale )
    {
        Locale locale = theLocale;
        String strReturn = "";

        try
        {
            int nPos = strKey.indexOf( '.' );

            if ( nPos != -1 )
            {
                String strBundleKey = strKey.substring( 0, nPos );
                String strStringKey = strKey.substring( nPos + 1 );

                String strBundle;

                if ( !strBundleKey.equals( "portal" ) )
                {
                    if ( strBundleKey.equals( "module" ) )
                    {
                        // module resource
                        nPos = strStringKey.indexOf( '.' );

                        String strPlugin = strStringKey.substring( 0, nPos );
                        strStringKey = strStringKey.substring( nPos + 1 );
                        nPos = strStringKey.indexOf( '.' );

                        String strModule = strStringKey.substring( 0, nPos );
                        strStringKey = strStringKey.substring( nPos + 1 );

                        strBundle = getModuleBundleName( strPlugin, strModule );
                    }
                    else
                    {
                        // plugin resource
                        strBundle = getPluginBundleName( strBundleKey );
                    }
                }
                else
                {
                    nPos = strStringKey.indexOf( '.' );

                    String strElement = strStringKey.substring( 0, nPos );
                    strStringKey = strStringKey.substring( nPos + 1 );

                    strBundle = getPortalBundleName( strElement );
                }

                // if language is english use a special locale to force using default
                // bundle instead of the bundle of default locale.
                if ( locale.getLanguage( ).equals( Locale.ENGLISH.getLanguage( ) ) )
                {
                    locale = LOCALE_DEFAULT;
                }

                ResourceBundle rbLabels = getResourceBundle( locale, strBundle );
                strReturn = rbLabels.getString( strStringKey );
            }
        }
        catch( Exception e )
        {
            String strErrorMessage = "Error localizing key : '" + strKey + "' - " + e.getMessage( );

            if ( e.getCause( ) != null )
            {
                strErrorMessage += ( " - cause : " + e.getCause( ).getMessage( ) );
            }

            AppLogService.error( strErrorMessage );
        }

        return strReturn;
    }

    /**
     * Get resource bundle name for plugin
     * 
     * @param strBundleKey
     *            the plugin key
     * @return resource bundle name
     */
    private static String getPluginBundleName( String strBundleKey )
    {
        return _pluginBundleNames.computeIfAbsent( strBundleKey, s -> new MessageFormat( FORMAT_PACKAGE_PLUGIN_RESOURCES_LOCATION ).format( new String [ ] {
                s
        } ) );
    }

    /**
     * Get resource bundle name for module
     * 
     * @param strPlugin
     *            the plugin key
     * @param strModule
     *            the module key
     * @return resource bundle name
     */
    private static String getModuleBundleName( String strPlugin, String strModule )
    {
        String key = strPlugin + strModule;
        return _moduleBundleNames.computeIfAbsent( key, s -> new MessageFormat( FORMAT_PACKAGE_MODULE_RESOURCES_LOCATION ).format( new String [ ] {
                strPlugin, strModule
        } ) );
    }

    /**
     * Get resource bundle name for core element
     * 
     * @param strElement
     *            element name
     * @return resource bundle name
     */
    private static String getPortalBundleName( String strElement )
    {
        return _portalBundleNames.computeIfAbsent( strElement, s -> new MessageFormat( FORMAT_PACKAGE_PORTAL_RESOURCES_LOCATION ).format( new String [ ] {
                s
        } ) );
    }

    /**
     * Returns the string corresponding to a given key for a given locale that use a MessageFormat pattern with arguments.
     * 
     * @return The string corresponding to the key
     * @param arguments
     *            The arguments used as values by the formatter
     * @param strKey
     *            The key of the string that contains the pattern
     * @param locale
     *            The locale
     */
    public static String getLocalizedString( String strKey, Object [ ] arguments, Locale locale )
    {
        String strMessagePattern = getLocalizedString( strKey, locale );

        return MessageFormat.format( strMessagePattern, arguments );
    }

    /**
     * Format a date according to the given locale
     * 
     * @param date
     *            The date to format
     * @param locale
     *            The locale
     * @param nDateFormat
     *            A DateFormat constant corresponding to the expected format. (ie: DateFormat.FULL)
     * @return The formatted date
     */
    public static String getLocalizedDate( Date date, Locale locale, int nDateFormat )
    {
        DateFormat dateFormatter = DateFormat.getDateInstance( nDateFormat, locale );
        return dateFormatter.format( date );
    }

    /**
     * Format a date according to the given locale
     * 
     * @param date
     *            The date to format
     * @param locale
     *            The locale
     * @param nDateFormat
     *            A DateFormat constant corresponding to the expected format. (ie: DateFormat.FULL)
     * @param nTimeFormat
     *            A TimeFormat constant corresponding to the expected format. (ie: DateFormat.SHORT)
     * @return The formatted date
     */
    public static String getLocalizedDateTime( Date date, Locale locale, int nDateFormat, int nTimeFormat )
    {
        DateFormat dateFormatter = DateFormat.getDateTimeInstance( nDateFormat, nTimeFormat, locale );
        return dateFormatter.format( date );
    }

    /**
     * Returns supported locales for Lutece backoffice
     * 
     * @return A list of locales
     */
    public static List<Locale> getAdminAvailableLocales( )
    {
        String strAvailableLocales = AppPropertiesService.getProperty( PROPERTY_AVAILABLES_LOCALES );
        StringTokenizer strTokens = new StringTokenizer( strAvailableLocales, "," );
        List<Locale> list = new ArrayList<>( );

        while ( strTokens.hasMoreTokens( ) )
        {
            String strLanguage = strTokens.nextToken( );
            Locale locale = new Locale( strLanguage );
            list.add( locale );
        }

        return list;
    }

    /**
     * Get the default Locale specified in properties file
     * 
     * @return The default Locale
     */
    public static Locale getDefaultLocale( )
    {
        String strDefaultLocale = AppPropertiesService.getProperty( PROPERTY_DEFAULT_LOCALE );

        return new Locale( strDefaultLocale );
    }

    /**
     * Get the short date format specified by a locale
     * 
     * @param locale
     *            The locale
     * @return The localized short date pattern or null else
     */
    public static String getDateFormatShortPattern( Locale locale )
    {
        String strAvailableLocales = AppPropertiesService.getProperty( PROPERTY_FORMAT_DATE_SHORT_LIST );

        if ( ( locale != null ) && ( strAvailableLocales != null ) && !strAvailableLocales.equals( "" ) )
        {
            StringTokenizer strTokens = new StringTokenizer( strAvailableLocales, "," );
            String strToken = null;

            for ( Locale adminLocale : getAdminAvailableLocales( ) )
            {
                if ( strTokens.hasMoreTokens( ) )
                {
                    strToken = strTokens.nextToken( );
                }

                if ( adminLocale.getLanguage( ).equals( locale.getLanguage( ) ) )
                {
                    return strToken;
                }
            }
        }

        return null;
    }

    /**
     * Returns a ReferenceList of available locales
     * 
     * @param locale
     *            The locale to display available languages
     * @return A ReferenceList of available locales
     */
    public static ReferenceList getAdminLocales( Locale locale )
    {
        ReferenceList list = new ReferenceList( );

        for ( Locale l : getAdminAvailableLocales( ) )
        {
            list.addItem( l.getLanguage( ), l.getDisplayLanguage( l ) );
        }

        return list;
    }

    /**
     * Localize in place all items of a collection
     * 
     * @param <E>
     *            the type of the elements of the collection
     * 
     * @param collection
     *            The collection to localize
     * @param locale
     *            The locale
     * @return The localized collection passed as argument
     */
    public static <E extends Localizable> Collection<E> localizeCollection( Collection<E> collection, Locale locale )
    {
        for ( Localizable object : collection )
        {
            object.setLocale( locale );
        }

        return collection;
    }

    /**
     * Localize in place all items of a list
     * 
     * @param <E>
     *            the type of the elements of the list
     * 
     * @param list
     *            The list to localize
     * @param locale
     *            The locale
     * @return The localized collection passed as argument
     */
    public static <E extends Localizable> List<E> localizeCollection( List<E> list, Locale locale )
    {
        for ( Localizable object : list )
        {
            object.setLocale( locale );
        }

        return list;
    }

    /**
     * get the resource bundle, possibly with its override
     * 
     * @param locale
     *            the locale
     * @param strBundle
     *            the bundle name
     * @return the resource bundle
     */
    private static ResourceBundle getResourceBundle( Locale locale, String strBundle )
    {
        String key = strBundle + locale.toString( );
        return _resourceBundleCache.computeIfAbsent( key, k -> createResourceBundle( strBundle, locale ) );
    }

    private static ResourceBundle createResourceBundle( String strBundle, Locale locale )
    {
        ResourceBundle rbLabels = ResourceBundle.getBundle( strBundle, locale );

        if ( _overrideLoader != null )
        {
            ResourceBundle overrideBundle = null;

            try
            {
                overrideBundle = ResourceBundle.getBundle( strBundle, locale, _overrideLoader );
            }
            catch( MissingResourceException e )
            {
                // no override for this resource
                return rbLabels;
            }

            return new CombinedResourceBundle( overrideBundle, rbLabels );
        }
        return rbLabels;
    }

    /**
     * Reset the caches
     * 
     * @since 5.1
     */
    public static void resetCache( )
    {
        ResourceBundle.clearCache( );

        if ( _overrideLoader != null )
        {
            ResourceBundle.clearCache( _overrideLoader );
        }

        _resourceBundleCache.clear( );
    }
}