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