001/*
002 *  Copyright 2025 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.util.ArrayList;
019import java.util.Arrays;
020import java.util.HashMap;
021import java.util.List;
022import java.util.Map;
023import java.util.Objects;
024import java.util.stream.Stream;
025
026import org.apache.avalon.framework.service.ServiceException;
027import org.apache.avalon.framework.service.ServiceManager;
028import org.apache.avalon.framework.service.Serviceable;
029
030import org.ametys.cms.ObservationConstants;
031import org.ametys.cms.data.ContentValue;
032import org.ametys.cms.repository.Content;
033import org.ametys.cms.repository.ContentQueryHelper;
034import org.ametys.cms.repository.ContentTypeExpression;
035import org.ametys.cms.repository.DefaultContent;
036import org.ametys.cms.repository.LanguageExpression;
037import org.ametys.cms.repository.ModifiableContent;
038import org.ametys.cms.repository.ModifiableDefaultContent;
039import org.ametys.cms.workflow.ContentWorkflowHelper;
040import org.ametys.core.observation.Event;
041import org.ametys.core.observation.ObservationManager;
042import org.ametys.core.user.CurrentUserProvider;
043import org.ametys.odf.program.Program;
044import org.ametys.odf.skill.workflow.SkillEditionFunction;
045import org.ametys.plugins.repository.AmetysObjectResolver;
046import org.ametys.plugins.repository.AmetysRepositoryException;
047import org.ametys.plugins.repository.ModifiableAmetysObject;
048import org.ametys.plugins.repository.ModifiableTraversableAmetysObject;
049import org.ametys.plugins.repository.RemovableAmetysObject;
050import org.ametys.plugins.repository.jcr.NameHelper;
051import org.ametys.plugins.repository.lock.LockableAmetysObject;
052import org.ametys.plugins.repository.query.expression.AndExpression;
053import org.ametys.plugins.repository.query.expression.Expression;
054import org.ametys.plugins.repository.query.expression.Expression.Operator;
055import org.ametys.plugins.repository.query.expression.StringExpression;
056import org.ametys.runtime.plugin.component.AbstractLogEnabled;
057
058/**
059 * Copy updater to update the micro skills on a macro skill and the macro skills on a {@link Program}.
060 */
061public class SkillsCopyUpdater extends AbstractLogEnabled implements CopyCatalogUpdater, Serviceable
062{
063    private static final List<String> __SKILLS_IGNORED_ATTRIBUTES = List.of("parentMacroSkill", "microSkills", "parentProgram", "catalog");
064    
065    /** The ametys object resolver */
066    protected AmetysObjectResolver _resolver;
067    /** The observation manager */
068    protected ObservationManager _observationManager;
069    /** The current user provider */
070    protected CurrentUserProvider _currentUserProvider;
071    /** The content workflow helper */
072    protected ContentWorkflowHelper _contentWorkflowHelper;
073    
074    public void service(ServiceManager manager) throws ServiceException
075    {
076        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
077        _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE);
078        _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE);
079        _contentWorkflowHelper = (ContentWorkflowHelper) manager.lookup(ContentWorkflowHelper.ROLE);
080    }
081    
082    public void updateContents(String initialCatalogName, String newCatalogName, Map<Content, Content> copiedContents, Content targetParentContent)
083    {
084        // Do Nothing
085    }
086    
087    public List<Content> getAdditionalContents(String catalogName)
088    {
089        List<Content> results = new ArrayList<>();
090        
091        results.addAll(_getContents(catalogName, SkillEditionFunction.MICRO_SKILL_TYPE));
092        results.addAll(_getContents(catalogName, SkillEditionFunction.MACRO_SKILL_TYPE));
093        
094        return results;
095    }
096    
097    public void copyAdditionalContents(String initialCatalogName, String newCatalogName, Map<Content, Content> copiedContents)
098    {
099        // Get the skills of the catalog to copy
100        List<DefaultContent> microSkillsToCopy = _getContents(initialCatalogName, SkillEditionFunction.MICRO_SKILL_TYPE);
101        List<DefaultContent> macroSkillsToCopy = _getContents(initialCatalogName, SkillEditionFunction.MACRO_SKILL_TYPE);
102        
103        // Copy the micro skills in the new catalog
104        Map<String, Content> copiedMicroSkills = _copyMicroSkills(microSkillsToCopy, newCatalogName);
105        // Copy the macro skills in the new catalog and update the links to the copied micro skills
106        Map<String, Content> copiedMacroSkills = _copyMacroSkills(macroSkillsToCopy, newCatalogName, copiedMicroSkills);
107
108        // Update the links to the copied macro skills in programs
109        _updateContentsAfterSkillsCreation(newCatalogName, copiedContents, copiedMacroSkills);
110    }
111    
112    private Map<String, Content> _copyMacroSkills(List<DefaultContent> skills, String newCatalogName, Map<String, Content> copiedMicroSkills)
113    {
114        return _copySkills(skills, SkillEditionFunction.MACRO_SKILL_TYPE, newCatalogName, copiedMicroSkills);
115    }
116    
117    private Map<String, Content> _copyMicroSkills(List<DefaultContent> skills, String newCatalogName)
118    {
119        return _copySkills(skills, SkillEditionFunction.MICRO_SKILL_TYPE, newCatalogName, Map.of());
120    }
121    
122    private Map<String, Content> _copySkills(List<DefaultContent> skills, String contentType, String newCatalogName, Map<String, Content> copiedMicroSkills)
123    {
124        Map<String, Content> copiedSkills = new HashMap<>();
125        for (DefaultContent skill : skills)
126        {
127            // Log if the targeted catalog already contains the skill
128            if (_skillExists(skill, contentType, newCatalogName))
129            {
130                getLogger().info("A skill already exists with the same code, catalog and language [{}, {}, {}]", skill.getValue("code"), newCatalogName, skill.getLanguage());
131            }
132            // Copy the skill in the targeted catalog
133            else
134            {
135                try
136                {
137                    ModifiableContent newSkill = _createSkill(skill, newCatalogName);
138                    
139                    // If the skill is a macroSkill and has microSkills values, update the linkes to point to the new ones
140                    if (contentType.equals(SkillEditionFunction.MACRO_SKILL_TYPE) && skill.hasValue("microSkills"))
141                    {
142                        // If the content is a macroSkill and has microSkills, link the new ones that where previously copied
143                        Map<String, Object> values = new HashMap<>();
144                        ContentValue[] previousMicroSkills = skill.getValue("microSkills");
145                        List<Content> microSkills = Arrays.asList(previousMicroSkills)
146                                .stream()
147                                .filter(Objects::nonNull)
148                                .map(microSkill -> copiedMicroSkills.get(microSkill.getContentId()))
149                                .toList();
150                        
151                        values.put("microSkills", microSkills);
152                        try
153                        {
154                            _contentWorkflowHelper.editContent((ModifiableDefaultContent) newSkill, values, 2);
155                        }
156                        catch (Exception e)
157                        {
158                            // Log and rollback
159                            getLogger().error("Impossible to update skill '{}' ({}) while creating the catalog {}", newSkill.getTitle(), newSkill.getId(), newCatalogName);
160                            
161                            _deleteContent(newSkill);
162                        }
163                    }
164                    
165                    // If the skill could be created, add it to the copied skills
166                    if (newSkill != null)
167                    {
168                        copiedSkills.put(skill.getId(), newSkill);
169                    }
170                }
171                catch (AmetysRepositoryException e)
172                {
173                    getLogger().error("Impossible to create the skill '{}' ({}) while creating the catalog {}", skill.getTitle(), skill.getId(), newCatalogName, e);
174                }
175            }
176        }
177        
178        return copiedSkills;
179    }
180    
181    private ModifiableContent _createSkill(DefaultContent skill, String newCatalogName) throws AmetysRepositoryException
182    {
183        // Create the skill in the new catalog
184        ModifiableContent newSkill = skill.copyTo((ModifiableTraversableAmetysObject) skill.getParent(), NameHelper.filterName(skill.getTitle()));
185        
186        // Remove the attributes that need to be updated
187        for (String data : newSkill.getDataNames())
188        {
189            // Remove the attribute that can't be copied
190            if (__SKILLS_IGNORED_ATTRIBUTES.contains(data))
191            {
192                newSkill.removeValue(data);
193            }
194        }
195        // Set the new catalog
196        newSkill.setValue("catalog", newCatalogName);
197        newSkill.saveChanges();
198        
199        return newSkill;
200    }
201    
202    private void _updateContentsAfterSkillsCreation(String newCatalogName, Map<Content, Content> copiedContents, Map<String, Content> copiedMacroSkills)
203    {
204        // For every copied program, update its links from the original macro skills to the copied macro skills
205        for (Content copiedContent : copiedContents.values())
206        {
207            try
208            {
209                if (copiedContent instanceof Program program)
210                {
211                    ContentValue[] programOwnSkills = program.getValue("ownSkills");
212                    // Remove the skills without triggering the observer that would delete the skills
213                    program.removeValue("ownSkills");
214                    program.saveChanges();
215
216                    // If the program has own skills, update the links to target the copied macro skills
217                    if (programOwnSkills != null)
218                    {
219                        Content[] ownSkills = _getCopiedSkills(programOwnSkills, copiedMacroSkills);
220                        
221                        for (Content ownSkill : ownSkills)
222                        {
223                            ModifiableContent ownSkillModifiable = (ModifiableContent) ownSkill;
224                            // Set the parent program of the copied skills
225                            ownSkillModifiable.synchronizeValues(Map.of("parentProgram", program.getId()));
226                            ownSkillModifiable.saveChanges();
227                        }
228                        
229                        program.saveChanges();
230                    }
231                    
232                    ContentValue[] programTransversalSkills = program.getValue("transversalMacroSkills");
233                    // If the program has transversal skills linked, update the link to target the copied macro skills
234                    if (programTransversalSkills != null)
235                    {
236                        Content[] transversalSkills = _getCopiedSkills(programTransversalSkills, copiedMacroSkills);
237                        program.setValue("transversalMacroSkills", transversalSkills);
238                        program.saveChanges();
239                    }
240                }
241            }
242            catch (Exception e)
243            {
244                getLogger().error("An error occurred while copying the program '{}' in the new catalog '{}'", copiedContent.getId(), newCatalogName, e);
245            }
246        }
247    }
248    
249    private Content[] _getCopiedSkills(ContentValue[] originalSkills, Map<String, Content> copiedMacroSkills)
250    {
251        return Arrays.asList(originalSkills)
252                     .stream()
253                     // Keep the former skill if the copy is not found
254                     .map(originalSkill -> copiedMacroSkills.getOrDefault(originalSkill.getContentId(), originalSkill.getContent()))
255                     .toArray(Content[]::new);
256    }
257    
258    private boolean _skillExists(Content skill, String contentType, String newCatalogName)
259    {
260        return _getContents(newCatalogName, contentType, skill).findAny().isPresent();
261    }
262    
263    private <T extends Content> List<T> _getContents(String catalogName, String contentType)
264    {
265        return this.<T>_getContents(catalogName, contentType, null).toList();
266    }
267    
268    private <T extends Content> Stream<T> _getContents(String catalogName, String contentType, Content skill)
269    {
270        List<Expression> exprs = new ArrayList<>();
271        exprs.add(new ContentTypeExpression(Operator.EQ, contentType));
272        exprs.add(new StringExpression("catalog", Operator.EQ, catalogName));
273        if (skill != null)
274        {
275            exprs.add(new LanguageExpression(Operator.EQ, skill.getLanguage()));
276            exprs.add(new StringExpression("code", Operator.EQ, skill.getValue("code")));
277        }
278        Expression expression = new AndExpression(exprs.toArray(Expression[]::new));
279        
280        String query = ContentQueryHelper.getContentXPathQuery(expression);
281        return _resolver.<T>query(query).stream();
282    }
283    
284    private void _deleteContent(Content content)
285    {
286        Map<String, Object> eventParams = new HashMap<>();
287        eventParams.put(ObservationConstants.ARGS_CONTENT, content);
288        eventParams.put(ObservationConstants.ARGS_CONTENT_NAME, content.getName());
289        eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId());
290        ModifiableAmetysObject parent = content.getParent();
291        
292        _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_DELETING, _currentUserProvider.getUser(), eventParams));
293        
294        // Remove the content.
295        LockableAmetysObject lockedContent = (LockableAmetysObject) content;
296        if (lockedContent.isLocked())
297        {
298            lockedContent.unlock();
299        }
300        
301        ((RemovableAmetysObject) content).remove();
302        
303        parent.saveChanges();
304        
305        _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_DELETED, _currentUserProvider.getUser(), eventParams));
306    }
307}