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