001/*
002 *  Copyright 2014 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.odf.catalog;
017
018import java.time.Duration;
019import java.time.ZonedDateTime;
020import java.time.temporal.ChronoUnit;
021import java.util.ArrayList;
022import java.util.Collection;
023import java.util.HashMap;
024import java.util.List;
025import java.util.Map;
026import java.util.Set;
027import java.util.stream.Collectors;
028import java.util.stream.Stream;
029
030import org.apache.avalon.framework.component.Component;
031import org.apache.avalon.framework.context.Context;
032import org.apache.avalon.framework.context.ContextException;
033import org.apache.avalon.framework.context.Contextualizable;
034import org.apache.avalon.framework.service.ServiceException;
035import org.apache.avalon.framework.service.ServiceManager;
036import org.apache.avalon.framework.service.Serviceable;
037import org.apache.cocoon.ProcessingException;
038import org.apache.cocoon.components.ContextHelper;
039import org.apache.cocoon.environment.Request;
040import org.apache.commons.lang.StringUtils;
041
042import org.ametys.cms.ObservationConstants;
043import org.ametys.cms.content.archive.ArchiveConstants;
044import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
045import org.ametys.cms.data.ContentValue;
046import org.ametys.cms.indexing.solr.SolrIndexHelper;
047import org.ametys.cms.repository.Content;
048import org.ametys.cms.repository.ContentQueryHelper;
049import org.ametys.cms.repository.ContentTypeExpression;
050import org.ametys.cms.repository.LanguageExpression;
051import org.ametys.cms.repository.ModifiableDefaultContent;
052import org.ametys.cms.repository.WorkflowAwareContent;
053import org.ametys.cms.workflow.ContentWorkflowHelper;
054import org.ametys.core.observation.Event;
055import org.ametys.core.observation.ObservationManager;
056import org.ametys.core.ui.Callable;
057import org.ametys.core.user.CurrentUserProvider;
058import org.ametys.odf.ODFHelper;
059import org.ametys.odf.ProgramItem;
060import org.ametys.odf.course.Course;
061import org.ametys.odf.course.CourseContainer;
062import org.ametys.odf.courselist.CourseList;
063import org.ametys.odf.courselist.CourseListContainer;
064import org.ametys.odf.coursepart.CoursePart;
065import org.ametys.odf.coursepart.CoursePartFactory;
066import org.ametys.odf.program.Program;
067import org.ametys.odf.program.ProgramFactory;
068import org.ametys.odf.program.TraversableProgramPart;
069import org.ametys.plugins.repository.AmetysObject;
070import org.ametys.plugins.repository.AmetysObjectIterable;
071import org.ametys.plugins.repository.AmetysObjectResolver;
072import org.ametys.plugins.repository.AmetysRepositoryException;
073import org.ametys.plugins.repository.ModifiableAmetysObject;
074import org.ametys.plugins.repository.ModifiableTraversableAmetysObject;
075import org.ametys.plugins.repository.RemovableAmetysObject;
076import org.ametys.plugins.repository.RepositoryConstants;
077import org.ametys.plugins.repository.TraversableAmetysObject;
078import org.ametys.plugins.repository.UnknownAmetysObjectException;
079import org.ametys.plugins.repository.lock.LockableAmetysObject;
080import org.ametys.plugins.repository.provider.RequestAttributeWorkspaceSelector;
081import org.ametys.plugins.repository.query.QueryHelper;
082import org.ametys.plugins.repository.query.SortCriteria;
083import org.ametys.plugins.repository.query.expression.AndExpression;
084import org.ametys.plugins.repository.query.expression.Expression;
085import org.ametys.plugins.repository.query.expression.Expression.Operator;
086import org.ametys.plugins.repository.query.expression.StringExpression;
087import org.ametys.runtime.plugin.component.AbstractLogEnabled;
088import org.ametys.runtime.plugin.component.PluginAware;
089
090import com.opensymphony.workflow.WorkflowException;
091
092/**
093 * Component to handle ODF catalogs
094 */
095public class CatalogsManager extends AbstractLogEnabled implements Serviceable, Component, PluginAware, Contextualizable
096{
097    /** Avalon Role */
098    public static final String ROLE = CatalogsManager.class.getName();
099
100    private AmetysObjectResolver _resolver;
101
102    private CopyCatalogUpdaterExtensionPoint _copyUpdaterEP;
103
104    private ObservationManager _observationManager;
105
106    private CurrentUserProvider _userProvider;
107
108    private ContentWorkflowHelper _contentWorkflowHelper;
109
110    private String _pluginName;
111
112    private ODFHelper _odfHelper;
113
114    private ContentTypeExtensionPoint _cTypeEP;
115    
116    private Context _context;
117
118    private SolrIndexHelper _solrIndexHelper;
119    
120    private String _defaultCatalogId;
121
122    private CurrentUserProvider _currentUserProvider;
123
124    public void contextualize(Context context) throws ContextException
125    {
126        _context = context;
127    }
128    
129    @Override
130    public void service(ServiceManager manager) throws ServiceException
131    {
132        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
133        _copyUpdaterEP = (CopyCatalogUpdaterExtensionPoint) manager.lookup(CopyCatalogUpdaterExtensionPoint.ROLE);
134        _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE);
135        _userProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE);
136        _contentWorkflowHelper = (ContentWorkflowHelper) manager.lookup(ContentWorkflowHelper.ROLE);
137        _odfHelper = (ODFHelper) manager.lookup(ODFHelper.ROLE);
138        _cTypeEP = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE);
139        _solrIndexHelper = (SolrIndexHelper) manager.lookup(SolrIndexHelper.ROLE);
140        _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE);
141    }
142    
143    public void setPluginInfo(String pluginName, String featureName, String id)
144    {
145        _pluginName = pluginName;
146    }
147    
148    /**
149     * Get the list of catalogs
150     * @return the catalogs
151     */
152    public List<Catalog> getCatalogs()
153    {
154        List<Catalog> result = new ArrayList<>();
155
156        TraversableAmetysObject catalogsNode = getCatalogsRootNode();
157        
158        AmetysObjectIterable<Catalog> catalogs = catalogsNode.getChildren();
159        for (Catalog catalog : catalogs)
160        {
161            result.add(catalog);
162        }
163        
164        return result;
165    }
166
167    /**
168     * Get a catalog matching with the given name
169     * @param name The name
170     * @return a catalog, or null if not found
171     */
172    public Catalog getCatalog(String name)
173    {
174        ModifiableTraversableAmetysObject catalogsNode = getCatalogsRootNode();
175        
176        if (StringUtils.isNotEmpty(name) && catalogsNode.hasChild(name))
177        {
178            return catalogsNode.getChild(name);
179        }
180        
181        // Not found
182        return null;
183    }
184    
185    /**
186     * Returns the name of the default catalog
187     * @return the name of the default catalog
188     */
189    @Callable
190    public String getDefaultCatalogName()
191    {
192        Catalog defaultCatalog = getDefaultCatalog();
193        return defaultCatalog != null ? defaultCatalog.getName() : null;
194    }
195    
196    /**
197     * Returns the default catalog
198     * @return the default catalog or null if no default catalog was defined.
199     */
200    public synchronized Catalog getDefaultCatalog()
201    {
202        if (_defaultCatalogId == null)
203        {
204            updateDefaultCatalog();
205        }
206        
207        return _resolver.resolveById(_defaultCatalogId);
208    }
209    
210    /**
211     * Updates the default catalog (if it's null or if the user has updated it).
212     */
213    void updateDefaultCatalog()
214    {
215        List<Catalog> catalogs = getCatalogs();
216        for (Catalog catalog : catalogs)
217        {
218            if (catalog.isDefault())
219            {
220                _defaultCatalogId = catalog.getId();
221                return;
222            }
223        }
224        
225        // If no default catalog found, get the only catalog if it exists
226        if (catalogs.size() == 1)
227        {
228            _defaultCatalogId = catalogs.get(0).getId();
229        }
230    }
231    
232    /**
233     * Get the name of the catalog of a ODF content
234     * @param contentId The id of content
235     * @return The catalog's name
236     */
237    @Callable
238    public String getContentCatalog(String contentId)
239    {
240        Content content = _resolver.resolveById(contentId);
241        
242        if (content instanceof ProgramItem)
243        {
244            return ((ProgramItem) content).getCatalog();
245        }
246        
247        // Get catalog from its parents (unecessary ?)
248        AmetysObject parent = content.getParent();
249        while (parent != null)
250        {
251            if (parent instanceof ProgramItem)
252            {
253                return ((ProgramItem) parent).getCatalog();
254            }
255            parent = parent.getParent();
256        }
257        
258        return null;
259    }
260    
261    /**
262     * Determines if the catalog can be modified from the given content
263     * @param contentId The content id
264     * @return A map with success=false if the catalog cannot be edited
265     */
266    @Callable
267    public Map<String, Object> canEditCatalog(String contentId)
268    {
269        Map<String, Object> result = new HashMap<>();
270        
271        Content content = _resolver.resolveById(contentId);
272        
273        if (content instanceof ProgramItem)
274        {
275            if (_isReferenced(content))
276            {
277                result.put("success", false);
278                result.put("error", "referenced");
279            }
280            else if (_hasSharedContent((ProgramItem) content, (ProgramItem) content))
281            {
282                result.put("success", false);
283                result.put("error", "hasSharedContent");
284            }
285            else
286            {
287                result.put("success", true);
288            }
289            
290        }
291        else
292        {
293            result.put("success", false);
294            result.put("error", "typeError");
295        }
296        
297        return result;
298    }
299    
300    private boolean _isReferenced (Content content)
301    {
302        return !_odfHelper.getParentProgramItems((ProgramItem) content).isEmpty();
303    }
304    
305    private boolean _hasSharedContent (ProgramItem rootProgramItem, ProgramItem programItem)
306    {
307        List<ProgramItem> children = _odfHelper.getChildProgramItems(programItem);
308        
309        for (ProgramItem child : children)
310        {
311            if (_isShared(rootProgramItem, child))
312            {
313                return true;
314            }
315        }
316        
317        if (programItem instanceof Course)
318        {
319            List<CoursePart> courseParts = ((Course) programItem).getCourseParts();
320            for (CoursePart coursePart : courseParts)
321            {
322                List<ProgramItem> parentCourses = coursePart.getCourses()
323                        .stream()
324                        .map(ProgramItem.class::cast)
325                        .collect(Collectors.toList());
326                if (parentCourses.size() > 1 && !_isPartOfSameStructure(rootProgramItem, parentCourses))
327                {
328                    return true;
329                }
330            }
331        }
332        
333        return false;
334    }
335    
336    private boolean _isShared(ProgramItem rootProgramItem, ProgramItem programItem)
337    {
338        try
339        {
340            List<ProgramItem> parents = _odfHelper.getParentProgramItems(programItem);
341            if (parents.size() > 1 && !_isPartOfSameStructure(rootProgramItem, parents)
342                || _hasSharedContent(rootProgramItem, programItem))
343            {
344                return true;
345            }
346        }
347        catch (UnknownAmetysObjectException e)
348        {
349            // Nothing
350        }
351        
352        return false;
353    }
354    
355    private boolean _isPartOfSameStructure(ProgramItem rootProgramItem, List<ProgramItem> programItems)
356    {
357        for (ProgramItem programItem : programItems)
358        {
359            List<List<ProgramItem>> ancestorPaths = _odfHelper.getPathOfAncestors(programItem);
360            
361            boolean isPartOfInitalStructure = false;
362            for (List<ProgramItem> ancestorPath : ancestorPaths)
363            {
364                for (ProgramItem pathSegment : ancestorPath)
365                {
366                    if (pathSegment.equals(rootProgramItem)) 
367                    {
368                        isPartOfInitalStructure = true;
369                        break;
370                    }
371                }
372            }
373            
374            if (!isPartOfInitalStructure)
375            {
376                // The content is shared outside the program item to edit
377                return false;
378            }
379        }
380        
381        return true;
382    }
383    
384    /**
385     * Set the catalog of a content. This will modify recursively the catalog of referenced children
386     * @param catalog The catalog
387     * @param contentId The id of content to edit
388     * @throws WorkflowException if an error occurred
389     */
390    @Callable
391    public void setContentCatalog(String catalog, String contentId) throws WorkflowException
392    {
393        Content content = _resolver.resolveById(contentId);
394        
395        if (content instanceof ProgramItem)
396        {
397            _setCatalog(content, catalog);
398        }
399        else
400        {
401            throw new IllegalArgumentException("You can not edit the catalog of the content " + contentId);
402        }
403    }
404    
405    private void _setCatalog (Content content, String catalogName) throws WorkflowException
406    {
407        if (content instanceof ProgramItem)
408        {
409            String oldCatalog = ((ProgramItem) content).getCatalog();
410            if (!catalogName.equals(oldCatalog))
411            {
412                ((ProgramItem) content).setCatalog(catalogName);
413                
414                if (content instanceof WorkflowAwareContent)
415                {
416                    _applyChanges((WorkflowAwareContent) content);
417                }
418            }
419        }
420        else if (content instanceof CoursePart)
421        {
422            String oldCatalog = ((CoursePart) content).getCatalog();
423            if (!catalogName.equals(oldCatalog))
424            {
425                ((CoursePart) content).setCatalog(catalogName);
426                
427                if (content instanceof WorkflowAwareContent)
428                {
429                    _applyChanges((WorkflowAwareContent) content);
430                }
431            }
432        }
433        
434        _setCatalogToChildren(content, catalogName);
435    }
436    
437    private void _setCatalogToChildren (Content content, String catalogName) throws WorkflowException
438    {
439        if (content instanceof TraversableProgramPart)
440        {
441            ContentValue[] children = content.getValue(TraversableProgramPart.CHILD_PROGRAM_PARTS, false, new ContentValue[0]);
442            for (ContentValue child : children)
443            {
444                try
445                {
446                    _setCatalog(child.getContent(), catalogName);
447                }
448                catch (UnknownAmetysObjectException e)
449                {
450                    // Nothing
451                }
452            }
453        }
454        
455        if (content instanceof CourseContainer)
456        {
457            for (Course course : ((CourseContainer) content).getCourses())
458            {
459                _setCatalog(course, catalogName);
460            }
461        }
462        
463        if (content instanceof CourseListContainer)
464        {
465            for (CourseList cl : ((CourseListContainer) content).getCourseLists())
466            {
467                _setCatalog(cl, catalogName);
468            }
469        }
470        
471        if (content instanceof Course)
472        {
473            for (CoursePart coursePart : ((Course) content).getCourseParts())
474            {
475                _setCatalog(coursePart, catalogName);
476            }
477        }
478    }
479    
480    private void _applyChanges(WorkflowAwareContent content) throws WorkflowException
481    {
482        ((ModifiableDefaultContent) content).setLastContributor(_userProvider.getUser());
483        ((ModifiableDefaultContent) content).setLastModified(ZonedDateTime.now());
484        
485        // Remove the proposal date.
486        content.setProposalDate(null);
487        
488        // Save changes
489        content.saveChanges();
490        
491        // Notify listeners
492        Map<String, Object> eventParams = new HashMap<>();
493        eventParams.put(ObservationConstants.ARGS_CONTENT, content);
494        eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId());
495        _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_MODIFIED, _userProvider.getUser(), eventParams));
496       
497        _contentWorkflowHelper.doAction(content, 22);
498    }
499    
500    /**
501     * Get the root catalogs storage object.
502     * @return the root catalogs node
503     * @throws AmetysRepositoryException if a repository error occurs.
504     */
505    public ModifiableTraversableAmetysObject getCatalogsRootNode() throws AmetysRepositoryException
506    {
507        String originalWorkspace = null;
508        Request request = ContextHelper.getRequest(_context);
509        try
510        {
511            originalWorkspace = RequestAttributeWorkspaceSelector.getForcedWorkspace(request);
512            if (ArchiveConstants.ARCHIVE_WORKSPACE.equals(originalWorkspace))
513            {
514                RequestAttributeWorkspaceSelector.setForcedWorkspace(request, RepositoryConstants.DEFAULT_WORKSPACE);
515            }
516            
517            ModifiableTraversableAmetysObject rootNode = _resolver.resolveByPath("/");
518            ModifiableTraversableAmetysObject pluginsNode = _getOrCreateNode(rootNode, "ametys:plugins", "ametys:unstructured");
519            ModifiableTraversableAmetysObject pluginNode = _getOrCreateNode(pluginsNode, _pluginName, "ametys:unstructured");
520            
521            return _getOrCreateNode(pluginNode, "catalogs", "ametys:unstructured");
522        }
523        catch (AmetysRepositoryException e)
524        {
525            throw new AmetysRepositoryException("Unable to get the ODF catalogs root node", e);
526        }
527        finally
528        {
529            if (ArchiveConstants.ARCHIVE_WORKSPACE.equals(originalWorkspace))
530            {
531                RequestAttributeWorkspaceSelector.setForcedWorkspace(request, originalWorkspace);
532            }
533        }
534    }
535    
536    /**
537     * Create a new catalog
538     * @param name The unique name
539     * @param title The title of catalog
540     * @return the created catalog
541     */
542    public Catalog createCatalog(String name, String title)
543    {
544        Catalog newCatalog = null;
545        
546        ModifiableTraversableAmetysObject catalogsNode = getCatalogsRootNode();
547        
548        newCatalog = catalogsNode.createChild(name, "ametys:catalog");
549        newCatalog.setTitle(title);
550        
551        if (getCatalogs().size() == 1)
552        {
553            newCatalog.setDefault(true);
554        }
555        
556        newCatalog.saveChanges();
557        
558        return newCatalog;
559    }
560    
561    /**
562     * Get the programs of a catalog for all languages
563     * @param catalog The code of catalog
564     * @return The programs
565     */
566    public AmetysObjectIterable<Program> getPrograms (String catalog)
567    {
568        return getPrograms(catalog, null);
569    }
570    
571    /**
572     * Get the program's items of a catalog for all languages
573     * @param catalog The code of catalog
574     * @return The {@link ProgramItem}
575     */
576    public AmetysObjectIterable<Content> getProgramItems(String catalog)
577    {
578        return _getContentsInCatalog(catalog, ProgramItem.PROGRAM_ITEM_CONTENT_TYPE);
579    }
580
581    private <T extends Content> AmetysObjectIterable<T> _getContentsInCatalog(String catalog, String contentTypeId)
582    {
583        List<Expression> exprs = new ArrayList<>();
584        
585        exprs.add(_cTypeEP.createHierarchicalCTExpression(contentTypeId));
586        exprs.add(new StringExpression(ProgramItem.CATALOG, Operator.EQ, catalog));
587        
588        Expression expression = new AndExpression(exprs.toArray(new Expression[exprs.size()]));
589        
590        String query = ContentQueryHelper.getContentXPathQuery(expression);
591        return _resolver.query(query);
592    }
593    
594    /**
595     * Get the programs of a catalog
596     * @param catalog The code of catalog
597     * @param lang The language. Can be null to get programs for all languages
598     * @return The programs
599     */
600    public AmetysObjectIterable<Program> getPrograms (String catalog, String lang)
601    {
602        List<Expression> exprs = new ArrayList<>();
603        exprs.add(new ContentTypeExpression(Operator.EQ, ProgramFactory.PROGRAM_CONTENT_TYPE));
604        exprs.add(new StringExpression(ProgramItem.CATALOG, Operator.EQ, catalog));
605        if (lang != null)
606        {
607            exprs.add(new LanguageExpression(Operator.EQ, lang));
608        }
609        
610        Expression programsExpression = new AndExpression(exprs.toArray(new Expression[exprs.size()]));
611        
612        // Add sort criteria to get size
613        SortCriteria sortCriteria = new SortCriteria();
614        sortCriteria.addCriterion(Content.ATTRIBUTE_TITLE, true, true);
615        
616        String programsQuery = QueryHelper.getXPathQuery(null, ProgramFactory.PROGRAM_NODETYPE, programsExpression, sortCriteria);
617        return _resolver.query(programsQuery);
618    }
619    
620    /**
621     * Copy the programs and its hierarchy from a catalog to another.
622     * The referenced courses are NOT copied.
623     * @param catalog The new catalog to populate
624     * @param catalogToCopy The catalog from which we copy the programs.
625     * @throws ProcessingException If an error occurred during copy
626     */
627    public void copyCatalog(Catalog catalog, Catalog catalogToCopy) throws ProcessingException
628    {
629        String catalogToCopyName = catalogToCopy.getName();
630        String catalogName = catalog.getName();
631        String [] handledEventIds = new String[] {ObservationConstants.EVENT_CONTENT_ADDED, ObservationConstants.EVENT_CONTENT_MODIFIED,  ObservationConstants.EVENT_CONTENT_WORKFLOW_CHANGED};
632        try
633        {
634            Map<String, String> copiedPrograms = new HashMap<>();
635            Map<String, String> copiedSubPrograms = new HashMap<>();
636            Map<String, String> copiedContainers = new HashMap<>();
637            Map<String, String> copiedCourseLists = new HashMap<>();
638            Map<String, String> copiedCourses = new HashMap<>();
639            Map<String, String> copiedCourseParts = new HashMap<>();
640            
641            Set<String> copyUpdaters = _copyUpdaterEP.getExtensionsIds();
642            
643            AmetysObjectIterable<Program> programs = getPrograms(catalogToCopyName);
644            
645            // Do NOT commit yet to Solr in order to improve perfs
646            _solrIndexHelper.pauseSolrCommitForEvents(handledEventIds);
647            long start = System.currentTimeMillis();
648            
649            getLogger().debug("Begin to iterate over programs for copying them");
650            
651            for (Program program : programs)
652            {
653                if (getLogger().isDebugEnabled())
654                {
655                    getLogger().debug("Start copying program '{}' (name: '{}', title: '{}')...", program.getId(), program.getName(), program.getTitle());
656                }
657                
658                Program newProgram = _odfHelper.copyProgramItem(program, catalogName, true, copiedPrograms, copiedSubPrograms, copiedContainers, copiedCourseLists, copiedCourses, copiedCourseParts);
659                
660                for (String updaterId : copyUpdaters)
661                {
662                    // Call updaters after copy of program
663                    CopyCatalogUpdater updater = _copyUpdaterEP.getExtension(updaterId);
664                    updater.updateContent(catalogToCopyName, catalogName, program, newProgram);
665                }
666            }
667
668            for (String updaterId : copyUpdaters)
669            {
670                // Call updaters after full copy of catalog
671                CopyCatalogUpdater updater = _copyUpdaterEP.getExtension(updaterId);
672                updater.updateContents(catalogToCopyName, catalogName, copiedPrograms, copiedSubPrograms, copiedContainers, copiedCourseLists, copiedCourses, copiedCourseParts);
673            }
674            
675            // Workflow
676            _addCopyStep(copiedPrograms.values());
677            _addCopyStep(copiedSubPrograms.values());
678            _addCopyStep(copiedContainers.values());
679            _addCopyStep(copiedCourseLists.values());
680            _addCopyStep(copiedCourses.values());
681            _addCopyStep(copiedCourseParts.values());
682
683            if (getLogger().isDebugEnabled())
684            {
685                getLogger().debug("End of iteration over programs for copying them ({})", Duration.of((System.currentTimeMillis() - start) / 1000, ChronoUnit.SECONDS));
686            }
687            
688        }
689        catch (AmetysRepositoryException | WorkflowException e)
690        {
691            getLogger().error("Copy of items of catalog {} into catalog {} has failed", catalogToCopyName, catalogName);
692            throw new ProcessingException("Failed to copy catalog", e);
693        }
694        finally
695        {
696            _solrIndexHelper.restartSolrCommitForEvents(handledEventIds);
697        }
698    }
699
700    private void _addCopyStep(Collection<String> contentIds) throws AmetysRepositoryException, WorkflowException
701    {
702        for (String contentId : contentIds)
703        {
704            WorkflowAwareContent content = _resolver.resolveById(contentId);
705            _contentWorkflowHelper.doAction(content, getCopyActionId());
706        }
707    }
708    
709    /**
710     * Get the workflow action id for copy.
711     * @return The workflow action id
712     */
713    protected int getCopyActionId()
714    {
715        return 210;
716    }
717    
718    /**
719     * Delete catalog
720     * @param catalog the catalog to delete
721     * @return the result map
722     */
723    public Map<String, Object> deleteCatalog(Catalog catalog)
724    {
725        Map<String, Object> result = new HashMap<>();
726        result.put("id", catalog.getId());
727        
728        // Before deleting anything, we have to make sure that it's safe to delete the catalog and its programItems
729        String catalogName = catalog.getName();
730        AmetysObjectIterable<Content> programItems = getProgramItems(catalogName);
731        AmetysObjectIterable<CoursePart> courseParts = _getCourseParts(catalogName);
732        
733        List<Content> contentsToDelete = Stream.concat(courseParts.stream(), programItems.stream()).collect(Collectors.toUnmodifiableList());
734        
735        List<Content> referencingContents = _getExternalReferencingContents(contentsToDelete);
736        
737        if (!referencingContents.isEmpty())
738        {
739            for (Content content : referencingContents)
740            {
741                if (content instanceof ProgramItem || content instanceof CoursePart)
742                {
743                    getLogger().error("{} '{}' ({}) is referencing a content of the catalog {} while being itself in the catalog {}. There is an inconsistency.",
744                            content.getClass().getName(),
745                            content.getTitle(),
746                            content.getValue("code"),
747                            catalogName,
748                            content.getValue("catalog", false, StringUtils.EMPTY));
749                }
750                else
751                {
752                    getLogger().warn("Content {} ({}) is referencing a content of the catalog {}. There is an inconsistency.",
753                            content.getTitle(),
754                            content.getId(),
755                            catalogName);
756                }
757            }
758            result.put("error", "referencing-contents");
759            result.put("referencingContents", referencingContents);
760            return result;
761        }
762        
763        // Everything is fine, we can delete the courseParts, the programItems and the catalog
764        String[] handledEventIds = new String[] {ObservationConstants.EVENT_CONTENT_DELETED};
765        try
766        {
767            _solrIndexHelper.pauseSolrCommitForEvents(handledEventIds);
768            contentsToDelete.forEach(this::_deleteContent);
769        }
770        finally
771        {
772            _solrIndexHelper.restartSolrCommitForEvents(handledEventIds);
773        }
774        
775        ModifiableAmetysObject parent = catalog.getParent();
776        catalog.remove();
777        parent.saveChanges();
778        
779        return result;
780    }
781
782    /**
783     * Get the course part of a catalog for all languages
784     * @param catalog The code of catalog
785     * @return The {@link CoursePart}s
786     */
787    private AmetysObjectIterable<CoursePart> _getCourseParts(String catalog)
788    {
789        return _getContentsInCatalog(catalog, CoursePartFactory.COURSE_PART_CONTENT_TYPE);
790    }
791
792    /**
793     * Get the list of contents referencing one of the {@code ProgramItem}s in the set.
794     * This method will ignore content that are included in the set and any {@code CoursePart} belonging to a {@code Course} of the set
795     * @param contentsToDelete the contents to be tested
796     * @return the contents referencing those program items excluding the course parts id
797     */
798    private List<Content> _getExternalReferencingContents(List<Content> contentsToDelete)
799    {
800        
801        // Get all the Contents referencing one of the content to delete but not one of the content to delete
802        List<Content> referencingContents = contentsToDelete.stream()
803            .map(Content::getReferencingContents)   // get the referencing contents
804            .flatMap(Collection::stream)            // flatten the Collection
805            .distinct()                             // remove all duplicates
806            .filter(content -> !contentsToDelete.contains(content))
807            .collect(Collectors.toUnmodifiableList());          // collect it into a list
808        return referencingContents;
809    }
810    
811    private void _deleteContent(Content content)
812    {
813        
814        Map<String, Object> eventParams = new HashMap<>();
815        eventParams.put(ObservationConstants.ARGS_CONTENT, content);
816        eventParams.put(ObservationConstants.ARGS_CONTENT_NAME, content.getName());
817        eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId());
818        ModifiableAmetysObject parent = content.getParent();
819        
820        _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_DELETING, _currentUserProvider.getUser(), eventParams));
821        
822        // Remove the content.
823        LockableAmetysObject lockedContent = (LockableAmetysObject) content;
824        if (lockedContent.isLocked())
825        {
826            lockedContent.unlock();
827        }
828        
829        ((RemovableAmetysObject) content).remove();
830        
831        
832        parent.saveChanges();
833        
834        _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_DELETED, _currentUserProvider.getUser(), eventParams));
835    }
836    
837    private ModifiableTraversableAmetysObject _getOrCreateNode(ModifiableTraversableAmetysObject parentNode, String nodeName, String nodeType) throws AmetysRepositoryException
838    {
839        ModifiableTraversableAmetysObject definitionsNode;
840        if (parentNode.hasChild(nodeName))
841        {
842            definitionsNode = parentNode.getChild(nodeName);
843        }
844        else
845        {
846            definitionsNode = parentNode.createChild(nodeName, nodeType);
847            parentNode.saveChanges();
848        }
849        return definitionsNode;
850    }
851}