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