001/*
002 *  Copyright 2023 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 */
016package org.ametys.plugins.workflow.support;
017
018import java.io.IOException;
019import java.io.InputStream;
020import java.io.OutputStream;
021import java.util.Collections;
022import java.util.HashMap;
023import java.util.LinkedHashMap;
024import java.util.Map;
025import java.util.Map.Entry;
026import java.util.Optional;
027import java.util.Properties;
028
029import javax.xml.parsers.ParserConfigurationException;
030import javax.xml.parsers.SAXParserFactory;
031import javax.xml.transform.OutputKeys;
032import javax.xml.transform.TransformerFactory;
033import javax.xml.transform.sax.SAXTransformerFactory;
034import javax.xml.transform.sax.TransformerHandler;
035import javax.xml.transform.stream.StreamResult;
036
037import org.apache.avalon.framework.component.Component;
038import org.apache.avalon.framework.configuration.DefaultConfigurationBuilder;
039import org.apache.avalon.framework.service.ServiceException;
040import org.apache.avalon.framework.service.ServiceManager;
041import org.apache.avalon.framework.service.Serviceable;
042import org.apache.cocoon.xml.AttributesImpl;
043import org.apache.cocoon.xml.XMLUtils;
044import org.apache.commons.lang3.ArrayUtils;
045import org.apache.commons.lang3.StringUtils;
046import org.apache.excalibur.source.ModifiableSource;
047import org.apache.excalibur.source.Source;
048import org.apache.excalibur.source.SourceNotFoundException;
049import org.apache.excalibur.source.SourceResolver;
050import org.apache.xml.serializer.OutputPropertiesFactory;
051import org.xml.sax.SAXException;
052
053import org.ametys.core.observation.Event;
054import org.ametys.core.observation.ObservationManager;
055import org.ametys.core.user.CurrentUserProvider;
056import org.ametys.core.util.I18nUtils;
057import org.ametys.plugins.workflow.component.WorkflowLanguageManager;
058import org.ametys.runtime.i18n.I18nizableText;
059import org.ametys.runtime.plugin.component.AbstractLogEnabled;
060
061/**
062 * Helper for saxing i18n catalogs
063 */
064public class I18nHelper extends AbstractLogEnabled implements Component, Serviceable
065{
066    /** The helper role */
067    public static final String ROLE = I18nHelper.class.getName();
068    
069    /** I18n Utils */
070    protected I18nUtils _i18nUtils;
071   
072    /** The workflow helper */
073    protected WorkflowHelper _workflowHelper;
074    
075    /** The workflow session helper */
076    protected WorkflowSessionHelper _workflowSessionHelper;
077    
078    /** The workflow language manager */
079    protected WorkflowLanguageManager _workflowLanguageManager;
080    
081    /** The source resolver */
082    protected SourceResolver _sourceResolver;
083    
084    /** The observation manager */
085    protected ObservationManager _observationManager;
086    
087    /** The current user provider */
088    protected CurrentUserProvider _currentUserProvider;
089    
090    public void service(ServiceManager manager) throws ServiceException
091    {
092        _sourceResolver = (SourceResolver) manager.lookup(org.apache.excalibur.source.SourceResolver.ROLE);
093        _i18nUtils = (I18nUtils) manager.lookup(I18nUtils.ROLE);
094        _workflowSessionHelper = (WorkflowSessionHelper) manager.lookup(WorkflowSessionHelper.ROLE);
095        _workflowLanguageManager = (WorkflowLanguageManager) manager.lookup(WorkflowLanguageManager.ROLE);
096        _workflowHelper = (WorkflowHelper) manager.lookup(WorkflowHelper.ROLE);
097        _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE);
098        _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE);
099    }
100    
101    /**
102     * Get the default language to use for the i18n translation
103     * @return a language code
104     */
105    public String getI18nDefaultLanguage()
106    {
107        Optional<String> fileLanguage = Optional.empty();
108        try
109        {
110            // Parse default catalog application to know the default language
111            fileLanguage = _getFileLanguage("application");
112        }
113        catch (Exception e)
114        {
115            getLogger().warn("An exception occured while getting current catalog language", e);
116        }
117        return fileLanguage.orElseGet(_workflowLanguageManager::getCurrentLanguage);
118    }
119    
120    /**
121     * Get language used by XML file
122     * @param catalogName the catalog name
123     * @return the language code
124     * @throws Exception while resolving and getting file configuration
125     */
126    private Optional<String> _getFileLanguage(String catalogName) throws Exception
127    {
128        String defaultLanguage = null;
129        String defaultI18nCatalogPath = getPrefixCatalogLocation(catalogName) + ".xml";
130        Source defaultI18nCatalogSource = null;
131        try
132        {
133            defaultI18nCatalogSource = _sourceResolver.resolveURI(defaultI18nCatalogPath);
134            if (defaultI18nCatalogSource.exists())
135            {
136                try (InputStream is = defaultI18nCatalogSource.getInputStream())
137                {
138                    defaultLanguage = new DefaultConfigurationBuilder(true)
139                            .build(is)
140                            .getAttribute("xml:lang", null);
141                }
142            }
143            
144        }
145        catch (SourceNotFoundException e)
146        {
147            getLogger().warn("Couldn't find file at path: {} a new file will be created", defaultI18nCatalogPath, e);
148        }
149        finally
150        {
151            _sourceResolver.release(defaultI18nCatalogSource);
152        }
153        
154        return Optional.ofNullable(defaultLanguage).filter(StringUtils::isNotEmpty);
155    }
156    
157    /**
158     * Translate i18n label for workflow element, return a default name if translation is not found
159     * @param workflowName the workflow's unique name
160     * @param i18nKey an i18n key pointing to workflow element's label 
161     * @param defaultKey a default i18n key for workflow element
162     * @return a translated label
163     */
164    public String translateKey(String workflowName, I18nizableText i18nKey, I18nizableText defaultKey)
165    {
166        return Optional.of(workflowName)
167            .map(_workflowSessionHelper::getTranslations)
168            .map(translations -> translations.get(i18nKey))
169            // if current language doesn't have translations jump to _translateKey()
170            .map(translation -> translation.get(_workflowLanguageManager.getCurrentLanguage()))
171            .filter(StringUtils::isNotEmpty)
172            .orElseGet(() -> _translateKey(i18nKey, defaultKey));
173    }
174    
175    private String _translateKey(I18nizableText i18nKey, I18nizableText defaultKey)
176    {
177        return Optional.ofNullable(_i18nUtils.translate(i18nKey))
178                .filter(StringUtils::isNotBlank)
179                .orElseGet(() -> _i18nUtils.translate(defaultKey));
180    }
181    
182    /**
183     * Transform workflow name in unique I18n key
184     * @param workflowName the workflow's unique name
185     * @return the workflow label I18n Key
186     */
187    public I18nizableText getWorkflowLabelKey(String workflowName)
188    {
189        return ArrayUtils.contains(_workflowHelper.getWorkflowNames(), workflowName)
190                ? _workflowHelper.getWorkflowLabel(workflowName) 
191                : new I18nizableText("application", buildI18nWorkflowKey(workflowName));
192    }
193    
194    /**
195     * Generate unique key for workflow element label
196     * @param workflowName the workflow's unique name
197     * @param type the workflow's element type
198     * @param workflowElementId the element's id
199     * @return a unique i18n key 
200     */
201    public I18nizableText generateI18nKey(String workflowName, String type, int workflowElementId)
202    {
203        String key = buildI18nWorkflowKey(workflowName) + "_" + type.toUpperCase() + "_" + workflowElementId;
204        String workflowCatalog = _workflowHelper.getWorkflowCatalog(workflowName);
205        I18nizableText i18nKey = new I18nizableText(workflowCatalog, key);
206        return i18nKey;
207    }
208
209    /**
210     * Sax new messages into i18N catalogs
211     * @param newI18nMessages catalog of the new i18N messages, key is language, value is map of i18nKeys, translation 
212     * @param currentCatalog the current catalog
213     * @throws Exception exception while reading file
214     */
215    public void saveCatalogs(Map<String, Map<I18nizableText, String>> newI18nMessages, String currentCatalog) throws Exception
216    {
217        String prefixCatalogPath = getPrefixCatalogLocation(currentCatalog);
218        
219        // Determine the default catalog language
220        String defaultLanguage = getI18nDefaultLanguage();
221        Map<String, Map<I18nizableText, String>> i18nCatalogs = new HashMap<>();
222        for (Entry<String, Map<I18nizableText, String>> i18nMessageTranslation : newI18nMessages.entrySet())
223        {
224            Map<I18nizableText, String> i18nMessages = new HashMap<>();
225            String language = i18nMessageTranslation.getKey();
226            String catalogPath = prefixCatalogPath + (currentCatalog.equals("application") && language.equals(defaultLanguage) ? "" : ("_" + language)) + ".xml";
227            i18nMessages = readI18nCatalog(i18nCatalogs, catalogPath, currentCatalog);
228            _updateI18nMessages(i18nMessages, i18nMessageTranslation.getValue());
229            _saveI18nCatalog(i18nMessages, catalogPath, language);
230        }
231    }
232    
233    /**
234     * Clear the i18n caches (ametys and cocoon)
235     */
236    public void clearCaches()
237    {
238        _i18nUtils.reloadCatalogues();
239        
240        _observationManager.notify(new Event(org.ametys.core.ObservationConstants.EVENT_CACHE_RESET,
241                _currentUserProvider.getUser(),
242                Collections.singletonMap(org.ametys.core.ObservationConstants.ARGS_CACHE_ID, I18nUtils.I18N_CACHE)));
243    }
244    
245    /**
246     * Generate i18n catalog from workflow's new i18n translations
247     * @param translationsToConvert the workflow to save's translation list: keys are future i18n key, values are maps of language codes and translations 
248     * @return a map of i18n catalogs : keys are languages, values are pair of i18nkey, translation
249     */
250    public Map<String, Map<I18nizableText, String>> createNewI18nCatalogs(Map<I18nizableText, Map<String, String>> translationsToConvert)
251    {
252        Map<String, Map<I18nizableText, String>> i18nMessageTranslations = new HashMap<>();
253        for (Entry<I18nizableText, Map<String, String>> newI18n : translationsToConvert.entrySet())
254        {
255            _updateI18nMessageTranslations(i18nMessageTranslations, newI18n.getKey(), newI18n.getValue());
256        }
257        return i18nMessageTranslations;
258    }
259
260    /**
261     * Add translations to catalogs
262     * @param i18nMessageTranslations the map of i18n catalogs, keys are languages, values are pair of i18nkey, translation
263     * @param key an i18nKey to add
264     * @param translations a map of translations to transform: key is language and value is translation
265     */
266    private void _updateI18nMessageTranslations(Map<String, Map<I18nizableText, String>> i18nMessageTranslations, I18nizableText key, Map<String, String> translations)
267    {
268        for (Entry<String, String> translation : translations.entrySet())
269        {
270            String language = translation.getKey();
271            Map<I18nizableText, String> map = i18nMessageTranslations.computeIfAbsent(language, __ -> new HashMap<>());
272            map.put(key, translation.getValue());
273        }
274    }
275    
276    /**
277     * Read existing i18n catalog
278     * @param i18nCatalogs map of already read catalogs
279     * @param path the path to the file to read
280     * @param currentCatalog the catalog of the current workflow
281     * @return the messages in the catalog as map
282     */
283    public Map<I18nizableText, String> readI18nCatalog(Map<String, Map<I18nizableText, String>> i18nCatalogs, String path, String currentCatalog)
284    {
285        if (i18nCatalogs.containsKey(path))
286        {
287            return i18nCatalogs.get(path);
288        }
289        
290        Map<I18nizableText, String> i18nMessages = new LinkedHashMap<>();
291        Source i18nCatalogSource = null;
292        
293        try
294        {
295            i18nCatalogSource = _sourceResolver.resolveURI(path);
296            if (i18nCatalogSource.exists())
297            {
298                try (InputStream is = i18nCatalogSource.getInputStream())
299                {
300                    SAXParserFactory.newInstance().newSAXParser().parse(is, new I18nMessageHandler(i18nMessages, currentCatalog));
301                    i18nCatalogs.put(path, i18nMessages);
302                }
303            }
304        }
305        catch (IOException | SAXException | ParserConfigurationException e)
306        {
307            getLogger().error("An error occured while reading existing i18n catalog", e);
308        }
309        finally
310        {
311            _sourceResolver.release(i18nCatalogSource);
312        }
313        
314        return i18nMessages;
315    }
316    
317    /**
318     * Update current i18n catalog with new values
319     * @param i18nMessages the current i18n catalog
320     * @param newI18nMessages the map of new messages
321     */
322    private void _updateI18nMessages(Map<I18nizableText, String> i18nMessages, Map<I18nizableText, String> newI18nMessages)
323    {
324        for (Entry<I18nizableText, String> newI18nMessage : newI18nMessages.entrySet())
325        {
326            i18nMessages.put(newI18nMessage.getKey(), newI18nMessage.getValue());
327        }
328    }
329    
330    /**
331     * Save the i18n catalog with new values 
332     * @param i18nMessages the messages to save
333     * @param catalogPath the path to the catalog
334     * @param language the language of the translations
335     */
336    protected void _saveI18nCatalog(Map<I18nizableText, String> i18nMessages, String catalogPath, String language)
337    {
338        ModifiableSource defaultI18nCatalogSource = null;
339        try
340        {
341            defaultI18nCatalogSource = (ModifiableSource) _sourceResolver.resolveURI(catalogPath);
342            try (OutputStream os = defaultI18nCatalogSource.getOutputStream())
343            {
344                // create a transformer for saving sax into a file
345                TransformerHandler th = ((SAXTransformerFactory) TransformerFactory.newInstance()).newTransformerHandler();
346
347                StreamResult result = new StreamResult(os);
348                th.setResult(result);
349
350                // create the format of result
351                Properties format = new Properties();
352                format.put(OutputKeys.METHOD, "xml");
353                format.put(OutputKeys.INDENT, "yes");
354                format.put(OutputKeys.ENCODING, "UTF-8");
355                format.put(OutputPropertiesFactory.S_KEY_INDENT_AMOUNT, "2");
356                th.getTransformer().setOutputProperties(format);
357
358                // sax the config into the transformer
359                _saxI18nCatalog(th, i18nMessages, language);
360
361            }
362            catch (Exception e)
363            {
364                getLogger().error("An error occured while saving the catalog values.", e);
365            }
366        }
367        catch (IOException e)
368        {
369            getLogger().error("An error occured while getting i18n catalog", e);
370        }
371        finally
372        {
373            _sourceResolver.release(defaultI18nCatalogSource);
374        }
375
376    }
377    
378    private void _saxI18nCatalog(TransformerHandler handler, Map<I18nizableText, String> i18nMessages, String language) throws SAXException
379    {
380        handler.startDocument();
381        AttributesImpl attribute = new AttributesImpl();
382        attribute.addCDATAAttribute("xml:lang", language);
383        XMLUtils.startElement(handler, "catalogue", attribute);
384        for (Entry<I18nizableText, String> i18Message : i18nMessages.entrySet())
385        {
386            attribute = new AttributesImpl();
387            attribute.addCDATAAttribute("key", i18Message.getKey().getKey());
388            XMLUtils.createElement(handler, "message", attribute, i18Message.getValue());
389        }
390        XMLUtils.endElement(handler, "catalogue");
391        handler.endDocument();
392    }
393    
394    /**
395     * Get the overridable catalog location for a plugin, workspace or application.
396     * @param catalogName The catalog name
397     * @return the location
398     */
399    public String getPrefixCatalogLocation(String catalogName)
400    {
401        if (catalogName.equals("application"))
402        {
403            return _i18nUtils.getApplicationCatalogLocation() + "/" + I18nUtils.APPLICATION;
404        }
405        
406        String[] catalogParts = catalogName.split("\\.", 2);
407        if (catalogParts.length != 2)
408        {
409            throw new IllegalArgumentException("The catalog name should be composed of two parts (like plugin.cms): " + catalogName);
410        }
411        
412        // Transform plugin to plugins and workspace to workspaces
413        return _i18nUtils.getOverridableCatalogLocation(catalogParts[0], catalogParts[1]) + "/" + I18nUtils.MESSAGES;
414    }
415    
416    /**
417     * Build an i18n key for workflows like "WORKFLOW_[WORKFLOW_NAME]"
418     * @param workflowName the workflow name
419     * @return the built key
420     */
421    
422    public static String buildI18nWorkflowKey(String workflowName)
423    {
424        return "WORKFLOW_" + workflowName.replace("-", "_").toUpperCase();
425    }
426}