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.stream.Collectors;
028import java.util.stream.Stream;
029
030import org.apache.avalon.framework.component.Component;
031import org.apache.avalon.framework.context.Context;
032import org.apache.avalon.framework.context.ContextException;
033import org.apache.avalon.framework.context.Contextualizable;
034import org.apache.avalon.framework.service.ServiceException;
035import org.apache.avalon.framework.service.ServiceManager;
036import org.apache.avalon.framework.service.Serviceable;
037import org.apache.cocoon.ProcessingException;
038import org.apache.cocoon.components.ContextHelper;
039import org.apache.cocoon.environment.Request;
040import org.apache.commons.lang.StringUtils;
041
042import org.ametys.cms.ObservationConstants;
043import org.ametys.cms.content.archive.ArchiveConstants;
044import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
045import org.ametys.cms.data.ContentValue;
046import org.ametys.cms.indexing.solr.SolrIndexHelper;
047import org.ametys.cms.repository.Content;
048import org.ametys.cms.repository.ContentQueryHelper;
049import org.ametys.cms.repository.ContentTypeExpression;
050import org.ametys.cms.repository.LanguageExpression;
051import org.ametys.cms.repository.ModifiableDefaultContent;
052import org.ametys.cms.repository.WorkflowAwareContent;
053import org.ametys.cms.rights.ContentRightAssignmentContext;
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.Operator;
089import org.ametys.plugins.repository.query.expression.StringExpression;
090import org.ametys.runtime.i18n.I18nizableText;
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 (rights = Callable.NO_CHECK_REQUIRED)
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 (rights = Callable.READ_ACCESS, paramIndex = 0, rightContext = ContentRightAssignmentContext.ID)
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 (rights = "ODF_Rights_EditCatalog", paramIndex = 0, rightContext = ContentRightAssignmentContext.ID)
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 (rights = "ODF_Rights_EditCatalog", paramIndex = 1, rightContext = ContentRightAssignmentContext.ID)
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        AndExpression expression = new AndExpression();
610        expression.add(_cTypeEP.createHierarchicalCTExpression(contentTypeId));
611        expression.add(new StringExpression(ProgramItem.CATALOG, Operator.EQ, catalog));
612        
613        String query = ContentQueryHelper.getContentXPathQuery(expression);
614        return _resolver.query(query);
615    }
616    
617    /**
618     * Get the programs of a catalog
619     * @param catalog The code of catalog
620     * @param lang The language. Can be null to get programs for all languages
621     * @return The programs
622     */
623    public AmetysObjectIterable<Program> getPrograms (String catalog, String lang)
624    {
625        AndExpression expression = new AndExpression();
626        expression.add(new ContentTypeExpression(Operator.EQ, ProgramFactory.PROGRAM_CONTENT_TYPE));
627        expression.add(new StringExpression(ProgramItem.CATALOG, Operator.EQ, catalog));
628        if (lang != null)
629        {
630            expression.add(new LanguageExpression(Operator.EQ, lang));
631        }
632        
633        // Add sort criteria to get size
634        SortCriteria sortCriteria = new SortCriteria();
635        sortCriteria.addCriterion(Content.ATTRIBUTE_TITLE, true, true);
636        
637        String programsQuery = QueryHelper.getXPathQuery(null, ProgramFactory.PROGRAM_NODETYPE, expression, sortCriteria);
638        return _resolver.query(programsQuery);
639    }
640    
641    /**
642     * Copy the programs and its hierarchy from a catalog to another.
643     * The referenced courses are NOT copied.
644     * @param catalog The new catalog to populate
645     * @param catalogToCopy The catalog from which we copy the programs.
646     * @param progressionTracker the progression tracker for catalog copy
647     * @throws ProcessingException If an error occurred during copy
648     */
649    public void copyCatalog(Catalog catalog, Catalog catalogToCopy, ContainerProgressionTracker progressionTracker) throws ProcessingException
650    {
651        progressionTracker.addSimpleStep("copy-step", new I18nizableText("plugin.odf", "PLUGINS_ODF_SCHEDULABLE_COPY_CATALOG_COPY_STEP_LABEL"), 100);
652        progressionTracker.addSimpleStep("additional-copy-step", new I18nizableText("plugin.odf", "PLUGINS_ODF_SCHEDULABLE_COPY_CATALOG_ADDITIONAL_COPY_STEP_LABEL"), 10);
653        progressionTracker.addSimpleStep("updates-after-copy-step", new I18nizableText("plugin.odf", "PLUGINS_ODF_SCHEDULABLE_COPY_CATALOG_UPDATE_AFTER_COPY_STEP_LABEL"), 5);
654        progressionTracker.addSimpleStep("update-workflow-step", new I18nizableText("plugin.odf", "PLUGINS_ODF_SCHEDULABLE_COPY_CATALOG_UPDATE_WORKFLOW_STEP_LABEL"), 10);
655        progressionTracker.addSimpleStep("restart-solr-commit", new I18nizableText("plugin.odf", "PLUGINS_ODF_SCHEDULABLE_COPY_CATALOG_RESTART_SOLR_COMMIT_STEP_LABEL"), 10);
656        
657        String catalogToCopyName = catalogToCopy.getName();
658        String catalogName = catalog.getName();
659        String [] handledEventIds = new String[] {ObservationConstants.EVENT_CONTENT_ADDED, ObservationConstants.EVENT_CONTENT_MODIFIED,  ObservationConstants.EVENT_CONTENT_WORKFLOW_CHANGED};
660        try
661        {
662            List<CopyCatalogUpdater> copyUpdaters = _copyUpdaterEP.getExtensionsIds()
663                    .stream()
664                    .map(_copyUpdaterEP::getExtension)
665                    .toList();
666            
667            // Do NOT commit yet to Solr in order to improve perfs
668            _solrIndexHelper.pauseSolrCommitForEvents(handledEventIds);
669            long start = System.currentTimeMillis();
670
671            // Copy all the catalog's contents: programs and their children, and side contents associated to a catalog like thematics or skills
672            Map<Content, Content> copiedContents = new HashMap<>();
673            copiedContents.putAll(_copyPrograms(catalogToCopyName, catalogName, (SimpleProgressionTracker) progressionTracker.getCurrentStep()));
674            copiedContents.putAll(_copyAdditionalContents(copyUpdaters, catalogToCopyName, catalogName, (SimpleProgressionTracker) progressionTracker.getCurrentStep()));
675            
676            // Update the references into the copied contents or remove data should not be copied
677            _updateContents(copyUpdaters, copiedContents, catalogToCopyName, catalogName, (SimpleProgressionTracker) progressionTracker.getCurrentStep());
678            
679            // Add workflow step to each contents
680            _addCopyWorkflowStep(copiedContents.values(), (SimpleProgressionTracker) progressionTracker.getCurrentStep());
681            
682            if (getLogger().isDebugEnabled())
683            {
684                getLogger().debug("End of iteration over programs for copying them ({})", Duration.of((System.currentTimeMillis() - start) / 1000, ChronoUnit.SECONDS));
685            }
686            
687        }
688        catch (AmetysRepositoryException | WorkflowException e)
689        {
690            getLogger().error("Copy of items of catalog {} into catalog {} has failed", catalogToCopyName, catalogName);
691            throw new ProcessingException("Failed to copy catalog", e);
692        }
693        finally
694        {
695            SimpleProgressionTracker restartCommitStep = (SimpleProgressionTracker) progressionTracker.getCurrentStep();
696            restartCommitStep.setSize(1);
697            _solrIndexHelper.restartSolrCommitForEvents(handledEventIds);
698            restartCommitStep.increment();
699        }
700    }
701    
702    private Map<Content, Content> _copyPrograms(String catalogToCopyName, String catalogName, SimpleProgressionTracker progressionTracker) throws AmetysRepositoryException, WorkflowException
703    {
704        Map<Content, Content> copiedContents = new HashMap<>();
705        
706        AmetysObjectIterable<Program> programs = getPrograms(catalogToCopyName);
707        
708        progressionTracker.setSize(programs.getSize());
709        
710        getLogger().debug("Begin to iterate over programs for copying them");
711        
712        for (Program program : programs)
713        {
714            if (getLogger().isDebugEnabled())
715            {
716                getLogger().debug("Start copying program '{}' (name: '{}', title: '{}')...", program.getId(), program.getName(), program.getTitle());
717            }
718            
719            _odfHelper.copyProgramItem(program, catalogName, true, copiedContents);
720            progressionTracker.increment();
721        }
722        
723        return copiedContents;
724    }
725    
726    private Map<Content, Content> _copyAdditionalContents(Collection<CopyCatalogUpdater> copyUpdaters, String catalogToCopyName, String catalogName, SimpleProgressionTracker progressionTracker) throws AmetysRepositoryException
727    {
728        Map<Content, Content> copiedContents = new HashMap<>();
729        
730        progressionTracker.setSize(copyUpdaters.size());
731
732        for (CopyCatalogUpdater updater : copyUpdaters)
733        {
734            // Call additional contents to copy (thematics, skills, etc.)
735            copiedContents.putAll(updater.copyAdditionalContents(catalogToCopyName, catalogName));
736            
737            progressionTracker.increment();
738        }
739        
740        return copiedContents;
741    }
742    
743    private void _updateContents(Collection<CopyCatalogUpdater> copyUpdaters, Map<Content, Content> copiedContents, String catalogToCopyName, String catalogName, SimpleProgressionTracker progressionTracker) throws AmetysRepositoryException
744    {
745        progressionTracker.setSize(copyUpdaters.size());
746
747        for (CopyCatalogUpdater updater : copyUpdaters)
748        {
749            // Call additional contents to copy (thematics, skills, etc.)
750            updater.updateContents(catalogToCopyName, catalogName, copiedContents, null);
751            
752            progressionTracker.increment();
753        }
754    }
755    
756    private void _addCopyWorkflowStep(Collection<Content> contents, SimpleProgressionTracker progressionTracker) throws AmetysRepositoryException, WorkflowException
757    {
758        progressionTracker.setSize(contents.size());
759        
760        for (Content content : contents)
761        {
762            if (content instanceof WorkflowAwareContent workflowAwareContent)
763            {
764                _contentWorkflowHelper.doAction(workflowAwareContent, getCopyActionId(content));
765            }
766            progressionTracker.increment();
767        }
768    }
769    
770    /**
771     * Get the workflow action id for copy.
772     * @param content The content concerned by the copy
773     * @return The workflow action id
774     */
775    protected int getCopyActionId(Content content)
776    {
777        if (content instanceof ProgramItem || content instanceof CoursePart)
778        {
779            return 210;
780        }
781        return 222;
782    }
783    
784    /**
785     * Delete catalog
786     * @param catalog the catalog to delete
787     * @return the result map
788     */
789    public Map<String, Object> deleteCatalog(Catalog catalog)
790    {
791        Map<String, Object> result = new HashMap<>();
792        result.put("id", catalog.getId());
793        
794        String catalogName = catalog.getName();
795        List<Content> contentsToDelete = getContents(catalogName);
796        
797        // Before deleting anything, we have to make sure that it's safe to delete the catalog and its programItems
798        List<Content> referencingContents = _getExternalReferencingContents(contentsToDelete);
799        
800        if (!referencingContents.isEmpty())
801        {
802            for (Content content : referencingContents)
803            {
804                if (content instanceof ProgramItem || content instanceof CoursePart)
805                {
806                    getLogger().error("{} '{}' ({}) is referencing a content of the catalog {} while being itself in the catalog {}. There is an inconsistency.",
807                            content.getClass().getName(),
808                            content.getTitle(),
809                            content.getValue("code"),
810                            catalogName,
811                            content.getValue("catalog", false, StringUtils.EMPTY));
812                }
813                else
814                {
815                    getLogger().warn("Content {} ({}) is referencing a content of the catalog {}. There is an inconsistency.",
816                            content.getTitle(),
817                            content.getId(),
818                            catalogName);
819                }
820            }
821            result.put("error", "referencing-contents");
822            result.put("referencingContents", referencingContents);
823            return result;
824        }
825        
826        // Everything is fine, we can delete the courseParts, the programItems and the catalog
827        String[] handledEventIds = new String[] {ObservationConstants.EVENT_CONTENT_DELETED};
828        try
829        {
830            _solrIndexHelper.pauseSolrCommitForEvents(handledEventIds);
831            contentsToDelete.forEach(this::_deleteContent);
832        }
833        finally
834        {
835            _solrIndexHelper.restartSolrCommitForEvents(handledEventIds);
836        }
837        
838        ModifiableAmetysObject parent = catalog.getParent();
839        catalog.remove();
840        parent.saveChanges();
841        
842        return result;
843    }
844    
845    /**
846     * Get all the contents to delete when deleting a catalog.
847     * @param catalogName The catalog name
848     * @return a {@link Stream} of {@link Content} to delete
849     */
850    public List<Content> getContents(String catalogName)
851    {
852        List<Content> contents = new ArrayList<>();
853        contents.addAll(_getProgramItems(catalogName).stream().toList());
854        contents.addAll(_getCourseParts(catalogName).stream().toList());
855        
856        for (String updaterId : _copyUpdaterEP.getExtensionsIds())
857        {
858            CopyCatalogUpdater updater = _copyUpdaterEP.getExtension(updaterId);
859            contents.addAll(updater.getAdditionalContents(catalogName));
860        }
861        
862        return contents;
863    }
864    
865    /**
866     * Get the course part of a catalog for all languages
867     * @param catalog The code of catalog
868     * @return The {@link CoursePart}s
869     */
870    private AmetysObjectIterable<CoursePart> _getCourseParts(String catalog)
871    {
872        return _getContentsInCatalog(catalog, CoursePartFactory.COURSE_PART_CONTENT_TYPE);
873    }
874
875    /**
876     * Get the list of contents referencing one of the {@code ProgramItem}s in the set.
877     * This method will ignore content that are included in the set and any {@code CoursePart} belonging to a {@code Course} of the set
878     * @param contentsToDelete the contents to be tested
879     * @return the contents referencing those program items excluding the course parts id
880     */
881    private List<Content> _getExternalReferencingContents(List<Content> contentsToDelete)
882    {
883        
884        // Get all the Contents referencing one of the content to delete but not one of the content to delete
885        List<Content> referencingContents = contentsToDelete.stream()
886            .map(Content::getReferencingContents)   // get the referencing contents
887            .flatMap(Collection::stream)            // flatten the Collection
888            .distinct()                             // remove all duplicates
889            .filter(content -> !contentsToDelete.contains(content))
890            .collect(Collectors.toUnmodifiableList());          // collect it into a list
891        return referencingContents;
892    }
893    
894    private void _deleteContent(Content deletedContent)
895    {
896        try
897        {
898            RemovableAmetysObject content = _resolver.resolveById(deletedContent.getId());
899            
900            Map<String, Object> eventParams = new HashMap<>();
901            eventParams.put(ObservationConstants.ARGS_CONTENT, content);
902            eventParams.put(ObservationConstants.ARGS_CONTENT_NAME, content.getName());
903            eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId());
904            ModifiableAmetysObject parent = content.getParent();
905            
906            _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_DELETING, _currentUserProvider.getUser(), eventParams));
907            
908            // Remove the content.
909            LockableAmetysObject lockedContent = (LockableAmetysObject) content;
910            if (lockedContent.isLocked())
911            {
912                lockedContent.unlock();
913            }
914            
915            content.remove();
916            
917            parent.saveChanges();
918            
919            _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_DELETED, _currentUserProvider.getUser(), eventParams));
920        }
921        catch (UnknownAmetysObjectException e)
922        {
923            // Ignore, already removed
924            if (getLogger().isDebugEnabled())
925            {
926                getLogger().debug("An error occured while trying to delete the content '{}' while deleting the catalog.", deletedContent.getId(), e);
927            }
928        }
929    }
930    
931    private ModifiableTraversableAmetysObject _getOrCreateNode(ModifiableTraversableAmetysObject parentNode, String nodeName, String nodeType) throws AmetysRepositoryException
932    {
933        ModifiableTraversableAmetysObject definitionsNode;
934        if (parentNode.hasChild(nodeName))
935        {
936            definitionsNode = parentNode.getChild(nodeName);
937        }
938        else
939        {
940            definitionsNode = parentNode.createChild(nodeName, nodeType);
941            parentNode.saveChanges();
942        }
943        return definitionsNode;
944    }
945}