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