001/*
002 *  Copyright 2012 Anyware Services
003 *
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016
017package org.ametys.core.util;
018
019import java.io.IOException;
020import java.util.Arrays;
021import java.util.Collection;
022import java.util.HashMap;
023import java.util.List;
024import java.util.Locale;
025import java.util.Map;
026import java.util.stream.Collectors;
027
028import org.apache.avalon.framework.activity.Initializable;
029import org.apache.avalon.framework.component.Component;
030import org.apache.avalon.framework.component.ComponentException;
031import org.apache.avalon.framework.context.Context;
032import org.apache.avalon.framework.context.ContextException;
033import org.apache.avalon.framework.context.Contextualizable;
034import org.apache.avalon.framework.logger.AbstractLogEnabled;
035import org.apache.avalon.framework.service.ServiceException;
036import org.apache.avalon.framework.service.ServiceManager;
037import org.apache.avalon.framework.service.Serviceable;
038import org.apache.cocoon.components.ContextHelper;
039import org.apache.cocoon.i18n.Bundle;
040import org.apache.cocoon.i18n.BundleFactory;
041import org.apache.cocoon.xml.ParamSaxBuffer;
042import org.apache.cocoon.xml.SaxBuffer;
043import org.apache.cocoon.xml.SaxBuffer.Characters;
044import org.apache.commons.lang3.LocaleUtils;
045import org.apache.excalibur.source.Source;
046import org.apache.excalibur.source.SourceResolver;
047import org.apache.excalibur.source.TraversableSource;
048import org.xml.sax.SAXException;
049import org.xml.sax.helpers.DefaultHandler;
050
051import org.ametys.core.DevMode;
052import org.ametys.core.DevMode.DEVMODE;
053import org.ametys.core.cache.AbstractCacheManager;
054import org.ametys.core.cache.Cache;
055import org.ametys.core.cocoon.XMLResourceBundle;
056import org.ametys.core.util.language.LocaleHelper;
057import org.ametys.plugins.core.impl.cache.AbstractCacheKey;
058import org.ametys.runtime.i18n.FormatableI18nizable;
059import org.ametys.runtime.i18n.I18nizable;
060import org.ametys.runtime.i18n.I18nizableText;
061import org.ametys.runtime.i18n.I18nizableTextParameter;
062import org.ametys.runtime.plugin.PluginsManager;
063import org.ametys.runtime.workspace.WorkspaceManager;
064
065/**
066 * Utils for i18n
067 */
068public class I18nUtils extends AbstractLogEnabled implements Component, Serviceable, Contextualizable, Initializable
069{
070    /** The avalon role */
071    public static final String ROLE = I18nUtils.class.getName();
072    
073    /** i18n cache id */
074    public static final String I18N_CACHE = I18nUtils.class.getName() + "$i18n";
075
076    /** Base filename for application */
077    public static final String APPLICATION = "application";
078    
079    /** Base filename for all other types except application (like plugin, workspace, etc.) */
080    public static final String MESSAGES = "messages";
081    
082    /** I18n catalogues */
083    protected Map<String, Location> _locations;
084  
085    /** The avalon context */
086    protected Context _context;
087    
088    /** Source Resolver */
089    protected SourceResolver _resolver;
090    
091    private BundleFactory _bundleFactory;
092  
093    private AbstractCacheManager _cacheManager;
094    private LocaleHelper _localeHelper;
095    
096    @Override
097    public void contextualize(Context context) throws ContextException
098    {
099        _context = context;
100    }
101    
102    @Override
103    public void service(ServiceManager manager) throws ServiceException
104    {
105        _bundleFactory = (BundleFactory) manager.lookup(BundleFactory.ROLE);
106        _cacheManager = (AbstractCacheManager) manager.lookup(AbstractCacheManager.ROLE);
107        _resolver = (SourceResolver) manager.lookup(SourceResolver.ROLE);
108        _localeHelper = (LocaleHelper) manager.lookup(LocaleHelper.ROLE);
109    }
110    
111    @Override
112    public void initialize() throws Exception
113    {
114        _createCache();
115
116        _configure();
117    }
118    
119    /**
120     * Create the i18n cache
121     */
122    protected void _createCache()
123    {
124        _cacheManager.createMemoryCache(I18N_CACHE,
125                new I18nizableText("plugin.core", "PLUGINS_CORE_I18N_CACHE_LABEL"),
126                new I18nizableText("plugin.core", "PLUGINS_CORE_I18N_CACHE_DESCRIPTION"),
127                true,
128                null);
129    }
130    
131    /**
132     * Configure the i18n catalogue
133     */
134    protected void _configure ()
135    {
136        _locations = new HashMap<>();
137        
138        // initializes locations
139        
140        _locations.put("application", new Location(APPLICATION, new String[]{getApplicationCatalogLocation()}));
141        
142        // WEB-INF/param/*/i18n/
143        for (String name : getParamsFoldersWithI18n())
144        {
145            _locations.put("param." + name, new Location(MESSAGES, new String[]{getParamCatalogLocation(name)}));
146        }
147        
148        String type = "plugin";
149        PluginsManager pm = PluginsManager.getInstance();
150        for (String pluginName : pm.getPluginNames())
151        {
152            _locations.put(type + "." + pluginName, new Location(MESSAGES, new String[]{getOverridableCatalogLocation(type, pluginName), getDefaultCatalogLocation(type, pluginName)}));
153        }
154        
155        type = "workspace";
156        WorkspaceManager wm = WorkspaceManager.getInstance();
157        for (String workspace : wm.getWorkspaceNames())
158        {
159            _locations.put(type + "." + workspace, new Location(MESSAGES, new String[]{getOverridableCatalogLocation(type, workspace), getDefaultCatalogLocation(type, workspace)}));
160        }
161    }
162    
163    /**
164     * Get the catalog location for application.
165     * context://WEB-INF/i18n
166     * @return the location
167     */
168    public String getApplicationCatalogLocation()
169    {
170        return "context://WEB-INF/i18n";
171    }
172    
173    /**
174     * Get the catalog location form param.
175     * context://WEB-INF/param/[name]/i18n
176     * @param name the param name
177     * @return the location
178     */
179    public String getParamCatalogLocation(String name)
180    {
181        return "context://WEB-INF/param/" + name + "/i18n";
182    }
183    
184    /**
185     * Get the default catalog location for most typed cases (plugin, workspace at least).
186     * [type]:[name]://i18n
187     * @param type the type
188     * @param name the name of the element (can be a plugin, workspace, etc.)
189     * @return the location
190     */
191    public String getDefaultCatalogLocation(String type, String name)
192    {
193        return type + ":" + name + "://i18n";
194    }
195    
196    /**
197     * Get the overridable catalog location for most typed cases (plugin, workspace at least).
198     * context://WEB-INF/i18n/[type]s/[name]
199     * @param type the type
200     * @param name the name of the element (can be a plugin, workspace, etc.)
201     * @return the location
202     */
203    public String getOverridableCatalogLocation(String type, String name)
204    {
205        // Transform plugin to plugins and workspace to workspaces
206        return "context://WEB-INF/i18n/" + type + "s/" + name;
207    }
208    
209    /**
210     * Get the name of folders into WEB-INF/param which contains i18n catalogues
211     * @return the name of folders into WEB-INF/param which contains i18n catalogues
212     */
213    public List<String> getParamsFoldersWithI18n()
214    {
215        try
216        {
217            Source paramsRootFolder = _resolver.resolveURI("context://WEB-INF/param");
218            if (paramsRootFolder.exists() && paramsRootFolder instanceof TraversableSource)
219            {
220                Collection<Source> children = ((TraversableSource) paramsRootFolder).getChildren();
221                
222                return children.stream()
223                        .filter(TraversableSource.class::isInstance)
224                        .map(TraversableSource.class::cast)
225                        .filter(s -> s.isCollection())
226                        .filter(LambdaUtils.wrapPredicate(s -> s.getChild("i18n").exists()))
227                        .map(s -> s.getName())
228                        .collect(Collectors.toList());
229            }
230        }
231        catch (IOException e)
232        {
233            getLogger().error("Error while fetching i18n folders in WEB-INF/param/*/i18n", e);
234        }
235        
236        return List.of();
237    }
238    
239    /**
240     * Reload the i18n catalogues and clear cache.
241     * This method should be called as soon as the list of i18n catalogue was changed, when adding a new catalogue for example.
242     */
243    public void reloadCatalogues ()
244    {
245        clearCache();
246        _configure();
247    }
248    
249    /**
250     * Get the translation of the key in the first locale found in this order : user's language, browser language, language config parameter and then server's language.
251     * @param text The i18n key to translate
252     * @return The translation or null if there's no available translation
253     * @throws IllegalStateException if an error occured
254     */
255    public String translate(I18nizable text)
256    {
257        return translate(text, null);
258    }
259    
260    /**
261     * Get the translation of the key.
262     * @param text The i18n key to translate
263     * @param language The language code to use for translation. Can be null. Defaults to the first locale found in this order : user's language, browser language, language config parameter and then server's language.
264     * @return The translation or null if there's no available translation
265     * @throws IllegalStateException if an error occurred
266     */
267    public String translate(I18nizable text, String language) throws IllegalStateException
268    {
269        return translate(text, language, false);
270    }
271    
272    /**
273     * Get the translation of the key.
274     * @param i18nizable The {@link I18nizable} to translate
275     * @param language The language code to use for translation. Can be null. Defaults to the first locale found in this order : user's language, browser language, language config parameter and then server's language.
276     * @param rawValue Set true to get the value corresponding strictly to the specified Locale, without escalading to parent Locale if not found. Note that there is no cache for strict values.
277     * @return The translation or null if there's no available translation
278     * @throws IllegalStateException if an error occurred
279     */
280    public String translate(I18nizable i18nizable, String language, boolean rawValue) throws IllegalStateException
281    {
282        // Check language
283        final String langCode;
284        if (language != null)
285        {
286            langCode = language;
287        }
288        else
289        {
290            Map objectModel = ContextHelper.getObjectModel(_context);
291            Locale locale = _localeHelper.findLocale(objectModel);
292            langCode = locale.toString();
293        }
294        
295        if (i18nizable instanceof FormatableI18nizable)
296        {
297            return ((FormatableI18nizable) i18nizable).format(LocaleUtils.toLocale(langCode));
298        }
299        
300        I18nizableText text = (I18nizableText) i18nizable;
301        String value = null;
302        
303        if (rawValue)
304        {
305            // No cache for strict values
306            value = _translate(text, langCode, true);
307        }
308        else if (DevMode.getDeveloperMode() != DEVMODE.PRODUCTION)
309        {
310            // don't use cache in dev mode, so that i18n catalogues could by modified at any time
311            value = _translate(text, langCode, false);
312        }
313        else
314        {
315            value = _getI18NCache().get(I18nKey.of(langCode, text), __ -> _translate(text, langCode, false));
316        }
317
318        return value;
319    }
320    
321    /**
322     * Clear the i18n cache.
323     */
324    public void clearCache()
325    {
326        _getI18NCache().invalidateAll();
327    }
328    
329    /**
330     * Get the translation of the key.
331     * Only use in very specific cases (send mail for example)
332     * @param text The i18n key to translate
333     * @param language The language code to use for translation. Can be null.
334     * @param rawValue Set true to get the value corresponding strictly to the specified Locale, without escalading to parent Locale if not found
335     * @return The translation or null if there's no available translation
336     * @throws IllegalStateException if an error occured
337     */
338    protected String _translate(I18nizableText text, String language, boolean rawValue) throws IllegalStateException
339    {
340        if (!text.isI18n())
341        {
342            return text.getLabel();
343        }
344        
345        Location location = null;
346        if (text.getLocation() != null)
347        {
348            location = new Location(text.getBundleName(), new String[]{text.getLocation()});
349        }
350        else
351        {
352            String catalogue = text.getCatalogue();
353            location = _locations.get(catalogue);
354        }
355        
356        if (location == null)
357        {
358            return null;
359        }
360        
361        try
362        {
363            Bundle bundle = _bundleFactory.select(location.getLocations(), location.getName(), org.apache.cocoon.i18n.I18nUtils.parseLocale(language));
364            
365            // translated message
366            ParamSaxBuffer buffer = rawValue ? (ParamSaxBuffer) ((XMLResourceBundle) bundle).getRawObject(text.getKey()) : (ParamSaxBuffer) bundle.getObject(text.getKey());
367            
368            if (buffer == null)
369            {
370                return null;
371            }
372            
373            // message parameters
374            Map<String, SaxBuffer> params = new HashMap<>();
375            
376            if (text.getParameters() != null)
377            {
378                int p = 0;
379                for (String param : text.getParameters())
380                {
381                    Characters characters = new Characters(param.toCharArray(), 0, param.length());
382                    params.put(String.valueOf(p++), new SaxBuffer(Arrays.asList(characters)));
383                }
384            }
385            
386            if (text.getParameterMap() != null)
387            {
388                for (String name : text.getParameterMap().keySet())
389                {
390                    I18nizableTextParameter i18nizable = text.getParameterMap().get(name);
391                    
392                    if (i18nizable instanceof FormatableI18nizable)
393                    {
394                        String param = ((FormatableI18nizable) i18nizable).format(LocaleUtils.toLocale(language));
395                        Characters characters = new Characters(param.toCharArray(), 0, param.length());
396                        params.put(name, new SaxBuffer(Arrays.asList(characters)));
397                    }
398                    else
399                    {
400                        // named parameters are themselves I18nizableText, so translate them recursively
401                        String param = translate((I18nizableText) i18nizable, language, rawValue);
402                        if (param == null)
403                        {
404                            param = "";
405                        }
406                        Characters characters = new Characters(param.toCharArray(), 0, param.length());
407                        params.put(name, new SaxBuffer(Arrays.asList(characters)));
408                    }
409                }
410            }
411            
412            StringBuilder result = new StringBuilder();
413            buffer.toSAX(new BufferHandler(result), params);
414            
415            return result.toString();
416        }
417        catch (SAXException e)
418        {
419            throw new RuntimeException("Unable to get i18n translation", e);
420        }
421        catch (ComponentException e)
422        {
423            throw new RuntimeException("Unable to get i18n catalogue", e);
424        }
425    }
426    
427    private class BufferHandler extends DefaultHandler
428    {
429        StringBuilder _builder;
430        
431        public BufferHandler(StringBuilder builder)
432        {
433            _builder = builder;
434        }
435        
436        @Override
437        public void characters(char[] ch, int start, int length) throws SAXException
438        {
439            _builder.append(ch, start, length);
440        }
441    }
442    
443    /**
444     * Class representing an i18n location
445     */
446    protected static class Location
447    {
448        String[] _loc;
449        String _name;
450        
451        /**
452         * Constructor.
453         * @param name the catalogue name
454         * @param locations the files locations.
455         */
456        public Location(String name, String[] locations)
457        {
458            _name = name;
459            _loc = locations;
460        }
461        
462        /**
463         * Get the name
464         * @return the name
465         */
466        public String getName()
467        {
468            return _name;
469        }
470        
471        /**
472         * Get the files location
473         * @return the files location
474         */
475        public String[] getLocations()
476        {
477            return _loc;
478        }
479    }
480    
481    /**
482     * get the i18n cache (link language and I18nizable text to a translated value)
483     * @return the i18n cache
484     */
485    protected Cache<I18nKey, String> _getI18NCache()
486    {
487        return _cacheManager.get(I18N_CACHE);
488    }
489
490    static final class I18nKey extends AbstractCacheKey
491    {
492        private I18nKey(String language, I18nizableText text)
493        {
494            super(language, text);
495        }
496        
497        static I18nKey of(String language, I18nizableText text)
498        {
499            return new I18nKey(language, text);
500        }
501    }
502}