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        // Get catalog from its parents (unecessary ?)
265        AmetysObject parent = content.getParent();
266        while (parent != null)
267        {
268            if (parent instanceof ProgramItem)
269            {
270                return ((ProgramItem) parent).getCatalog();
271            }
272            parent = parent.getParent();
273        }
274        
275        return null;
276    }
277    
278    /**
279     * Determines if the catalog can be modified from the given content
280     * @param contentId The content id
281     * @return A map with success=false if the catalog cannot be edited
282     */
283    @Callable
284    public Map<String, Object> canEditCatalog(String contentId)
285    {
286        Map<String, Object> result = new HashMap<>();
287        
288        Content content = _resolver.resolveById(contentId);
289        
290        if (content instanceof ProgramItem)
291        {
292            if (_isReferenced(content))
293            {
294                result.put("success", false);
295                result.put("error", "referenced");
296            }
297            else if (_hasSharedContent((ProgramItem) content, (ProgramItem) content))
298            {
299                result.put("success", false);
300                result.put("error", "hasSharedContent");
301            }
302            else
303            {
304                result.put("success", true);
305            }
306            
307        }
308        else
309        {
310            result.put("success", false);
311            result.put("error", "typeError");
312        }
313        
314        return result;
315    }
316    
317    private boolean _isReferenced (Content content)
318    {
319        return !_odfHelper.getParentProgramItems((ProgramItem) content).isEmpty();
320    }
321    
322    private boolean _hasSharedContent (ProgramItem rootProgramItem, ProgramItem programItem)
323    {
324        List<ProgramItem> children = _odfHelper.getChildProgramItems(programItem);
325        
326        for (ProgramItem child : children)
327        {
328            if (_isShared(rootProgramItem, child))
329            {
330                return true;
331            }
332        }
333        
334        if (programItem instanceof Course)
335        {
336            List<CoursePart> courseParts = ((Course) programItem).getCourseParts();
337            for (CoursePart coursePart : courseParts)
338            {
339                List<ProgramItem> parentCourses = coursePart.getCourses()
340                        .stream()
341                        .map(ProgramItem.class::cast)
342                        .collect(Collectors.toList());
343                if (parentCourses.size() > 1 && !_isPartOfSameStructure(rootProgramItem, parentCourses))
344                {
345                    return true;
346                }
347            }
348        }
349        
350        return false;
351    }
352    
353    private boolean _isShared(ProgramItem rootProgramItem, ProgramItem programItem)
354    {
355        try
356        {
357            List<ProgramItem> parents = _odfHelper.getParentProgramItems(programItem);
358            if (parents.size() > 1 && !_isPartOfSameStructure(rootProgramItem, parents)
359                || _hasSharedContent(rootProgramItem, programItem))
360            {
361                return true;
362            }
363        }
364        catch (UnknownAmetysObjectException e)
365        {
366            // Nothing
367        }
368        
369        return false;
370    }
371    
372    private boolean _isPartOfSameStructure(ProgramItem rootProgramItem, List<ProgramItem> programItems)
373    {
374        for (ProgramItem programItem : programItems)
375        {
376            boolean isPartOfInitalStructure = false;
377            
378            List<EducationalPath> ancestorPaths = _odfHelper.getEducationalPaths(programItem);
379            
380            for (EducationalPath ancestorPath : ancestorPaths)
381            {
382                isPartOfInitalStructure = ancestorPath.resolveProgramItems(_resolver).anyMatch(p -> p.equals(rootProgramItem));
383                break;
384            }
385            
386            if (!isPartOfInitalStructure)
387            {
388                // The content is shared outside the program item to edit
389                return false;
390            }
391        }
392        
393        return true;
394    }
395    
396    /**
397     * Set the catalog of a content. This will modify recursively the catalog of referenced children
398     * @param catalog The catalog
399     * @param contentId The id of content to edit
400     * @throws WorkflowException if an error occurred
401     */
402    @Callable
403    public void setContentCatalog(String catalog, String contentId) throws WorkflowException
404    {
405        Content content = _resolver.resolveById(contentId);
406        
407        if (content instanceof ProgramItem)
408        {
409            _setCatalog(content, catalog);
410        }
411        else
412        {
413            throw new IllegalArgumentException("You can not edit the catalog of the content " + contentId);
414        }
415    }
416    
417    private void _setCatalog (Content content, String catalogName) throws WorkflowException
418    {
419        if (content instanceof ProgramItem)
420        {
421            String oldCatalog = ((ProgramItem) content).getCatalog();
422            if (!catalogName.equals(oldCatalog))
423            {
424                ((ProgramItem) content).setCatalog(catalogName);
425                
426                if (content instanceof WorkflowAwareContent)
427                {
428                    _applyChanges((WorkflowAwareContent) content);
429                }
430            }
431        }
432        else if (content instanceof CoursePart)
433        {
434            String oldCatalog = ((CoursePart) content).getCatalog();
435            if (!catalogName.equals(oldCatalog))
436            {
437                ((CoursePart) content).setCatalog(catalogName);
438                
439                if (content instanceof WorkflowAwareContent)
440                {
441                    _applyChanges((WorkflowAwareContent) content);
442                }
443            }
444        }
445        
446        _setCatalogToChildren(content, catalogName);
447    }
448    
449    private void _setCatalogToChildren (Content content, String catalogName) throws WorkflowException
450    {
451        if (content instanceof TraversableProgramPart)
452        {
453            ContentValue[] children = content.getValue(TraversableProgramPart.CHILD_PROGRAM_PARTS, false, new ContentValue[0]);
454            for (ContentValue child : children)
455            {
456                try
457                {
458                    _setCatalog(child.getContent(), catalogName);
459                }
460                catch (UnknownAmetysObjectException e)
461                {
462                    // Nothing
463                }
464            }
465        }
466        
467        if (content instanceof CourseContainer)
468        {
469            for (Course course : ((CourseContainer) content).getCourses())
470            {
471                _setCatalog(course, catalogName);
472            }
473        }
474        
475        if (content instanceof CourseListContainer)
476        {
477            for (CourseList cl : ((CourseListContainer) content).getCourseLists())
478            {
479                _setCatalog(cl, catalogName);
480            }
481        }
482        
483        if (content instanceof Course)
484        {
485            for (CoursePart coursePart : ((Course) content).getCourseParts())
486            {
487                _setCatalog(coursePart, catalogName);
488            }
489        }
490    }
491    
492    private void _applyChanges(WorkflowAwareContent content) throws WorkflowException
493    {
494        ((ModifiableDefaultContent) content).setLastContributor(_userProvider.getUser());
495        ((ModifiableDefaultContent) content).setLastModified(ZonedDateTime.now());
496        
497        // Remove the proposal date.
498        content.setProposalDate(null);
499        
500        // Save changes
501        content.saveChanges();
502        
503        // Notify listeners
504        Map<String, Object> eventParams = new HashMap<>();
505        eventParams.put(ObservationConstants.ARGS_CONTENT, content);
506        eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId());
507        _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_MODIFIED, _userProvider.getUser(), eventParams));
508       
509        _contentWorkflowHelper.doAction(content, 22);
510    }
511    
512    /**
513     * Get the root catalogs storage object.
514     * @return the root catalogs node
515     * @throws AmetysRepositoryException if a repository error occurs.
516     */
517    public ModifiableTraversableAmetysObject getCatalogsRootNode() throws AmetysRepositoryException
518    {
519        String originalWorkspace = null;
520        Request request = ContextHelper.getRequest(_context);
521        try
522        {
523            originalWorkspace = RequestAttributeWorkspaceSelector.getForcedWorkspace(request);
524            if (ArchiveConstants.ARCHIVE_WORKSPACE.equals(originalWorkspace))
525            {
526                RequestAttributeWorkspaceSelector.setForcedWorkspace(request, RepositoryConstants.DEFAULT_WORKSPACE);
527            }
528            
529            ModifiableTraversableAmetysObject rootNode = _resolver.resolveByPath("/");
530            ModifiableTraversableAmetysObject pluginsNode = _getOrCreateNode(rootNode, "ametys:plugins", "ametys:unstructured");
531            ModifiableTraversableAmetysObject pluginNode = _getOrCreateNode(pluginsNode, _pluginName, "ametys:unstructured");
532            
533            return _getOrCreateNode(pluginNode, "catalogs", "ametys:unstructured");
534        }
535        catch (AmetysRepositoryException e)
536        {
537            throw new AmetysRepositoryException("Unable to get the ODF catalogs root node", e);
538        }
539        finally
540        {
541            if (ArchiveConstants.ARCHIVE_WORKSPACE.equals(originalWorkspace))
542            {
543                RequestAttributeWorkspaceSelector.setForcedWorkspace(request, originalWorkspace);
544            }
545        }
546    }
547    
548    /**
549     * Create a new catalog
550     * @param name The unique name
551     * @param title The title of catalog
552     * @return the created catalog
553     */
554    public Catalog createCatalog(String name, String title)
555    {
556        Catalog newCatalog = null;
557        
558        ModifiableTraversableAmetysObject catalogsNode = getCatalogsRootNode();
559        
560        newCatalog = catalogsNode.createChild(name, "ametys:catalog");
561        newCatalog.setTitle(title);
562        
563        if (getCatalogs().size() == 1)
564        {
565            newCatalog.setDefault(true);
566        }
567        
568        newCatalog.saveChanges();
569        
570        return newCatalog;
571    }
572    
573    /**
574     * Get the programs of a catalog for all languages
575     * @param catalog The code of catalog
576     * @return The programs
577     */
578    public AmetysObjectIterable<Program> getPrograms (String catalog)
579    {
580        return getPrograms(catalog, null);
581    }
582    
583    /**
584     * Get the program's items of a catalog for all languages
585     * @param catalog The code of catalog
586     * @return The {@link ProgramItem}
587     */
588    private AmetysObjectIterable<Content> _getProgramItems(String catalog)
589    {
590        return _getContentsInCatalog(catalog, ProgramItem.PROGRAM_ITEM_CONTENT_TYPE);
591    }
592
593    /**
594     * Get the contents of a content type in a catalog.
595     * @param <T> The type of the elements to get
596     * @param catalog The catalog name
597     * @param contentTypeId The content type identifier
598     * @return An iterable of contents with given content type in the catalog
599     */
600    private <T extends Content> AmetysObjectIterable<T> _getContentsInCatalog(String catalog, String contentTypeId)
601    {
602        List<Expression> exprs = new ArrayList<>();
603        
604        exprs.add(_cTypeEP.createHierarchicalCTExpression(contentTypeId));
605        exprs.add(new StringExpression(ProgramItem.CATALOG, Operator.EQ, catalog));
606        
607        Expression expression = new AndExpression(exprs.toArray(Expression[]::new));
608        
609        String query = ContentQueryHelper.getContentXPathQuery(expression);
610        return _resolver.query(query);
611    }
612    
613    /**
614     * Get the programs of a catalog
615     * @param catalog The code of catalog
616     * @param lang The language. Can be null to get programs for all languages
617     * @return The programs
618     */
619    public AmetysObjectIterable<Program> getPrograms (String catalog, String lang)
620    {
621        List<Expression> exprs = new ArrayList<>();
622        exprs.add(new ContentTypeExpression(Operator.EQ, ProgramFactory.PROGRAM_CONTENT_TYPE));
623        exprs.add(new StringExpression(ProgramItem.CATALOG, Operator.EQ, catalog));
624        if (lang != null)
625        {
626            exprs.add(new LanguageExpression(Operator.EQ, lang));
627        }
628        
629        Expression programsExpression = new AndExpression(exprs.toArray(Expression[]::new));
630        
631        // Add sort criteria to get size
632        SortCriteria sortCriteria = new SortCriteria();
633        sortCriteria.addCriterion(Content.ATTRIBUTE_TITLE, true, true);
634        
635        String programsQuery = QueryHelper.getXPathQuery(null, ProgramFactory.PROGRAM_NODETYPE, programsExpression, sortCriteria);
636        return _resolver.query(programsQuery);
637    }
638    
639    /**
640     * Copy the programs and its hierarchy from a catalog to another.
641     * The referenced courses are NOT copied.
642     * @param catalog The new catalog to populate
643     * @param catalogToCopy The catalog from which we copy the programs.
644     * @param progressionTracker the progression tracker for catalog copy
645     * @throws ProcessingException If an error occurred during copy
646     */
647    public void copyCatalog(Catalog catalog, Catalog catalogToCopy, ContainerProgressionTracker progressionTracker) throws ProcessingException
648    {
649        String catalogToCopyName = catalogToCopy.getName();
650        String catalogName = catalog.getName();
651        String [] handledEventIds = new String[] {ObservationConstants.EVENT_CONTENT_ADDED, ObservationConstants.EVENT_CONTENT_MODIFIED,  ObservationConstants.EVENT_CONTENT_WORKFLOW_CHANGED};
652        try
653        {
654            Map<Content, Content> copiedContents = new HashMap<>();
655            
656            Set<String> copyUpdaters = _copyUpdaterEP.getExtensionsIds();
657            
658            AmetysObjectIterable<Program> programs = getPrograms(catalogToCopyName);
659            
660            SimpleProgressionTracker copyStep = (SimpleProgressionTracker) progressionTracker.getCurrentStep();
661            copyStep.setSize(programs.getSize());
662            
663            // Do NOT commit yet to Solr in order to improve perfs
664            _solrIndexHelper.pauseSolrCommitForEvents(handledEventIds);
665            long start = System.currentTimeMillis();
666            
667            getLogger().debug("Begin to iterate over programs for copying them");
668            
669            for (Program program : programs)
670            {
671                if (getLogger().isDebugEnabled())
672                {
673                    getLogger().debug("Start copying program '{}' (name: '{}', title: '{}')...", program.getId(), program.getName(), program.getTitle());
674                }
675                
676                _odfHelper.copyProgramItem(program, catalogName, true, copiedContents);
677                copyStep.increment();
678            }
679
680            SimpleProgressionTracker updatesAfterCopy = (SimpleProgressionTracker) progressionTracker.getCurrentStep();
681            updatesAfterCopy.setSize(copyUpdaters.size());
682            
683            for (String updaterId : copyUpdaters)
684            {
685                // Call updaters after full copy of catalog
686                CopyCatalogUpdater updater = _copyUpdaterEP.getExtension(updaterId);
687                updater.updateContents(catalogToCopyName, catalogName, copiedContents, null);
688                updater.copyAdditionalContents(catalogToCopyName, catalogName);
689                
690                updatesAfterCopy.increment();
691            }
692
693            // Workflow
694            _addCopyStep(copiedContents.values(), (SimpleProgressionTracker) progressionTracker.getCurrentStep());
695            
696            if (getLogger().isDebugEnabled())
697            {
698                getLogger().debug("End of iteration over programs for copying them ({})", Duration.of((System.currentTimeMillis() - start) / 1000, ChronoUnit.SECONDS));
699            }
700            
701        }
702        catch (AmetysRepositoryException | WorkflowException e)
703        {
704            getLogger().error("Copy of items of catalog {} into catalog {} has failed", catalogToCopyName, catalogName);
705            throw new ProcessingException("Failed to copy catalog", e);
706        }
707        finally
708        {
709            SimpleProgressionTracker restartCommitStep = (SimpleProgressionTracker) progressionTracker.getCurrentStep();
710            restartCommitStep.setSize(1);
711            _solrIndexHelper.restartSolrCommitForEvents(handledEventIds);
712            restartCommitStep.increment();
713        }
714    }
715
716    private void _addCopyStep(Collection<Content> contents, SimpleProgressionTracker progressionTracker) throws AmetysRepositoryException, WorkflowException
717    {
718        progressionTracker.setSize(contents.size());
719        
720        for (Content content : contents)
721        {
722            if (content instanceof WorkflowAwareContent workflowAwareContent)
723            {
724                _contentWorkflowHelper.doAction(workflowAwareContent, getCopyActionId());
725            }
726            progressionTracker.increment();
727        }
728    }
729    
730    /**
731     * Get the workflow action id for copy.
732     * @return The workflow action id
733     */
734    protected int getCopyActionId()
735    {
736        return 210;
737    }
738    
739    /**
740     * Delete catalog
741     * @param catalog the catalog to delete
742     * @return the result map
743     */
744    public Map<String, Object> deleteCatalog(Catalog catalog)
745    {
746        Map<String, Object> result = new HashMap<>();
747        result.put("id", catalog.getId());
748        
749        String catalogName = catalog.getName();
750        List<Content> contentsToDelete = getContents(catalogName);
751        
752        // Before deleting anything, we have to make sure that it's safe to delete the catalog and its programItems
753        List<Content> referencingContents = _getExternalReferencingContents(contentsToDelete);
754        
755        if (!referencingContents.isEmpty())
756        {
757            for (Content content : referencingContents)
758            {
759                if (content instanceof ProgramItem || content instanceof CoursePart)
760                {
761                    getLogger().error("{} '{}' ({}) is referencing a content of the catalog {} while being itself in the catalog {}. There is an inconsistency.",
762                            content.getClass().getName(),
763                            content.getTitle(),
764                            content.getValue("code"),
765                            catalogName,
766                            content.getValue("catalog", false, StringUtils.EMPTY));
767                }
768                else
769                {
770                    getLogger().warn("Content {} ({}) is referencing a content of the catalog {}. There is an inconsistency.",
771                            content.getTitle(),
772                            content.getId(),
773                            catalogName);
774                }
775            }
776            result.put("error", "referencing-contents");
777            result.put("referencingContents", referencingContents);
778            return result;
779        }
780        
781        // Everything is fine, we can delete the courseParts, the programItems and the catalog
782        String[] handledEventIds = new String[] {ObservationConstants.EVENT_CONTENT_DELETED};
783        try
784        {
785            _solrIndexHelper.pauseSolrCommitForEvents(handledEventIds);
786            contentsToDelete.forEach(this::_deleteContent);
787        }
788        finally
789        {
790            _solrIndexHelper.restartSolrCommitForEvents(handledEventIds);
791        }
792        
793        ModifiableAmetysObject parent = catalog.getParent();
794        catalog.remove();
795        parent.saveChanges();
796        
797        return result;
798    }
799    
800    /**
801     * Get all the contents to delete when deleting a catalog.
802     * @param catalogName The catalog name
803     * @return a {@link Stream} of {@link Content} to delete
804     */
805    public List<Content> getContents(String catalogName)
806    {
807        List<Content> contents = new ArrayList<>();
808        contents.addAll(_getProgramItems(catalogName).stream().toList());
809        contents.addAll(_getCourseParts(catalogName).stream().toList());
810        
811        for (String updaterId : _copyUpdaterEP.getExtensionsIds())
812        {
813            CopyCatalogUpdater updater = _copyUpdaterEP.getExtension(updaterId);
814            contents.addAll(updater.getAdditionalContents(catalogName));
815        }
816        
817        return contents;
818    }
819    
820    /**
821     * Get the course part of a catalog for all languages
822     * @param catalog The code of catalog
823     * @return The {@link CoursePart}s
824     */
825    private AmetysObjectIterable<CoursePart> _getCourseParts(String catalog)
826    {
827        return _getContentsInCatalog(catalog, CoursePartFactory.COURSE_PART_CONTENT_TYPE);
828    }
829
830    /**
831     * Get the list of contents referencing one of the {@code ProgramItem}s in the set.
832     * This method will ignore content that are included in the set and any {@code CoursePart} belonging to a {@code Course} of the set
833     * @param contentsToDelete the contents to be tested
834     * @return the contents referencing those program items excluding the course parts id
835     */
836    private List<Content> _getExternalReferencingContents(List<Content> contentsToDelete)
837    {
838        
839        // Get all the Contents referencing one of the content to delete but not one of the content to delete
840        List<Content> referencingContents = contentsToDelete.stream()
841            .map(Content::getReferencingContents)   // get the referencing contents
842            .flatMap(Collection::stream)            // flatten the Collection
843            .distinct()                             // remove all duplicates
844            .filter(content -> !contentsToDelete.contains(content))
845            .collect(Collectors.toUnmodifiableList());          // collect it into a list
846        return referencingContents;
847    }
848    
849    private void _deleteContent(Content content)
850    {
851        Map<String, Object> eventParams = new HashMap<>();
852        eventParams.put(ObservationConstants.ARGS_CONTENT, content);
853        eventParams.put(ObservationConstants.ARGS_CONTENT_NAME, content.getName());
854        eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId());
855        ModifiableAmetysObject parent = content.getParent();
856        
857        _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_DELETING, _currentUserProvider.getUser(), eventParams));
858        
859        // Remove the content.
860        LockableAmetysObject lockedContent = (LockableAmetysObject) content;
861        if (lockedContent.isLocked())
862        {
863            lockedContent.unlock();
864        }
865        
866        ((RemovableAmetysObject) content).remove();
867        
868        
869        parent.saveChanges();
870        
871        _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_DELETED, _currentUserProvider.getUser(), eventParams));
872    }
873    
874    private ModifiableTraversableAmetysObject _getOrCreateNode(ModifiableTraversableAmetysObject parentNode, String nodeName, String nodeType) throws AmetysRepositoryException
875    {
876        ModifiableTraversableAmetysObject definitionsNode;
877        if (parentNode.hasChild(nodeName))
878        {
879            definitionsNode = parentNode.getChild(nodeName);
880        }
881        else
882        {
883            definitionsNode = parentNode.createChild(nodeName, nodeType);
884            parentNode.saveChanges();
885        }
886        return definitionsNode;
887    }
888}