001/*
002 *  Copyright 2010 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.cms.workflow;
017
018import java.io.IOException;
019import java.time.ZoneId;
020import java.util.ArrayList;
021import java.util.Collections;
022import java.util.Comparator;
023import java.util.Date;
024import java.util.HashMap;
025import java.util.HashSet;
026import java.util.Iterator;
027import java.util.List;
028import java.util.Map;
029import java.util.Set;
030
031import javax.jcr.Node;
032import javax.jcr.RepositoryException;
033import javax.jcr.Session;
034
035import org.apache.avalon.framework.service.ServiceException;
036import org.apache.avalon.framework.service.ServiceManager;
037import org.apache.cocoon.ProcessingException;
038import org.apache.cocoon.environment.ObjectModelHelper;
039import org.apache.cocoon.environment.Request;
040import org.apache.cocoon.generation.Generator;
041import org.apache.cocoon.generation.ServiceableGenerator;
042import org.apache.cocoon.xml.AttributesImpl;
043import org.apache.cocoon.xml.XMLUtils;
044import org.apache.commons.lang.StringUtils;
045import org.xml.sax.SAXException;
046
047import org.ametys.cms.repository.Content;
048import org.ametys.cms.repository.WorkflowAwareContent;
049import org.ametys.core.user.User;
050import org.ametys.core.user.UserIdentity;
051import org.ametys.core.user.UserManager;
052import org.ametys.core.util.DateUtils;
053import org.ametys.plugins.repository.AmetysObjectResolver;
054import org.ametys.plugins.repository.version.VersionAwareAmetysObject;
055import org.ametys.plugins.workflow.store.AbstractJackrabbitWorkflowStore;
056import org.ametys.plugins.workflow.store.AmetysStep;
057import org.ametys.plugins.workflow.support.WorkflowProvider;
058import org.ametys.plugins.workflow.support.WorkflowProvider.AmetysObjectWorkflow;
059import org.ametys.runtime.i18n.I18nizableText;
060
061import com.opensymphony.workflow.loader.ActionDescriptor;
062import com.opensymphony.workflow.loader.StepDescriptor;
063import com.opensymphony.workflow.loader.WorkflowDescriptor;
064import com.opensymphony.workflow.spi.SimpleStep;
065import com.opensymphony.workflow.spi.Step;
066
067/**
068 * {@link Generator} for exposing content history and content versions.
069 */
070public class HistoryGenerator extends ServiceableGenerator
071{
072    
073    private static final I18nizableText __MESSAGE_NO_STEP = new I18nizableText("plugin.cms", "WORKFLOW_UNKNOWN_STEP");
074    
075    private static final I18nizableText __MESSAGE_NO_ACTION = new I18nizableText("plugin.cms", "WORKFLOW_UNKNOWN_ACTION");
076    
077    private Map<UserIdentity, String> _identityToFullNameCache;
078    private WorkflowProvider _workflowProvider;
079    private UserManager _userManager;
080    private AmetysObjectResolver _resolver;
081    
082    @Override
083    public void service(ServiceManager serviceManager) throws ServiceException
084    {
085        super.service(serviceManager);
086        _workflowProvider = (WorkflowProvider) serviceManager.lookup(WorkflowProvider.ROLE);
087        _userManager = (UserManager) serviceManager.lookup(UserManager.ROLE);
088        _resolver = (AmetysObjectResolver) serviceManager.lookup(AmetysObjectResolver.ROLE);
089    }
090    
091    @Override
092    public void generate() throws IOException, SAXException, ProcessingException
093    {
094        _identityToFullNameCache = new HashMap<>();
095        
096        Request request = ObjectModelHelper.getRequest(objectModel);
097        
098        String id = request.getParameter("contentId");
099        Content content = _resolver.resolveById(id);
100        
101        assert content instanceof VersionAwareAmetysObject;
102        
103        contentHandler.startDocument();
104        XMLUtils.startElement(contentHandler, "History");
105        
106        try
107        {
108            List<VersionInformation> versionsInformation = _resolveVersionInformations((VersionAwareAmetysObject) content);
109            
110            _saxVersionInformation(versionsInformation);
111            
112            AttributesImpl attrs = new AttributesImpl();
113            attrs.addCDATAAttribute("contentIsWorkflowAware", Boolean.toString(content instanceof WorkflowAwareContent));
114            
115            XMLUtils.startElement(contentHandler, "Workflow", attrs);
116            _saxContentWorkflowHistory(content, versionsInformation);
117            XMLUtils.endElement(contentHandler, "Workflow");
118        }
119        catch (RepositoryException e)
120        {
121            throw new ProcessingException("Unable to access version history", e);
122        }
123        
124        XMLUtils.endElement(contentHandler, "History");
125        contentHandler.endDocument();
126    }
127    
128    private List<VersionInformation> _resolveVersionInformations(VersionAwareAmetysObject content) throws RepositoryException
129    {
130        List<VersionInformation> versionsInformation = new ArrayList<>();
131        
132        for (String revision : content.getAllRevisions())
133        {
134            VersionInformation versionInformation = new VersionInformation(revision, content.getRevisionTimestamp(revision));
135            
136            for (String label : content.getLabels(revision))
137            {
138                versionInformation.addLabel(label);
139            }
140            
141            versionsInformation.add(versionInformation);
142        }
143        
144        // Sort by date descendant
145        Collections.sort(versionsInformation, new Comparator<VersionInformation>()
146        {
147            public int compare(VersionInformation o1, VersionInformation o2)
148            {
149                try
150                {
151                    return -o1.getCreatedAt().compareTo(o2.getCreatedAt());
152                }
153                catch (RepositoryException e)
154                {
155                    throw new RuntimeException("Unable to retrieve a creation date", e);
156                }
157            }
158        });
159        
160        // Set the version name
161        int count = versionsInformation.size();
162        for (VersionInformation versionInformation : versionsInformation)
163        {
164            versionInformation.setVersionName(String.valueOf(count--));
165        }
166        
167        return versionsInformation;
168    }
169    
170    private void _saxVersionInformation(List<VersionInformation> versionsInformation) throws SAXException, RepositoryException
171    {
172        XMLUtils.startElement(contentHandler, "Versions");
173        
174        for (VersionInformation info : versionsInformation)
175        {
176            AttributesImpl atts = new AttributesImpl();
177            
178            if (info.getVersionRawName() != null)
179            {
180                atts.addCDATAAttribute("rawName", info.getVersionRawName());
181            }
182            
183            if (info.getVersionName() != null)
184            {
185                atts.addCDATAAttribute("name", info.getVersionName());
186            }
187            
188            if (info.getCreatedAt() != null)
189            {
190                atts.addCDATAAttribute("createdAt", DateUtils.getISODateTimeFormatter().format(info.getCreatedAt().toInstant().atZone(ZoneId.systemDefault())));
191            }
192            
193            XMLUtils.createElement(contentHandler, "Version", atts);
194        }
195        
196        XMLUtils.endElement(contentHandler, "Versions");
197    }
198    
199    @SuppressWarnings("unchecked")
200    private void _saxContentWorkflowHistory(Content content, List<VersionInformation> versionsInformation) throws SAXException, ProcessingException, RepositoryException
201    {
202        if (content instanceof WorkflowAwareContent)
203        {
204            WorkflowAwareContent workflowAwareContent = (WorkflowAwareContent) content;
205            
206            long workflowId = workflowAwareContent.getWorkflowId();
207            AmetysObjectWorkflow workflow = _workflowProvider.getAmetysObjectWorkflow(workflowAwareContent);
208            
209            String workflowName = workflow.getWorkflowName(workflowId);
210            
211            if (workflowName == null)
212            {
213                throw new ProcessingException("Unknown workflow name for workflow instance id: " + workflowId);
214            }
215            
216            WorkflowDescriptor workflowDescriptor = workflow.getWorkflowDescriptor(workflowName);
217            
218            if (workflowDescriptor == null)
219            {
220                throw new ProcessingException("No workflow description for workflow name: " + workflowName);
221            }
222            
223            List<Step> allSteps = new ArrayList(workflow.getCurrentSteps(workflowId));
224            allSteps.addAll(workflow.getHistorySteps(workflowId));
225            
226            // Sort by start date descendant (only relevant when there is no split and nor join!)
227            Collections.sort(allSteps, new Comparator<Step>()
228            {
229                public int compare(Step s1, Step s2)
230                {
231                    return -s1.getStartDate().compareTo(s2.getStartDate());
232                }
233            });
234            
235            Date startDate = null;
236            
237            if (!allSteps.isEmpty())
238            {
239                // Use start date of the first step for the start date
240                // of the unstored "real first step"
241                startDate = allSteps.get(allSteps.size() - 1).getStartDate();
242            }
243            
244            // Use a custom step for the unstored "real first step"
245            int initialActionId = (int) _getInitialActionId(workflow, workflowAwareContent);
246            allSteps.add(new SimpleStep(0, 0, 0, initialActionId, null, content.getCreationDate(), startDate, null, "", new long[0], UserIdentity.userIdentityToString(content.getCreator())));
247            
248            Iterator<Step> itStep = allSteps.iterator();
249            // We got at least one
250            Step step = itStep.next();
251            
252            do
253            {
254                Step previousStep = null;
255                
256                if (itStep.hasNext())
257                {
258                    previousStep = itStep.next();
259                }
260                
261                _saxStep(workflowDescriptor, versionsInformation, step, previousStep);
262                
263                step = previousStep;
264            } while (itStep.hasNext());
265        }
266    }
267
268    private long _getInitialActionId(AmetysObjectWorkflow workflow, WorkflowAwareContent waContent)
269    {
270        try
271        {
272            Session session = waContent.getNode().getSession();
273            AbstractJackrabbitWorkflowStore workflowStore = (AbstractJackrabbitWorkflowStore) workflow.getConfiguration().getWorkflowStore();
274            Node workflowEntryNode = workflowStore.getEntryNode(session, waContent.getWorkflowId());
275            return workflowEntryNode.getProperty("ametys-internal:initialActionId").getLong();
276        }
277        catch (Exception e)
278        {
279            getLogger().error("Unable to retrieves initial action id for workflow aware content : " + waContent.getId(), e);
280            return 0;
281        }
282    }
283
284    private void _saxStep(WorkflowDescriptor workflowDescriptor, List<VersionInformation> versionsInformation, Step step, Step previousStep) throws SAXException, RepositoryException
285    {
286        AttributesImpl stepAttrs = new AttributesImpl();
287        
288        // We want the caller of the action responsible for being in the step
289        String caller = previousStep != null ? previousStep.getCaller() : null;
290        int stepId = step.getStepId();
291        // We want the id of the action responsible for being in the step
292        int actionId = previousStep != null ? previousStep.getActionId() : 0;
293        
294        // Get the action start and finish dates (here, it acts as a marker to distinguish
295        // old and new workflow step structure).
296        Date actionStartDate = null;
297        Date actionFinishDate = null;
298        if (step instanceof AmetysStep)
299        {
300            actionStartDate = (Date) ((AmetysStep) step).getProperty("actionStartDate");
301            actionFinishDate = (Date) ((AmetysStep) step).getProperty("actionFinishDate");
302        }
303        
304        Date startDate = null;
305        Date finishDate = null;
306        if (actionFinishDate != null)
307        {
308            // New format
309            startDate = step.getStartDate() != null ? step.getStartDate() : null;
310            finishDate = step.getFinishDate() != null ? step.getFinishDate() : null;
311        }
312        else
313        {
314            startDate = previousStep != null ? previousStep.getFinishDate() : null;
315            finishDate = step.getStartDate();
316        }
317        
318        if (stepId != 0)
319        {
320            stepAttrs.addCDATAAttribute("id", String.valueOf(stepId));
321        }
322        
323        if (step.getFinishDate() == null)
324        {
325            stepAttrs.addCDATAAttribute("current", Boolean.TRUE.toString());
326        }
327        
328        XMLUtils.startElement(contentHandler, "Step", stepAttrs);
329        
330        _saxStepDescription(workflowDescriptor, stepId);
331        
332        _saxCaller(UserIdentity.stringToUserIdentity(caller));
333        
334        _saxAction(actionId, workflowDescriptor);
335        
336        _saxComments(previousStep);
337        
338        _saxValidation(step, startDate, finishDate, actionStartDate, actionFinishDate);
339        
340        List<VersionInformation> versionsInsideStep = _computeVersionsBetween(versionsInformation, startDate, finishDate);
341        
342        for (VersionInformation versionInformation : versionsInsideStep)
343        {
344            AttributesImpl versionAttrs = new AttributesImpl();
345            
346            for (String label : versionInformation.getLabels())
347            {
348                versionAttrs.addCDATAAttribute(label, label);
349            }
350            if (StringUtils.isNotEmpty(versionInformation.getVersionRawName()))
351            {
352                versionAttrs.addCDATAAttribute("name", versionInformation.getVersionRawName());
353            }
354            XMLUtils.createElement(contentHandler, "Version", versionAttrs, versionInformation.getVersionName());
355        }
356        
357        XMLUtils.endElement(contentHandler, "Step");
358    }
359    
360    private void _saxStepDescription(WorkflowDescriptor workflowDescriptor, int stepId) throws SAXException
361    {
362        if (stepId != 0)
363        {
364            StepDescriptor stepDescriptor = workflowDescriptor.getStep(stepId);
365            
366            I18nizableText workflowStepName = stepDescriptor == null ? __MESSAGE_NO_STEP : new I18nizableText("application", stepDescriptor.getName());
367            
368            workflowStepName.toSAX(contentHandler, "StepName");
369            
370            String[] icons = new String[]{"-small", "-medium", "-large"};
371            for (String icon : icons)
372            {
373                if ("application".equals(workflowStepName.getCatalogue()))
374                {
375                    XMLUtils.createElement(contentHandler, "StepIcon" + icon, "/plugins/cms/resources_workflow/" + workflowStepName.getKey() + icon + ".png");
376                }
377                else
378                {
379                    String pluginName = workflowStepName.getCatalogue().substring("plugin.".length());
380                    XMLUtils.createElement(contentHandler, "StepIcon" + icon, "/plugins/" + pluginName + "/resources/img/workflow/" + workflowStepName.getKey() + icon + ".png");
381                }
382            }
383        }
384        else
385        {
386            XMLUtils.createElement(contentHandler, "StepName", "?");
387            XMLUtils.createElement(contentHandler, "StepIcon-small", "/plugins/cms/resources/img/history/workflow/step_0_16.png");
388            XMLUtils.createElement(contentHandler, "StepIcon-medium", "/plugins/cms/resources/img/history/workflow/step_0_32.png");
389            XMLUtils.createElement(contentHandler, "StepIcon-large", "/plugins/cms/resources/img/history/workflow/step_0_48.png");
390        }
391    }
392    
393    private void _saxCaller(UserIdentity caller) throws SAXException
394    {
395        if (caller != null)
396        {
397            AttributesImpl authorAttrs = new AttributesImpl();
398            authorAttrs.addCDATAAttribute("login", caller.getLogin());
399            authorAttrs.addCDATAAttribute("population", caller.getPopulationId());
400            XMLUtils.createElement(contentHandler, "Author", authorAttrs,  _resolveFullName(caller));
401        }
402    }
403    
404    private void _saxAction(int actionId, WorkflowDescriptor workflowDescriptor) throws SAXException
405    {
406        if (actionId != 0)
407        {
408            ActionDescriptor actionDescriptor = workflowDescriptor.getAction(actionId);
409            
410            I18nizableText actionLabel = actionDescriptor == null ? __MESSAGE_NO_ACTION : new I18nizableText("application", actionDescriptor.getName());
411            
412            XMLUtils.createElement(contentHandler, "ActionId", String.valueOf(actionId));
413            actionLabel.toSAX(contentHandler, "ActionName");
414        }
415        else
416        {
417            new I18nizableText("plugin.cms", "WORKFLOW_ACTION_CREATE").toSAX(contentHandler, "ActionName");
418        }
419    }
420    
421    private void _saxComments(Step previousStep) throws SAXException
422    {
423        if (previousStep != null && previousStep instanceof AmetysStep)
424        {
425            String comments = (String) ((AmetysStep) previousStep).getProperty("comment");
426            if (comments != null)
427            {
428                XMLUtils.createElement(contentHandler, "Comment", comments.replaceAll("\r?\n", "<br/>"));
429            }
430        }
431    }
432    
433    private void _saxValidation(Step step, Date startDate, Date finishDate, Date actionStartDate, Date actionFinishDate) throws SAXException
434    {
435        Boolean validation = false;
436        if (step instanceof AmetysStep)
437        {
438            validation = (Boolean) ((AmetysStep) step).getProperty("validation");
439        }
440        boolean valid = validation == null ? false : validation;
441        XMLUtils.createElement(contentHandler, "Validation", String.valueOf(valid));
442        
443        if (startDate != null)
444        {
445            XMLUtils.createElement(contentHandler, "Start", DateUtils.dateToString(startDate));
446        }
447        
448        if (finishDate != null)
449        {
450            XMLUtils.createElement(contentHandler, "Finish", DateUtils.dateToString(finishDate));
451        }
452
453        if (actionStartDate != null)
454        {
455            XMLUtils.createElement(contentHandler, "NewActionStart", DateUtils.dateToString(actionStartDate));
456        }
457        
458        if (actionFinishDate != null)
459        {
460            XMLUtils.createElement(contentHandler, "OldActionFinish", DateUtils.dateToString(actionFinishDate));
461        }
462        
463    }
464
465    private List<VersionInformation> _computeVersionsBetween(List<VersionInformation> versionsInformation, Date startDate, Date finishDate) throws RepositoryException
466    {
467        List<VersionInformation> versionsInsideStep = new ArrayList<>();
468        
469        for (VersionInformation versionInformation : versionsInformation)
470        {
471            Date versionCreationDate = versionInformation.getCreatedAt();
472            
473            if (startDate != null)
474            {
475                if (versionCreationDate.after(startDate))
476                {
477                    if (finishDate == null || versionCreationDate.before(finishDate))
478                    {
479                        versionsInsideStep.add(versionInformation);
480                    }
481                }
482            }
483            else
484            {
485                if (finishDate == null || versionCreationDate.before(finishDate))
486                {
487                    versionsInsideStep.add(versionInformation);
488                }
489            }
490        }
491        
492        // If there is no version created inside the step,
493        // retrieve the last one just before the step 
494        if (versionsInsideStep.isEmpty())
495        {
496            VersionInformation lastVersionBeforeStep = null;
497            
498            for (VersionInformation versionInformation : versionsInformation)
499            {
500                Date versionCreationDate = versionInformation.getCreatedAt();
501                
502                if (startDate != null && versionCreationDate.before(startDate))
503                {
504                    lastVersionBeforeStep = versionInformation;
505                    break;
506                }
507                else if (startDate == null)
508                {
509                    // Use the first version
510                    lastVersionBeforeStep = versionInformation;
511                }
512            }
513            
514            if (lastVersionBeforeStep != null)
515            {
516                versionsInsideStep.add(lastVersionBeforeStep);
517            }
518        }
519        
520        // if there is still no version for this step, then it is version 1 (case of migrations)
521        if (versionsInsideStep.isEmpty())
522        {
523            versionsInsideStep.add(new VersionInformation("1"));
524        }
525        
526        return versionsInsideStep;
527    }
528
529    private String _resolveFullName(UserIdentity userIdentity)
530    {
531        String fullName = _identityToFullNameCache.get(userIdentity);
532        
533        if (fullName == null)
534        {
535            User user = _userManager.getUser(userIdentity.getLogin(), userIdentity.getPopulationId());
536            
537            if (user != null)
538            {
539                fullName = user.getFullName();
540                // Cache result
541                _identityToFullNameCache.put(userIdentity, fullName);
542            }
543        }
544        
545        if (fullName == null)
546        {
547            // Not found
548            return "";
549        }
550        else
551        {
552            return fullName;
553        }
554    }
555
556    @Override
557    public void recycle()
558    {
559        super.recycle();
560        _identityToFullNameCache = null;
561    }
562    
563    private static class VersionInformation
564    {
565        private String _rawName;
566        private String _name;
567        private Date _creationDate;
568        private Set<String> _labels = new HashSet<>();
569        
570        /**
571         * Creates a {@link VersionInformation}.
572         * @param rawName the revision name.
573         * @param creationDate the revision creation date.
574         * @throws RepositoryException if an error occurs.
575         */
576        public VersionInformation(String rawName, Date creationDate)throws RepositoryException
577        {
578            _creationDate = creationDate;
579            _rawName = rawName;
580        }
581
582        /**
583         * Creates a named {@link VersionInformation}.
584         * @param name the name to use.
585         */
586        public VersionInformation(String name)
587        {
588            _name = name;
589        }
590
591        /**
592         * Set the version name
593         * @param name The name
594         */
595        public void setVersionName (String name)
596        {
597            _name = name;
598        }
599        
600        /**
601         * Retrieves the version name.
602         * @return the version name.
603         */
604        public String getVersionName()
605        {
606            return _name;
607        }
608        
609        /**
610         * Retrieves the version raw name.
611         * @return the version raw name.
612         */
613        public String getVersionRawName()
614        {
615            return _rawName;
616        }
617        
618        /**
619         * Retrieves the creation date.
620         * @return the creation date.
621         * @throws RepositoryException if an error occurs.
622         */
623        public Date getCreatedAt() throws RepositoryException
624        {
625            return _creationDate;
626        }
627        
628        /**
629         * Retrieves the labels associated with this version.
630         * @return the labels.
631         */
632        public Set<String> getLabels()
633        {
634            return _labels;
635        }
636        
637        /**
638         * Add a label to this version.
639         * @param label the label.
640         */
641        public void addLabel(String label)
642        {
643            _labels.add(label);
644        }
645    }
646}