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