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