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