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.dao;
017
018import java.io.File;
019import java.io.FileNotFoundException;
020import java.io.IOException;
021import java.io.PrintWriter;
022import java.nio.charset.StandardCharsets;
023import java.nio.file.StandardCopyOption;
024import java.util.ArrayList;
025import java.util.HashMap;
026import java.util.HashSet;
027import java.util.LinkedHashMap;
028import java.util.List;
029import java.util.Map;
030import java.util.Set;
031
032import org.apache.avalon.framework.component.Component;
033import org.apache.avalon.framework.context.ContextException;
034import org.apache.avalon.framework.context.Contextualizable;
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.Constants;
039import org.apache.cocoon.environment.Context;
040import org.apache.commons.io.FileUtils;
041import org.apache.commons.lang3.ArrayUtils;
042import org.apache.commons.lang3.StringUtils;
043import org.apache.excalibur.source.SourceResolver;
044
045import org.ametys.core.observation.Event;
046import org.ametys.core.observation.ObservationManager;
047import org.ametys.core.ui.Callable;
048import org.ametys.core.user.CurrentUserProvider;
049import org.ametys.core.util.I18nUtils;
050import org.ametys.plugins.workflow.ObservationConstants;
051import org.ametys.plugins.workflow.component.WorkflowLanguageManager;
052import org.ametys.plugins.workflow.definition.WorkflowDefinitionExtensionPoint;
053import org.ametys.plugins.workflow.support.I18nHelper;
054import org.ametys.plugins.workflow.support.WorflowRightHelper;
055import org.ametys.plugins.workflow.support.WorkflowHelper;
056import org.ametys.plugins.workflow.support.WorkflowSessionHelper;
057import org.ametys.runtime.i18n.I18nizableText;
058import org.ametys.runtime.plugin.component.AbstractLogEnabled;
059
060import com.opensymphony.workflow.loader.AbstractDescriptor;
061import com.opensymphony.workflow.loader.ActionDescriptor;
062import com.opensymphony.workflow.loader.ConditionalResultDescriptor;
063import com.opensymphony.workflow.loader.ConditionsDescriptor;
064import com.opensymphony.workflow.loader.DescriptorFactory;
065import com.opensymphony.workflow.loader.RestrictionDescriptor;
066import com.opensymphony.workflow.loader.WorkflowDescriptor;
067
068/**
069 * DAO for managing workflows
070 */
071public class WorkflowsDAO extends AbstractLogEnabled implements Serviceable, Component, Contextualizable
072{
073    /** The Avalon role */
074    public static final String ROLE = WorkflowsDAO.class.getName();
075    /** Meta to set in new workflows */
076    public static final String META_NEW_WORKFLOW = "new-workflow";
077    
078    /** The workflow helper */
079    protected WorkflowHelper _workflowHelper;
080    
081    /** The workflow session helper */
082    protected WorkflowSessionHelper _workflowSessionHelper;
083    
084    /** The workflow right helper */
085    protected WorflowRightHelper _workflowRightHelper;
086    
087    /** The workflow language manager */
088    protected WorkflowLanguageManager _workflowLanguageManager;
089    
090    /** The helper for i18n translations and catalogs */
091    protected I18nHelper _i18nHelper;
092    
093    /** The Cocoon context */
094    protected Context _cocoonContext;
095    
096    /** I18n Utils */
097    protected I18nUtils _i18nUtils;
098    
099    /** The context */
100    protected org.apache.avalon.framework.context.Context _context;
101    
102    /** The regex pattern for workflow names */
103    protected final String _regexPattern = "^[a-zA-Z\\-]+$";
104    
105    /** The Workflow Definition Extension Point */
106    protected WorkflowDefinitionExtensionPoint _workflowDefinitionEP;
107    
108    /** The workflow transition DAO */
109    protected WorkflowTransitionDAO _workflowTransitionDAO;
110    
111    /** The observation manager */
112    protected ObservationManager _observationManager;
113    
114    /** The current user provider */
115    protected CurrentUserProvider _currentUserProvider;
116    
117    /** The source resolver */
118    protected SourceResolver _sourceResolver;
119    
120    @Override
121    public void contextualize(org.apache.avalon.framework.context.Context context) throws ContextException
122    {
123        _context = context;
124        _cocoonContext = (Context) context.get(Constants.CONTEXT_ENVIRONMENT_CONTEXT);
125    }
126    
127    public void service(ServiceManager manager) throws ServiceException
128    {
129        _sourceResolver = (SourceResolver) manager.lookup(org.apache.excalibur.source.SourceResolver.ROLE);
130        _workflowDefinitionEP = (WorkflowDefinitionExtensionPoint) manager.lookup(WorkflowDefinitionExtensionPoint.ROLE);
131        _workflowHelper = (WorkflowHelper) manager.lookup(WorkflowHelper.ROLE);
132        _workflowRightHelper = (WorflowRightHelper) manager.lookup(WorflowRightHelper.ROLE);
133        _workflowSessionHelper = (WorkflowSessionHelper) manager.lookup(WorkflowSessionHelper.ROLE);
134        _i18nUtils = (I18nUtils) manager.lookup(I18nUtils.ROLE);
135        _workflowLanguageManager = (WorkflowLanguageManager) manager.lookup(WorkflowLanguageManager.ROLE);
136        _i18nHelper = (I18nHelper) manager.lookup(I18nHelper.ROLE);
137        _workflowTransitionDAO = (WorkflowTransitionDAO) manager.lookup(WorkflowTransitionDAO.ROLE);
138        _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE);
139        _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE);
140    }
141    
142    /**
143     * Get the workflow properties
144     * @param workflowName the name of the workflow to get
145     * @return the workflow properties
146     */
147    @Callable(rights = Callable.SKIP_BUILTIN_CHECK)
148    public Map<String, Object> getWorkflowRootProperties(String workflowName)
149    {
150        WorkflowDescriptor workflowDescriptor = _workflowSessionHelper.getWorkflowDescriptor(workflowName, false);
151        Map<String, Object> infos = new HashMap<>();
152        if (workflowDescriptor != null && _workflowRightHelper.canRead(workflowDescriptor))
153        {
154            infos.put("id", workflowName);
155            infos.put("label", _workflowSessionHelper.getWorkflowLabel(workflowName));
156            infos.put("hasChildren", workflowDescriptor.getSteps().size() > 0);
157            infos.put("hasChanges", _workflowSessionHelper.hasChanges(workflowName));
158            infos.put("isNew", workflowDescriptor.getMetaAttributes().containsKey(META_NEW_WORKFLOW));
159            infos.put("canWrite", _workflowRightHelper.canWrite(workflowDescriptor));
160        }
161        else
162        {
163            String errorMsg = workflowDescriptor != null ? "cant-read" : "workflow-unknown";
164            infos.put("error", errorMsg);
165        }
166        return infos;
167    }
168    
169    /**
170     * Get the list of all workflows
171     * @return a map with workflow's list as value
172     */
173    @Callable(rights = Callable.SKIP_BUILTIN_CHECK)
174    public Map<String, Object> getWorkflowsList()
175    {
176        List<Map<String, Object>> workflows2json = new ArrayList<>();
177        
178        Set<String> workflowNames = _workflowSessionHelper.getWorkflowNames();
179        for (String workflowName: workflowNames)
180        {
181            WorkflowDescriptor workflowDescriptor = _workflowSessionHelper.getWorkflowDescriptor(workflowName, false);
182            if (_workflowRightHelper.canRead(workflowDescriptor))
183            {
184                Map<String, Object> workflowData = new LinkedHashMap<>();
185                workflowData.put("title", _workflowSessionHelper.getWorkflowLabel(workflowName));
186                workflowData.put("id", workflowName);
187                workflowData.put("hasChanges", _workflowSessionHelper.hasChanges(workflowName));
188                workflows2json.add(workflowData);
189            }
190        }
191        
192        return Map.of("workflows", workflows2json);
193    }
194    
195    /**
196     * Overwrite the current workflow in a XML file  
197     * @param workflowName id of current workflow
198     * @return an empty map if all went well, an error message if not  
199     */
200    @Callable(rights = Callable.SKIP_BUILTIN_CHECK)
201    public Map<String, Object> saveChanges(String workflowName)
202    {
203        WorkflowDescriptor workflowDescriptor = _workflowSessionHelper.getWorkflowDescriptor(workflowName, false);
204        _workflowRightHelper.checkEditRight(workflowDescriptor);
205        Map<String, Object> results = new HashMap<>();
206        
207        try
208        {
209            // Write new workflow
210            if (!_setErrors(workflowDescriptor, results))
211            {
212                workflowDescriptor.getMetaAttributes().remove(META_NEW_WORKFLOW);
213                _writeWorklowFile(workflowName, workflowDescriptor);
214                
215                // Write new i18n
216                _writeI18nTranslations(workflowName, _workflowSessionHelper.getTranslations(workflowName));
217                
218                _workflowSessionHelper.cloneImages(workflowName);
219                _workflowSessionHelper.deleteSession(workflowName);
220                _workflowDefinitionEP.addOrUpdateExtension(workflowName);
221                
222                _i18nHelper.clearCaches();
223                
224                Map<String, Object> params = new HashMap<>();
225                params.put(ObservationConstants.ARGS_WORKFLOW_NAME, workflowName);
226                _observationManager.notify(new Event(ObservationConstants.EVENT_WORKFLOW_SAVED, _currentUserProvider.getUser(), params));
227            }
228            results.put("workflowId", workflowName);
229        }
230        catch (FileNotFoundException e)
231        {
232            results.put("message", "file-not-found");
233            getLogger().error("An error occured while overwriting workflow file: {}", workflowName, e);
234        }
235        catch (Exception e)
236        {
237            results.put("message", "sax-error");
238            getLogger().error("An error occured while saxing i18n catalogs file", e);
239        }
240        return results;
241    }
242    
243    /**
244     * Restore last version of current workflow if exist
245     * @param workflowName name of current workflow
246     * @return map of result
247     */
248    @Callable(rights = Callable.SKIP_BUILTIN_CHECK)
249    public Map<String, Object> reinit(String workflowName)
250    {
251        WorkflowDescriptor workflowDescriptor = _workflowSessionHelper.getWorkflowDescriptor(workflowName, false);
252        _workflowRightHelper.checkEditRight(workflowDescriptor);
253        if (workflowDescriptor.getMetaAttributes().containsKey(META_NEW_WORKFLOW))
254        {
255            return Map.of("message", "new_workflow");
256        }
257        _workflowSessionHelper.deleteSession(workflowName);
258        return Map.of("workflowId", workflowName);
259    }
260    
261    /**
262     * Check for invalid components in workflow, return true if there is any
263     * @param workflowDescriptor the workflow to check
264     * @param results a map to fill with error message and invalid component's labels
265     * @return true if there are errors
266     */
267    protected boolean _setErrors(WorkflowDescriptor workflowDescriptor, Map<String, Object> results)
268    {
269        if (workflowDescriptor.getInitialActions().isEmpty())
270        {
271            results.put("message", "empty-initials-actions");
272            return true;
273        }
274        Set<Integer> transitionIds = _workflowHelper.getAllActions(workflowDescriptor);
275        return _hasEmptyOperator(workflowDescriptor, transitionIds, results) || _hasEmptyConditionalResult(workflowDescriptor, results, transitionIds);
276    }
277    
278    /**
279     * Check for result without condition or operators without conditions
280     * @param workflowDescriptor the workflow to check
281     * @param results a map to fill with error message and invalid transition's labels
282     * @param transitionIds a list of all the workflow's transitions ids
283     * @return true if there are invalid conditional results
284     */
285    protected boolean _hasEmptyConditionalResult(WorkflowDescriptor workflowDescriptor, Map<String, Object> results, Set<Integer> transitionIds)
286    {
287        Set<String> invalidTransitions = new HashSet<>();
288        for (Integer id : transitionIds)
289        {
290            ActionDescriptor action = workflowDescriptor.getAction(id);
291            String actionLabel = _workflowTransitionDAO.getActionLabel(workflowDescriptor.getName(), action) + "(" + action.getId() + ")";
292            
293            List<ConditionalResultDescriptor> conditionalResults = action.getConditionalResults();
294            for (ConditionalResultDescriptor resultDescriptor : conditionalResults)
295            {
296                List<ConditionsDescriptor> conditions = resultDescriptor.getConditions();
297                if (conditions.isEmpty())
298                {
299                    results.put("message", "empty-conditionnal-result");
300                    invalidTransitions.add(actionLabel);
301                }
302                else if (_hasOperatorWithoutChild(conditions.get(0)))
303                {
304                    results.put("message", "empty-result-operator");
305                    invalidTransitions.add(actionLabel);
306                }
307            }
308        }
309        
310        results.put("invalidTransitions", invalidTransitions.isEmpty() ? null : invalidTransitions);
311        return results.get("message") != null;
312    }
313    
314    /**
315     * Check for operators without conditions
316     * @param workflowDescriptor the workflow to check
317     * @param transitionIds a list of all the workflow's transitions ids
318     * @param results a map to fill with error message and invalid transition's labels
319     * @return true if there are operator without conditions
320     */
321    protected boolean _hasEmptyOperator(WorkflowDescriptor workflowDescriptor, Set<Integer> transitionIds, Map<String, Object> results)
322    {
323        Set<String> invalidTransitions = new HashSet<>();
324        for (Integer id : transitionIds)
325        {
326            ActionDescriptor action = workflowDescriptor.getAction(id);
327            if (action != null) // case in kernel workflow where an action is declared but never used
328            {
329                String actionLabel = _i18nHelper.translateKey(workflowDescriptor.getName(), new I18nizableText("application", action.getName()), WorkflowTransitionDAO.DEFAULT_ACTION_NAME) + " (" + action.getId() + ")";
330                RestrictionDescriptor restriction = action.getRestriction();
331                if (restriction != null)
332                {
333                    ConditionsDescriptor rootOperator = restriction.getConditionsDescriptor();
334                    if (_hasOperatorWithoutChild(rootOperator))
335                    {
336                        invalidTransitions.add(actionLabel);
337                    }
338                }
339            }
340        }
341        if (!invalidTransitions.isEmpty())
342        {
343            results.put("message", "empty-condition-operator");
344            results.put("invalidTransitions", invalidTransitions);
345            return true;
346        }
347        return false;
348    }
349
350    private boolean _hasOperatorWithoutChild(ConditionsDescriptor rootOperator)
351    {
352        List<AbstractDescriptor> conditions = rootOperator.getConditions();
353        if (conditions.isEmpty())
354        {
355            return true;
356        }
357        else
358        {
359            int i = 0;
360            boolean hasChildFreeOperator = false;
361            while (i < conditions.size() && !hasChildFreeOperator)
362            {
363                if (conditions.get(i) instanceof ConditionsDescriptor operator)
364                {
365                    hasChildFreeOperator = _hasOperatorWithoutChild(operator);
366                }
367                i++;
368            }
369            return hasChildFreeOperator;
370        }
371    }
372    
373    private void _writeI18nTranslations(String workflowName, Map<I18nizableText, Map<String, String>> translations) throws Exception
374    {
375        if (!translations.isEmpty())
376        {
377            // Write new i18n messages in application 
378            String workflowCatalog = _workflowHelper.getWorkflowCatalog(workflowName);
379            Map<String, Map<I18nizableText, String>> newI18nCatalogs = _i18nHelper.createNewI18nCatalogs(translations);
380            _i18nHelper.saveCatalogs(newI18nCatalogs, workflowCatalog);
381        }
382    }
383    
384    private void _writeWorklowFile(String workflowName, WorkflowDescriptor workflowDescriptor) throws IOException
385    {
386        File workflowFile = new File(_workflowHelper.getParamWorkflowDir(), workflowName + ".xml");
387        
388        // Save the workflow file if it already exists
389        if (workflowFile.exists())
390        {
391            FileUtils.copyFile(workflowFile, new File(workflowFile + ".bak"), StandardCopyOption.REPLACE_EXISTING);
392        }
393        // Otherwise, create the file and its parents if necessary
394        else
395        {
396            FileUtils.createParentDirectories(workflowFile);
397            workflowFile.createNewFile();
398        }
399        
400        try (PrintWriter out = new PrintWriter(workflowFile, StandardCharsets.UTF_8))
401        {
402            out.println("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
403            out.println("<!DOCTYPE workflow PUBLIC \"-//OpenSymphony Group//DTD OSWorkflow 2.8//EN\" \"http://www.opensymphony.com/osworkflow/workflow_2_8.dtd\">");
404            workflowDescriptor.writeXML(out, 0);
405        }
406    }
407
408    /**
409     * Get multilingual labels for current workflow
410     * @param workflowName name of current workflow
411     * @return a map of labels, key is language and value is translation
412     */
413    protected Map<String, String> _getWorkflowLabels(String workflowName)
414    {
415        WorkflowDescriptor workflowDescriptor = _workflowSessionHelper.getWorkflowDescriptor(workflowName, false);
416        _workflowRightHelper.checkEditRight(workflowDescriptor);
417        Map<String, String> workflowLabelTranslations = _workflowSessionHelper.getWorkflowLabelTranslations(workflowName);
418        if (workflowLabelTranslations.isEmpty() && ArrayUtils.contains(_workflowHelper.getWorkflowNames(), workflowName))
419        {
420            I18nizableText workflowI18nLabelKey = _workflowHelper.getWorkflowLabel(workflowName);
421            for (String language : _workflowLanguageManager.getLanguages())
422            {
423                workflowLabelTranslations.put(language, StringUtils.defaultString(_i18nUtils.translate(workflowI18nLabelKey, language)));
424            }
425        }
426        return workflowLabelTranslations;
427    }
428    
429    /**
430     * Get workflow infos 
431     * @param workflowName the name of the  workflow
432     * @return a map of the list of workflow names and the workflow's labels
433     */
434    @Callable(rights = {"Workflow_Right_Edit", "Workflow_Right_Edit_User"})
435    public Map<String, Object> getWorkflowInfos(String workflowName)
436    {
437        Map<String, Object> workflowInfos = new HashMap<>();
438        workflowInfos.put("workflowNames",  _workflowSessionHelper.getWorkflowNames());
439        if (StringUtils.isNotBlank(workflowName))
440        {
441            workflowInfos.put("labels", _getWorkflowLabels(workflowName));
442        }
443        return workflowInfos;
444    }
445    
446    /**
447     * Create a new workflow 
448     * @param labels the multilingual labels
449     * @param id the unique name 
450     * @return map of error message or workflow name if creation went well
451     */
452    @Callable(rights = Callable.SKIP_BUILTIN_CHECK)
453    public Map<String, Object> createWorkflow(Map<String, String> labels, String id)
454    {
455        _workflowRightHelper.checkEditRight();
456        Set<String> workflowNames = _workflowSessionHelper.getWorkflowNames();
457        if (workflowNames.contains(id))
458        {
459            return Map.of("message", "duplicate-id");
460        }
461        
462        //create workflow descriptor
463        DescriptorFactory factory = new DescriptorFactory();
464        WorkflowDescriptor workflowDescriptor = factory.createWorkflowDescriptor();
465        workflowDescriptor.setName(id);
466        workflowDescriptor.getMetaAttributes().put("user", true);
467        
468        //add meta to new workflow to prevent reinit
469        workflowDescriptor.getMetaAttributes().put(META_NEW_WORKFLOW, true);
470        
471        //save workflow in session
472        _workflowSessionHelper.updateWorkflowDescriptor(workflowDescriptor); 
473        _workflowSessionHelper.updateWorkflowNames(workflowDescriptor);
474        
475        //add workflow label translations
476        _workflowSessionHelper.updateTranslations(id, _i18nHelper.getWorkflowLabelKey(id), labels);
477        
478        return Map.of("workflowId", id);
479    }
480    
481    /**
482     * Rename the workflow
483     * @param workflowName unique name of current workflow
484     * @param labels the new multilingual labels
485     * @return the workflow name
486     */
487    @Callable(rights = Callable.SKIP_BUILTIN_CHECK)
488    public Map<String, Object> renameWorkflow(String workflowName, Map<String, String> labels)
489    {
490        WorkflowDescriptor workflowDescriptor = _workflowSessionHelper.getWorkflowDescriptor(workflowName, false);
491        _workflowRightHelper.checkEditRight(workflowDescriptor);
492        //create i18n entry
493        I18nizableText labelKey = _i18nHelper.getWorkflowLabelKey(workflowName);
494        _workflowSessionHelper.updateTranslations(workflowName, labelKey, labels);
495        return Map.of("workflowId", workflowName);
496    }
497    
498    /**
499     * Duplicate a workflow
500     * @param workflowName the workflow to duplicate's name
501     * @param labels the new labels for the workflow
502     * @param duplicateId the new id for the clone
503     * @return map of results
504     */
505    @Callable(rights = Callable.SKIP_BUILTIN_CHECK)
506    public Map<String, Object> duplicateWorkflow(String workflowName, Map<String, String> labels, String duplicateId)
507    {
508        _workflowRightHelper.checkEditRight();
509        Set<String> workflowNames = _workflowSessionHelper.getWorkflowNames();
510        if (workflowNames.contains(workflowName))
511        {
512            return Map.of("message", "duplicate-id");
513        }
514        
515        WorkflowDescriptor workflowDescriptor = _workflowSessionHelper.getWorkflowDescriptor(duplicateId, false);
516        if (workflowDescriptor.getMetaAttributes().containsKey(META_NEW_WORKFLOW))
517        {
518            return Map.of("message", "no-save");
519        }
520        
521        _workflowSessionHelper.duplicateWorkflow(workflowName, labels, duplicateId);
522        
523        return Map.of("workflowId", workflowName);
524    }
525
526}