View Javadoc
1   /*
2    * Copyright (c) 2002-2025, City of Paris
3    * All rights reserved.
4    *
5    * Redistribution and use in source and binary forms, with or without
6    * modification, are permitted provided that the following conditions
7    * are met:
8    *
9    *  1. Redistributions of source code must retain the above copyright notice
10   *     and the following disclaimer.
11   *
12   *  2. Redistributions in binary form must reproduce the above copyright notice
13   *     and the following disclaimer in the documentation and/or other materials
14   *     provided with the distribution.
15   *
16   *  3. Neither the name of 'Mairie de Paris' nor 'Lutece' nor the names of its
17   *     contributors may be used to endorse or promote products derived from
18   *     this software without specific prior written permission.
19   *
20   * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21   * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22   * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
23   * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
24   * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
25   * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
26   * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
27   * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
28   * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
29   * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
30   * POSSIBILITY OF SUCH DAMAGE.
31   *
32   * License 1.0
33   */
34  package fr.paris.lutece.portal.service.i18n;
35  
36  import fr.paris.lutece.portal.service.util.AppException;
37  import fr.paris.lutece.portal.service.util.AppLogService;
38  import fr.paris.lutece.portal.service.util.AppPathService;
39  import fr.paris.lutece.portal.service.util.AppPropertiesService;
40  import fr.paris.lutece.util.ReferenceList;
41  
42  import java.io.File;
43  
44  import java.net.MalformedURLException;
45  import java.net.URL;
46  import java.net.URLClassLoader;
47  
48  import java.text.DateFormat;
49  import java.text.MessageFormat;
50  
51  import java.util.ArrayList;
52  import java.util.Collection;
53  import java.util.Collections;
54  import java.util.Date;
55  import java.util.Enumeration;
56  import java.util.HashMap;
57  import java.util.List;
58  import java.util.Locale;
59  import java.util.Map;
60  import java.util.MissingResourceException;
61  import java.util.ResourceBundle;
62  import java.util.StringTokenizer;
63  import java.util.regex.Matcher;
64  import java.util.regex.Pattern;
65  
66  /**
67   * This class provides services for internationalization (i18n)
68   * 
69   * @since v1.4.1
70   */
71  public final class I18nService
72  {
73      private static final String FORMAT_PACKAGE_PORTAL_RESOURCES_LOCATION = "fr.paris.lutece.portal.resources.{0}_messages";
74      private static final String FORMAT_PACKAGE_PLUGIN_RESOURCES_LOCATION = "fr.paris.lutece.plugins.{0}.resources.{0}_messages";
75      private static final String FORMAT_PACKAGE_MODULE_RESOURCES_LOCATION = "fr.paris.lutece.plugins.{0}.modules.{1}.resources.{1}_messages";
76      private static final Pattern PATTERN_LOCALIZED_KEY = Pattern.compile( "#i18n\\{(.*?)\\}" );
77      private static final String PROPERTY_AVAILABLES_LOCALES = "lutece.i18n.availableLocales";
78      private static final Locale LOCALE_DEFAULT = new Locale( "", "", "" );
79      private static final String PROPERTY_DEFAULT_LOCALE = "lutece.i18n.defaultLocale";
80      private static final String PROPERTY_FORMAT_DATE_SHORT_LIST = "lutece.format.date.short";
81      private static Map<String, String> _pluginBundleNames = Collections.synchronizedMap( new HashMap<String, String>( ) );
82      private static Map<String, String> _moduleBundleNames = Collections.synchronizedMap( new HashMap<String, String>( ) );
83      private static Map<String, String> _portalBundleNames = Collections.synchronizedMap( new HashMap<String, String>( ) );
84      private static final String PROPERTY_PATH_OVERRIDE = "path.i18n.override";
85      private static final ClassLoader _overrideLoader;
86      private static final Map<String, ResourceBundle> _resourceBundleCache = Collections.synchronizedMap( new HashMap<String, ResourceBundle>( ) );
87  
88      static
89      {
90          File overridePath = null;
91  
92          try
93          {
94              overridePath = new File( AppPathService.getPath( PROPERTY_PATH_OVERRIDE ) );
95          }
96          catch( AppException e )
97          {
98              // the key is unknown. Message override will be deactivated
99              AppLogService.error( "property {} is undefined. Message overriding will be disabled.", PROPERTY_PATH_OVERRIDE );
100         }
101 
102         URL [ ] overrideURL = null;
103 
104         if ( overridePath != null )
105         {
106             try
107             {
108                 overrideURL = new URL [ ] {
109                         overridePath.toURI( ).toURL( )
110                 };
111             }
112             catch( MalformedURLException e )
113             {
114                 AppLogService.error( "Error initializing message overriding: {}", e.getMessage( ), e );
115             }
116         }
117 
118         if ( overrideURL != null )
119         {
120             _overrideLoader = new URLClassLoader( overrideURL, null );
121         }
122         else
123         {
124             _overrideLoader = null;
125         }
126     }
127 
128     /**
129      * Private constructor
130      */
131     private I18nService( )
132     {
133     }
134 
135     /**
136      * This method localize a string. It scans for localization keys and replace them by localized values.<br>
137      * The localization key structure is : #{bundle.key}.<br>
138      * bundle's values should be 'portal' or a plugin name.
139      * 
140      * @param strSource
141      *            The string that contains localization keys
142      * @param locale
143      *            The locale
144      * @return The localized string
145      */
146     public static String localize( String strSource, Locale locale )
147     {
148         String result = strSource;
149 
150         if ( strSource != null )
151         {
152             Matcher matcher = PATTERN_LOCALIZED_KEY.matcher( strSource );
153 
154             if ( matcher.find( ) )
155             {
156                 StringBuffer sb = new StringBuffer( );
157 
158                 do
159                 {
160                     matcher.appendReplacement( sb, getLocalizedString( matcher.group( 1 ), locale ) );
161                 }
162                 while ( matcher.find( ) );
163 
164                 matcher.appendTail( sb );
165                 result = sb.toString( );
166             }
167         }
168 
169         return result;
170     }
171 
172     /**
173      * Returns the string corresponding to a given key for a given locale <br>
174      * <ul>
175      * <li>Core key structure :<br>
176      * <code>portal.[admin, features, insert, rbac, search, site, style, system, users, util].key</code></li>
177      * <li>Plugin key structure :<br>
178      * <code>[plugin].key </code></li>
179      * <li>Module key structure :<br>
180      * <code>module.[plugin].[module].key </code></li>
181      * </ul>
182      * 
183      * @param strKey
184      *            The key of the string
185      * @param theLocale
186      *            The locale
187      * @return The string corresponding to the key
188      */
189     public static String getLocalizedString( String strKey, Locale theLocale )
190     {
191         Locale locale = theLocale;
192         String strReturn = "";
193 
194         try
195         {
196             int nPos = strKey.indexOf( '.' );
197 
198             if ( nPos != -1 )
199             {
200                 String strBundleKey = strKey.substring( 0, nPos );
201                 String strStringKey = strKey.substring( nPos + 1 );
202 
203                 String strBundle;
204 
205                 if ( !strBundleKey.equals( "portal" ) )
206                 {
207                     if ( strBundleKey.equals( "module" ) )
208                     {
209                         // module resource
210                         nPos = strStringKey.indexOf( '.' );
211 
212                         String strPlugin = strStringKey.substring( 0, nPos );
213                         strStringKey = strStringKey.substring( nPos + 1 );
214                         nPos = strStringKey.indexOf( '.' );
215 
216                         String strModule = strStringKey.substring( 0, nPos );
217                         strStringKey = strStringKey.substring( nPos + 1 );
218 
219                         strBundle = getModuleBundleName( strPlugin, strModule );
220                     }
221                     else
222                     {
223                         // plugin resource
224                         strBundle = getPluginBundleName( strBundleKey );
225                     }
226                 }
227                 else
228                 {
229                     nPos = strStringKey.indexOf( '.' );
230 
231                     String strElement = strStringKey.substring( 0, nPos );
232                     strStringKey = strStringKey.substring( nPos + 1 );
233 
234                     strBundle = getPortalBundleName( strElement );
235                 }
236 
237                 // if language is english use a special locale to force using default
238                 // bundle instead of the bundle of default locale.
239                 if ( locale.getLanguage( ).equals( Locale.ENGLISH.getLanguage( ) ) )
240                 {
241                     locale = LOCALE_DEFAULT;
242                 }
243 
244                 ResourceBundle rbLabels = getResourceBundle( locale, strBundle );
245                 strReturn = rbLabels.getString( strStringKey );
246             }
247         }
248         catch( Exception e )
249         {
250             String strErrorMessage = "Error localizing key : '" + strKey + "' - " + e.getMessage( );
251 
252             if ( e.getCause( ) != null )
253             {
254                 strErrorMessage += ( " - cause : " + e.getCause( ).getMessage( ) );
255             }
256 
257             AppLogService.error( strErrorMessage );
258         }
259 
260         return strReturn;
261     }
262 
263     /**
264      * Get resource bundle name for plugin
265      * 
266      * @param strBundleKey
267      *            the plugin key
268      * @return resource bundle name
269      */
270     private static String getPluginBundleName( String strBundleKey )
271     {
272         return _pluginBundleNames.computeIfAbsent( strBundleKey, s -> new MessageFormat( FORMAT_PACKAGE_PLUGIN_RESOURCES_LOCATION ).format( new String [ ] {
273                 s
274         } ) );
275     }
276 
277     /**
278      * Get resource bundle name for module
279      * 
280      * @param strPlugin
281      *            the plugin key
282      * @param strModule
283      *            the module key
284      * @return resource bundle name
285      */
286     private static String getModuleBundleName( String strPlugin, String strModule )
287     {
288         String key = strPlugin + strModule;
289         return _moduleBundleNames.computeIfAbsent( key, s -> new MessageFormat( FORMAT_PACKAGE_MODULE_RESOURCES_LOCATION ).format( new String [ ] {
290                 strPlugin, strModule
291         } ) );
292     }
293 
294     /**
295      * Get resource bundle name for core element
296      * 
297      * @param strElement
298      *            element name
299      * @return resource bundle name
300      */
301     private static String getPortalBundleName( String strElement )
302     {
303         return _portalBundleNames.computeIfAbsent( strElement, s -> new MessageFormat( FORMAT_PACKAGE_PORTAL_RESOURCES_LOCATION ).format( new String [ ] {
304                 s
305         } ) );
306     }
307 
308     /**
309      * Returns the string corresponding to a given key for a given locale that use a MessageFormat pattern with arguments.
310      * 
311      * @return The string corresponding to the key
312      * @param arguments
313      *            The arguments used as values by the formatter
314      * @param strKey
315      *            The key of the string that contains the pattern
316      * @param locale
317      *            The locale
318      */
319     public static String getLocalizedString( String strKey, Object [ ] arguments, Locale locale )
320     {
321         String strMessagePattern = getLocalizedString( strKey, locale );
322 
323         return MessageFormat.format( strMessagePattern, arguments );
324     }
325 
326     /**
327      * Format a date according to the given locale
328      * 
329      * @param date
330      *            The date to format
331      * @param locale
332      *            The locale
333      * @param nDateFormat
334      *            A DateFormat constant corresponding to the expected format. (ie: DateFormat.FULL)
335      * @return The formatted date
336      */
337     public static String getLocalizedDate( Date date, Locale locale, int nDateFormat )
338     {
339         DateFormat dateFormatter = DateFormat.getDateInstance( nDateFormat, locale );
340         return dateFormatter.format( date );
341     }
342 
343     /**
344      * Format a date according to the given locale
345      * 
346      * @param date
347      *            The date to format
348      * @param locale
349      *            The locale
350      * @param nDateFormat
351      *            A DateFormat constant corresponding to the expected format. (ie: DateFormat.FULL)
352      * @param nTimeFormat
353      *            A TimeFormat constant corresponding to the expected format. (ie: DateFormat.SHORT)
354      * @return The formatted date
355      */
356     public static String getLocalizedDateTime( Date date, Locale locale, int nDateFormat, int nTimeFormat )
357     {
358         DateFormat dateFormatter = DateFormat.getDateTimeInstance( nDateFormat, nTimeFormat, locale );
359         return dateFormatter.format( date );
360     }
361 
362     /**
363      * Returns supported locales for Lutece backoffice
364      * 
365      * @return A list of locales
366      */
367     public static List<Locale> getAdminAvailableLocales( )
368     {
369         String strAvailableLocales = AppPropertiesService.getProperty( PROPERTY_AVAILABLES_LOCALES );
370         StringTokenizer strTokens = new StringTokenizer( strAvailableLocales, "," );
371         List<Locale> list = new ArrayList<>( );
372 
373         while ( strTokens.hasMoreTokens( ) )
374         {
375             String strLanguage = strTokens.nextToken( );
376             Locale locale = new Locale( strLanguage );
377             list.add( locale );
378         }
379 
380         return list;
381     }
382 
383     /**
384      * Get the default Locale specified in properties file
385      * 
386      * @return The default Locale
387      */
388     public static Locale getDefaultLocale( )
389     {
390         String strDefaultLocale = AppPropertiesService.getProperty( PROPERTY_DEFAULT_LOCALE );
391 
392         return new Locale( strDefaultLocale );
393     }
394 
395     /**
396      * Get the short date format specified by a locale
397      * 
398      * @param locale
399      *            The locale
400      * @return The localized short date pattern or null else
401      */
402     public static String getDateFormatShortPattern( Locale locale )
403     {
404         String strAvailableLocales = AppPropertiesService.getProperty( PROPERTY_FORMAT_DATE_SHORT_LIST );
405 
406         if ( ( locale != null ) && ( strAvailableLocales != null ) && !strAvailableLocales.equals( "" ) )
407         {
408             StringTokenizer strTokens = new StringTokenizer( strAvailableLocales, "," );
409             String strToken = null;
410 
411             for ( Locale adminLocale : getAdminAvailableLocales( ) )
412             {
413                 if ( strTokens.hasMoreTokens( ) )
414                 {
415                     strToken = strTokens.nextToken( );
416                 }
417 
418                 if ( adminLocale.getLanguage( ).equals( locale.getLanguage( ) ) )
419                 {
420                     return strToken;
421                 }
422             }
423         }
424 
425         return null;
426     }
427 
428     /**
429      * Returns a ReferenceList of available locales
430      * 
431      * @param locale
432      *            The locale to display available languages
433      * @return A ReferenceList of available locales
434      */
435     public static ReferenceList getAdminLocales( Locale locale )
436     {
437         ReferenceListnceList.html#ReferenceList">ReferenceList list = new ReferenceList( );
438 
439         for ( Locale l : getAdminAvailableLocales( ) )
440         {
441             list.addItem( l.getLanguage( ), l.getDisplayLanguage( l ) );
442         }
443 
444         return list;
445     }
446 
447     /**
448      * Localize in place all items of a collection
449      * 
450      * @param <E>
451      *            the type of the elements of the collection
452      * 
453      * @param collection
454      *            The collection to localize
455      * @param locale
456      *            The locale
457      * @return The localized collection passed as argument
458      */
459     public static <E extends Localizable> Collection<E> localizeCollection( Collection<E> collection, Locale locale )
460     {
461         for ( Localizable object : collection )
462         {
463             object.setLocale( locale );
464         }
465 
466         return collection;
467     }
468 
469     /**
470      * Localize in place all items of a list
471      * 
472      * @param <E>
473      *            the type of the elements of the list
474      * 
475      * @param list
476      *            The list to localize
477      * @param locale
478      *            The locale
479      * @return The localized collection passed as argument
480      */
481     public static <E extends Localizable> List<E> localizeCollection( List<E> list, Locale locale )
482     {
483         for ( Localizable object : list )
484         {
485             object.setLocale( locale );
486         }
487 
488         return list;
489     }
490 
491     /**
492      * get the resource bundle, possibly with its override
493      * 
494      * @param locale
495      *            the locale
496      * @param strBundle
497      *            the bundle name
498      * @return the resource bundle
499      */
500     private static ResourceBundle getResourceBundle( Locale locale, String strBundle )
501     {
502         String key = strBundle + locale.toString( );
503         return _resourceBundleCache.computeIfAbsent( key, k -> createResourceBundle( strBundle, locale ) );
504     }
505 
506     private static ResourceBundle createResourceBundle( String strBundle, Locale locale )
507     {
508         ResourceBundle rbLabels = ResourceBundle.getBundle( strBundle, locale );
509 
510         if ( _overrideLoader != null )
511         {
512             ResourceBundle overrideBundle = null;
513 
514             try
515             {
516                 overrideBundle = ResourceBundle.getBundle( strBundle, locale, _overrideLoader );
517             }
518             catch( MissingResourceException e )
519             {
520                 // no override for this resource
521                 return rbLabels;
522             }
523 
524             return new CombinedResourceBundle( overrideBundle, rbLabels );
525         }
526         return rbLabels;
527     }
528 
529     /**
530      * Reset the caches
531      * 
532      * @since 5.1
533      */
534     public static void resetCache( )
535     {
536         ResourceBundle.clearCache( );
537 
538         if ( _overrideLoader != null )
539         {
540             ResourceBundle.clearCache( _overrideLoader );
541         }
542 
543         _resourceBundleCache.clear( );
544     }
545     
546     /**
547      * returns the resource bundle keys of a plugin
548      * 
549      * @param strPluginName
550      * @return the enumeration
551      */
552     public static Enumeration<String> getPluginBundleKeys( String strPluginName )
553 	{
554 		ResourceBundle rb = getResourceBundle( getDefaultLocale(), getPluginBundleName( strPluginName ) );
555 		
556 		return rb.getKeys( );
557 	}
558 }