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.CHECKED_BY_IMPLEMENTATION)
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.CHECKED_BY_IMPLEMENTATION)
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.CHECKED_BY_IMPLEMENTATION)
201    public Map<String, Object> saveChanges(String workflowName)
202    {
203        WorkflowDescriptor workflowDescriptor = _workflowSessionHelper.getWorkflowDescriptor(workflowName, false);
204        
205        // Check user right
206        _workflowRightHelper.checkEditRight(workflowDescriptor);
207        
208        Map<String, Object> results = new HashMap<>();
209        
210        try
211        {
212            // Write new workflow
213            if (!_setErrors(workflowDescriptor, results))
214            {
215                workflowDescriptor.getMetaAttributes().remove(META_NEW_WORKFLOW);
216                _writeWorklowFile(workflowName, workflowDescriptor);
217                
218                // Write new i18n
219                _writeI18nTranslations(workflowName, _workflowSessionHelper.getTranslations(workflowName));
220                
221                _workflowSessionHelper.cloneImages(workflowName);
222                _workflowSessionHelper.deleteSession(workflowName);
223                _workflowDefinitionEP.addOrUpdateExtension(workflowName);
224                
225                _i18nHelper.clearCaches();
226                
227                Map<String, Object> params = new HashMap<>();
228                params.put(ObservationConstants.ARGS_WORKFLOW_NAME, workflowName);
229                _observationManager.notify(new Event(ObservationConstants.EVENT_WORKFLOW_SAVED, _currentUserProvider.getUser(), params));
230            }
231            results.put("workflowId", workflowName);
232        }
233        catch (FileNotFoundException e)
234        {
235            results.put("message", "file-not-found");
236            getLogger().error("An error occured while overwriting workflow file: {}", workflowName, e);
237        }
238        catch (Exception e)
239        {
240            results.put("message", "sax-error");
241            getLogger().error("An error occured while saxing i18n catalogs file", e);
242        }
243        return results;
244    }
245    
246    /**
247     * Restore last version of current workflow if exist
248     * @param workflowName name of current workflow
249     * @return map of result
250     */
251    @Callable(rights = Callable.CHECKED_BY_IMPLEMENTATION)
252    public Map<String, Object> reinit(String workflowName)
253    {
254        WorkflowDescriptor workflowDescriptor = _workflowSessionHelper.getWorkflowDescriptor(workflowName, false);
255        
256        // // Check user right
257        _workflowRightHelper.checkEditRight(workflowDescriptor);
258        
259        if (workflowDescriptor.getMetaAttributes().containsKey(META_NEW_WORKFLOW))
260        {
261            return Map.of("message", "new_workflow");
262        }
263        _workflowSessionHelper.deleteSession(workflowName);
264        return Map.of("workflowId", workflowName);
265    }
266    
267    /**
268     * Check for invalid components in workflow, return true if there is any
269     * @param workflowDescriptor the workflow to check
270     * @param results a map to fill with error message and invalid component's labels
271     * @return true if there are errors
272     */
273    protected boolean _setErrors(WorkflowDescriptor workflowDescriptor, Map<String, Object> results)
274    {
275        if (workflowDescriptor.getInitialActions().isEmpty())
276        {
277            results.put("message", "empty-initials-actions");
278            return true;
279        }
280        Set<Integer> transitionIds = _workflowHelper.getAllActions(workflowDescriptor);
281        return _hasEmptyOperator(workflowDescriptor, transitionIds, results) || _hasEmptyConditionalResult(workflowDescriptor, results, transitionIds);
282    }
283    
284    /**
285     * Check for result without condition or operators without conditions
286     * @param workflowDescriptor the workflow to check
287     * @param results a map to fill with error message and invalid transition's labels
288     * @param transitionIds a list of all the workflow's transitions ids
289     * @return true if there are invalid conditional results
290     */
291    protected boolean _hasEmptyConditionalResult(WorkflowDescriptor workflowDescriptor, Map<String, Object> results, Set<Integer> transitionIds)
292    {
293        Set<String> invalidTransitions = new HashSet<>();
294        for (Integer id : transitionIds)
295        {
296            ActionDescriptor action = workflowDescriptor.getAction(id);
297            String actionLabel = _workflowTransitionDAO.getActionLabel(workflowDescriptor.getName(), action) + "(" + action.getId() + ")";
298            
299            List<ConditionalResultDescriptor> conditionalResults = action.getConditionalResults();
300            for (ConditionalResultDescriptor resultDescriptor : conditionalResults)
301            {
302                List<ConditionsDescriptor> conditions = resultDescriptor.getConditions();
303                if (conditions.isEmpty())
304                {
305                    results.put("message", "empty-conditionnal-result");
306                    invalidTransitions.add(actionLabel);
307                }
308                else if (_hasOperatorWithoutChild(conditions.get(0)))
309                {
310                    results.put("message", "empty-result-operator");
311                    invalidTransitions.add(actionLabel);
312                }
313            }
314        }
315        
316        results.put("invalidTransitions", invalidTransitions.isEmpty() ? null : invalidTransitions);
317        return results.get("message") != null;
318    }
319    
320    /**
321     * Check for operators without conditions
322     * @param workflowDescriptor the workflow to check
323     * @param transitionIds a list of all the workflow's transitions ids
324     * @param results a map to fill with error message and invalid transition's labels
325     * @return true if there are operator without conditions
326     */
327    protected boolean _hasEmptyOperator(WorkflowDescriptor workflowDescriptor, Set<Integer> transitionIds, Map<String, Object> results)
328    {
329        Set<String> invalidTransitions = new HashSet<>();
330        for (Integer id : transitionIds)
331        {
332            ActionDescriptor action = workflowDescriptor.getAction(id);
333            if (action != null) // case in kernel workflow where an action is declared but never used
334            {
335                String actionLabel = _i18nHelper.translateKey(workflowDescriptor.getName(), new I18nizableText("application", action.getName()), WorkflowTransitionDAO.DEFAULT_ACTION_NAME) + " (" + action.getId() + ")";
336                RestrictionDescriptor restriction = action.getRestriction();
337                if (restriction != null)
338                {
339                    ConditionsDescriptor rootOperator = restriction.getConditionsDescriptor();
340                    if (_hasOperatorWithoutChild(rootOperator))
341                    {
342                        invalidTransitions.add(actionLabel);
343                    }
344                }
345            }
346        }
347        if (!invalidTransitions.isEmpty())
348        {
349            results.put("message", "empty-condition-operator");
350            results.put("invalidTransitions", invalidTransitions);
351            return true;
352        }
353        return false;
354    }
355
356    private boolean _hasOperatorWithoutChild(ConditionsDescriptor rootOperator)
357    {
358        List<AbstractDescriptor> conditions = rootOperator.getConditions();
359        if (conditions.isEmpty())
360        {
361            return true;
362        }
363        else
364        {
365            int i = 0;
366            boolean hasChildFreeOperator = false;
367            while (i < conditions.size() && !hasChildFreeOperator)
368            {
369                if (conditions.get(i) instanceof ConditionsDescriptor operator)
370                {
371                    hasChildFreeOperator = _hasOperatorWithoutChild(operator);
372                }
373                i++;
374            }
375            return hasChildFreeOperator;
376        }
377    }
378    
379    private void _writeI18nTranslations(String workflowName, Map<I18nizableText, Map<String, String>> translations) throws Exception
380    {
381        if (!translations.isEmpty())
382        {
383            // Write new i18n messages in application 
384            String workflowCatalog = _workflowHelper.getWorkflowCatalog(workflowName);
385            Map<String, Map<I18nizableText, String>> newI18nCatalogs = _i18nHelper.createNewI18nCatalogs(translations);
386            _i18nHelper.saveCatalogs(newI18nCatalogs, workflowCatalog);
387        }
388    }
389    
390    private void _writeWorklowFile(String workflowName, WorkflowDescriptor workflowDescriptor) throws IOException
391    {
392        File workflowFile = new File(_workflowHelper.getParamWorkflowDir(), workflowName + ".xml");
393        
394        // Save the workflow file if it already exists
395        if (workflowFile.exists())
396        {
397            FileUtils.copyFile(workflowFile, new File(workflowFile + ".bak"), StandardCopyOption.REPLACE_EXISTING);
398        }
399        // Otherwise, create the file and its parents if necessary
400        else
401        {
402            FileUtils.createParentDirectories(workflowFile);
403            workflowFile.createNewFile();
404        }
405        
406        try (PrintWriter out = new PrintWriter(workflowFile, StandardCharsets.UTF_8))
407        {
408            out.println("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
409            out.println("<!DOCTYPE workflow PUBLIC \"-//OpenSymphony Group//DTD OSWorkflow 2.8//EN\" \"http://www.opensymphony.com/osworkflow/workflow_2_8.dtd\">");
410            workflowDescriptor.writeXML(out, 0);
411        }
412    }
413
414    /**
415     * Get multilingual labels for current workflow
416     * @param workflowName name of current workflow
417     * @return a map of labels, key is language and value is translation
418     */
419    protected Map<String, String> _getWorkflowLabels(String workflowName)
420    {
421        WorkflowDescriptor workflowDescriptor = _workflowSessionHelper.getWorkflowDescriptor(workflowName, false);
422        _workflowRightHelper.checkEditRight(workflowDescriptor);
423        Map<String, String> workflowLabelTranslations = _workflowSessionHelper.getWorkflowLabelTranslations(workflowName);
424        if (workflowLabelTranslations.isEmpty() && ArrayUtils.contains(_workflowHelper.getWorkflowNames(), workflowName))
425        {
426            I18nizableText workflowI18nLabelKey = _workflowHelper.getWorkflowLabel(workflowName);
427            for (String language : _workflowLanguageManager.getLanguages())
428            {
429                workflowLabelTranslations.put(language, StringUtils.defaultString(_i18nUtils.translate(workflowI18nLabelKey, language)));
430            }
431        }
432        return workflowLabelTranslations;
433    }
434    
435    /**
436     * Get workflow infos 
437     * @param workflowName the name of the  workflow
438     * @return a map of the list of workflow names and the workflow's labels
439     */
440    @Callable(rights = {"Workflow_Right_Edit", "Workflow_Right_Edit_User"})
441    public Map<String, Object> getWorkflowInfos(String workflowName)
442    {
443        Map<String, Object> workflowInfos = new HashMap<>();
444        workflowInfos.put("workflowNames",  _workflowSessionHelper.getWorkflowNames());
445        if (StringUtils.isNotBlank(workflowName))
446        {
447            workflowInfos.put("labels", _getWorkflowLabels(workflowName));
448        }
449        return workflowInfos;
450    }
451    
452    /**
453     * Create a new workflow 
454     * @param labels the multilingual labels
455     * @param id the unique name 
456     * @return map of error message or workflow name if creation went well
457     */
458    @Callable(rights = Callable.CHECKED_BY_IMPLEMENTATION)
459    public Map<String, Object> createWorkflow(Map<String, String> labels, String id)
460    {
461        // Check user right
462        _workflowRightHelper.checkEditRight();
463        
464        Set<String> workflowNames = _workflowSessionHelper.getWorkflowNames();
465        if (workflowNames.contains(id))
466        {
467            return Map.of("message", "duplicate-id");
468        }
469        
470        //create workflow descriptor
471        DescriptorFactory factory = new DescriptorFactory();
472        WorkflowDescriptor workflowDescriptor = factory.createWorkflowDescriptor();
473        workflowDescriptor.setName(id);
474        workflowDescriptor.getMetaAttributes().put("user", true);
475        
476        //add meta to new workflow to prevent reinit
477        workflowDescriptor.getMetaAttributes().put(META_NEW_WORKFLOW, true);
478        
479        //save workflow in session
480        _workflowSessionHelper.initWorkflowDescriptor(workflowDescriptor);
481        _workflowSessionHelper.updateWorkflowNames(workflowDescriptor);
482        
483        //add workflow label translations
484        _workflowSessionHelper.updateTranslations(id, _i18nHelper.getWorkflowLabelKey(id), labels);
485        
486        return Map.of("workflowId", id);
487    }
488    
489    /**
490     * Rename the workflow
491     * @param workflowName unique name of current workflow
492     * @param labels the new multilingual labels
493     * @return the workflow name
494     */
495    @Callable(rights = Callable.CHECKED_BY_IMPLEMENTATION)
496    public Map<String, Object> renameWorkflow(String workflowName, Map<String, String> labels)
497    {
498        WorkflowDescriptor workflowDescriptor = _workflowSessionHelper.getWorkflowDescriptor(workflowName, false);
499        
500        // Check user right
501        _workflowRightHelper.checkEditRight(workflowDescriptor);
502        
503        // Create i18n entry
504        I18nizableText labelKey = _i18nHelper.getWorkflowLabelKey(workflowName);
505        _workflowSessionHelper.updateTranslations(workflowName, labelKey, labels);
506        return Map.of("workflowId", workflowName);
507    }
508    
509    /**
510     * Duplicate a workflow
511     * @param newWorkflowName the new workflow name
512     * @param labels the new labels for the workflow
513     * @param duplicatedWorkflowName the duplicated workflow name
514     * @return map of results
515     */
516    @Callable(rights = Callable.CHECKED_BY_IMPLEMENTATION)
517    public Map<String, Object> duplicateWorkflow(String newWorkflowName, Map<String, String> labels, String duplicatedWorkflowName)
518    {
519        // Check user right
520        _workflowRightHelper.checkEditRight();
521        
522        Set<String> workflowNames = _workflowSessionHelper.getWorkflowNames();
523        if (workflowNames.contains(newWorkflowName))
524        {
525            return Map.of("message", "duplicate-id");
526        }
527        
528        WorkflowDescriptor workflowDescriptor = _workflowSessionHelper.getWorkflowDescriptor(duplicatedWorkflowName, false);
529        if (workflowDescriptor.getMetaAttributes().containsKey(META_NEW_WORKFLOW))
530        {
531            return Map.of("message", "no-save");
532        }
533        
534        _workflowSessionHelper.duplicateWorkflow(newWorkflowName, labels, duplicatedWorkflowName);
535        
536        return Map.of("workflowId", newWorkflowName);
537    }
538
539}