001/*
002 *  Copyright 2024 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;
017
018import java.util.ArrayList;
019import java.util.Collections;
020import java.util.HashMap;
021import java.util.List;
022import java.util.Map;
023import java.util.Map.Entry;
024import java.util.Optional;
025import java.util.Set;
026import java.util.stream.Collectors;
027import java.util.stream.Stream;
028
029import javax.jcr.Node;
030import javax.jcr.NodeIterator;
031import javax.jcr.Repository;
032import javax.jcr.RepositoryException;
033import javax.jcr.Session;
034import javax.jcr.query.InvalidQueryException;
035import javax.jcr.query.Query;
036
037import org.apache.avalon.framework.component.Component;
038import org.apache.avalon.framework.service.ServiceException;
039import org.apache.avalon.framework.service.ServiceManager;
040import org.apache.avalon.framework.service.Serviceable;
041import org.apache.commons.lang.StringUtils;
042import org.apache.commons.lang3.tuple.Pair;
043
044import org.ametys.cms.data.holder.group.IndexableRepeaterEntry;
045import org.ametys.cms.data.holder.group.ModifiableIndexableRepeater;
046import org.ametys.cms.repository.Content;
047import org.ametys.cms.repository.ModifiableContent;
048import org.ametys.cms.repository.WorkflowAwareContent;
049import org.ametys.cms.workflow.ContentWorkflowHelper;
050import org.ametys.cms.workflow.EditContentFunction;
051import org.ametys.cms.workflow.ValidateContentFunction;
052import org.ametys.odf.data.EducationalPath;
053import org.ametys.odf.data.type.EducationalPathRepositoryElementType;
054import org.ametys.plugins.repository.AmetysObjectResolver;
055import org.ametys.plugins.repository.RepositoryConstants;
056import org.ametys.plugins.repository.data.holder.impl.DataHolderHelper;
057import org.ametys.plugins.repository.data.holder.values.SynchronizableValue;
058import org.ametys.plugins.repository.data.holder.values.SynchronizationResult;
059import org.ametys.plugins.repository.model.RepeaterDefinition;
060import org.ametys.plugins.repository.provider.AbstractRepository;
061import org.ametys.plugins.repository.query.QueryHelper;
062import org.ametys.plugins.repository.query.expression.AndExpression;
063import org.ametys.plugins.repository.query.expression.Expression;
064import org.ametys.plugins.repository.query.expression.Expression.Operator;
065import org.ametys.plugins.repository.query.expression.StringExpression;
066import org.ametys.plugins.workflow.AbstractWorkflowComponent;
067import org.ametys.plugins.workflow.component.CheckRightsCondition;
068import org.ametys.runtime.model.ModelItem;
069import org.ametys.runtime.plugin.component.AbstractLogEnabled;
070
071import com.opensymphony.workflow.WorkflowException;
072
073/**
074 * Helper for manipulating {@link EducationalPath}
075 */
076public class EducationalPathHelper extends AbstractLogEnabled implements Component, Serviceable
077{
078    /** The avalon role */
079    public static final String ROLE = EducationalPathHelper.class.getName();
080    
081    /** Constant to get/set the ancestor path (may be partial) of a program item in request attribute */
082    public static final String PROGRAM_ITEM_ANCESTOR_PATH_REQUEST_ATTR = EducationalPathHelper.class.getName() + "$ancestorPath";
083    
084    /** Constant to get/set the root program item in request attribute */
085    public static final String ROOT_PROGRAM_ITEM_REQUEST_ATTR = EducationalPathHelper.class.getName() + "$rootProgramItem";
086    
087    private AmetysObjectResolver _resolver;
088    private Repository _repository;
089
090    private ContentWorkflowHelper _workflowHelper;
091
092    public void service(ServiceManager manager) throws ServiceException
093    {
094        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
095        _repository = (Repository) manager.lookup(AbstractRepository.ROLE);
096        _workflowHelper = (ContentWorkflowHelper) manager.lookup(ContentWorkflowHelper.ROLE);
097    }
098    
099    /**
100     * Determines if program items is part of a same educational path value
101     * @param programItems the program items to search into a educational path value
102     * @return true if the program items is part of a educational path value
103     * @throws RepositoryException if failed to get educational path nodes
104     */
105    public boolean isPartOfEducationalPath(ProgramItem... programItems) throws RepositoryException
106    {
107        String[] programItemIds = Stream.of(programItems)
108                                        .map(ProgramItem::getId)
109                                        .toArray(String[]::new);
110        return isPartOfEducationalPath(programItemIds);
111    }
112    
113    /**
114     * Determines if program items is part of a same educational path  value
115     * @param programItemIds the id of program items to search into a educational path  value
116     * @return true if the program items is part of a educational path  value
117     * @throws RepositoryException if failed to get educational path nodes
118     */
119    public boolean isPartOfEducationalPath(String... programItemIds) throws RepositoryException
120    {
121        NodeIterator nodeIterator = _getEducationalPathNodes(programItemIds);
122        return nodeIterator.hasNext();
123    }
124    
125    /**
126     * Get the educational paths that reference the given program items
127     * @param programItems the referenced program items to search into a educational path
128     * @return a Map with program items referencing the given program items in a educational path attribute as key and the list of educational path references as value
129     * @throws RepositoryException if an error occurred while retrieving educational path references
130     */
131    public Map<ProgramItem, List<EducationalPathReference>> getEducationalPathReferences(ProgramItem... programItems) throws RepositoryException
132    {
133        String[] programItemIds = Stream.of(programItems)
134                .map(ProgramItem::getId)
135                .toArray(String[]::new);
136        return getEducationalPathReferences(programItemIds);
137    }
138    
139    /**
140     * Get the educational paths that reference the given program items
141     * @param programItemIds the id of program items to search into a educational path
142     * @return a Map with program items referencing the given program items in a educational path attribute as key and the list of educational path references as value
143     * @throws RepositoryException if an error occurred while retrieving educational path references
144     */
145    public Map<ProgramItem, List<EducationalPathReference>> getEducationalPathReferences(String... programItemIds) throws RepositoryException
146    {
147        Map<ProgramItem, List<EducationalPathReference>> references = new HashMap<>();
148        
149        NodeIterator educationalPathNodesIterator = _getEducationalPathNodes(programItemIds);
150        
151        while (educationalPathNodesIterator.hasNext())
152        {
153            Node educationalPathNode = educationalPathNodesIterator.nextNode();
154            String educationalPathAttributeName = StringUtils.substringAfter(educationalPathNode.getName(), RepositoryConstants.NAMESPACE_PREFIX + ":");
155            
156            Node repeaterEntryNode = educationalPathNode.getParent();
157            
158            Pair<ProgramItem, String> resolvedEntryPath = _resolveEntryPath(repeaterEntryNode);
159            
160            ProgramItem refProgramItem = resolvedEntryPath.getLeft();
161            if (!references.containsKey(refProgramItem))
162            {
163                references.put(refProgramItem, new ArrayList<>());
164            }
165            
166            EducationalPath value = ((Content) refProgramItem).getValue(resolvedEntryPath.getRight() + "/" + educationalPathAttributeName);
167            references.get(refProgramItem).add(new EducationalPathReference(refProgramItem, resolvedEntryPath.getRight(), educationalPathAttributeName, value));
168        }
169        
170        return references;
171    }
172    
173    /**
174     * Remove all repeater entries with a educational path that reference the given program items
175     * @param programItems the referenced program items to search into a educational path
176     * @throws RepositoryException if an error occurred while retrieving educational path references
177     * @throws WorkflowException if failed to do workflow action on modified contents
178     */
179    public void removeEducationalPathReferences(ProgramItem... programItems) throws RepositoryException, WorkflowException
180    {
181        String[] programItemIds = Stream.of(programItems)
182                .map(ProgramItem::getId)
183                .toArray(String[]::new);
184        removeEducationalPathReferences(2, programItemIds);
185    }
186    
187    /**
188     * Remove all repeater entries with a educational path that reference the given program items
189     * @param programItemIds the id program items to search into a educational path
190     * @throws RepositoryException if an error occurred while retrieving educational path references
191     * @throws WorkflowException if failed to do workflow action on modified contents
192     */
193    public void removeEducationalPathReferences(List<String> programItemIds) throws RepositoryException, WorkflowException
194    {
195        removeEducationalPathReferences(2, programItemIds.toArray(String[]::new));
196    }
197    
198    /**
199     * Remove all repeater entries with a educational path that reference the given program items
200     * @param workflowActionId The id of the workflow action to do
201     * @param programItemIds the ids of program items to search into a educational path
202     * @throws RepositoryException if an error occurred while retrieving educational path references
203     * @throws WorkflowException if failed to do workflow action on modified contents
204     */
205    public void removeEducationalPathReferences(int workflowActionId, String... programItemIds) throws RepositoryException, WorkflowException
206    {
207        Map<ProgramItem, List<EducationalPathReference>> educationalPathReferences = getEducationalPathReferences(programItemIds);
208        
209        for (Entry<ProgramItem, List<EducationalPathReference>> entry : educationalPathReferences.entrySet())
210        {
211            ProgramItem refProframItem = entry.getKey();
212            
213            List<EducationalPathReference> references = entry.getValue();
214            for (EducationalPathReference educationalPathReference : references)
215            {
216                String attributeName = educationalPathReference.attributeName();
217                EducationalPath value = educationalPathReference.value();
218                String repeaterEntryPath = educationalPathReference.repeaterEntryPath(); // (ex: rep1[2]/comp/rep1[1]/rep3[2])
219                String repeaterPath = StringUtils.substringBeforeLast(repeaterEntryPath, "["); // rep1[2]/comp/rep1[1]/rep3
220                
221                // Do NOT use repeater entry path to remove it as entry position can change during removing of entries
222                // Find entry with same educational path value
223                ModifiableIndexableRepeater repeater = ((ModifiableContent) refProframItem).getRepeater(repeaterPath);
224                Optional<Integer> entryPos = repeater.getEntries()
225                    .stream()
226                    .filter(e -> value.equals(e.getValue(attributeName)))
227                    .map(e -> e.getPosition())
228                    .findFirst();
229                            
230                if (entryPos.isPresent())
231                {
232                    String realEntryPath = repeaterPath + "[" + entryPos.get() + "]";
233                    ((ModifiableContent) refProframItem).removeValue(realEntryPath);
234                }
235            }
236            
237            _triggerWorkflowAction((Content) refProframItem, 2);
238        }
239    }
240    
241    
242    /**
243     * Remove all repeaters with a educational path in their model
244     * @param programItem the program item
245     * @throws WorkflowException if failed to do workflow action on modified contents
246     */
247    public void removeAllRepeatersWithEducationalPath(ProgramItem programItem) throws WorkflowException
248    {
249        ModifiableContent content = (ModifiableContent) programItem;
250        
251        boolean needSave = false;
252        
253        Map<String, Object> educationPaths = DataHolderHelper.findItemsByType(content, EducationalPathRepositoryElementType.EDUCATIONAL_PATH_ELEMENT_TYPE_ID);
254        
255        // Get path of repeaters with a education path value
256        Set<String> repeaterDataPaths = educationPaths.entrySet().stream()
257            .filter(e -> e.getValue() instanceof EducationalPath)
258            .map(Entry::getKey) // data path
259            .map(dataPath -> StringUtils.substring(dataPath, 0, dataPath.lastIndexOf("/"))) // parent data path
260            .filter(DataHolderHelper::isRepeaterEntryPath)
261            .map(DataHolderHelper::getRepeaterNameAndEntryPosition) // get repeater path
262            .map(Pair::getLeft)
263            .collect(Collectors.toSet());
264        
265        for (String repeaterDataPath : repeaterDataPaths)
266        {
267            RepeaterDefinition repeaterDef = (RepeaterDefinition) content.getDefinition(repeaterDataPath);
268            if (content.hasValue(repeaterDataPath) && !_isInRepeaterWithCommonAttribute(repeaterDef)) // do not remove repeaters with common model item !
269            {
270                content.removeValue(repeaterDataPath);
271                needSave = true;
272            }
273        }
274        
275        if (needSave)
276        {
277            _triggerWorkflowAction(content, 2);
278        }
279    }
280    
281    private boolean _isInRepeaterWithCommonAttribute(ModelItem modelItem)
282    {
283        ModelItem parentItem = modelItem;
284        while (parentItem != null)
285        {
286            if (parentItem instanceof RepeaterDefinition repeaterDef)
287            {
288                if (repeaterDef.hasModelItem("common"))
289                {
290                    return true;
291                }
292            }
293            parentItem = parentItem.getParent();
294        }
295        return false;
296    }
297    
298    
299    /**
300     * Determines if this repeater is a repeater with a educational path
301     * @param repeaterDefinition the repeater definition
302     * @return true if the repeater contains data related to specific an education path
303     */
304    public boolean isRepeaterWithEducationalPath(RepeaterDefinition repeaterDefinition)
305    {
306        for (ModelItem childItem : repeaterDefinition.getModelItems())
307        {
308            if (EducationalPathRepositoryElementType.EDUCATIONAL_PATH_ELEMENT_TYPE_ID.equals(childItem.getType().getId()))
309            {
310                return true;
311            }
312        }
313        return false;
314    }
315    
316    /**
317     * Browse entries of a repeater with educational path to get their values by educational path
318     * @param content The content
319     * @param repeaterDefinition The repeater definition
320     * @return a Map of {@link EducationalPath} with related values by names
321     */
322    public Map<EducationalPath, Map<String, Object>> getValuesByEducationalPath(Content content, RepeaterDefinition repeaterDefinition)
323    {
324        Map<EducationalPath, Map<String, Object>> valuesByEducationalPath = new HashMap<>();
325        
326        String repeaterPath = repeaterDefinition.getPath();
327        if (content.hasValue(repeaterPath) && isRepeaterWithEducationalPath(repeaterDefinition))
328        {
329            List< ? extends IndexableRepeaterEntry> entries = content.getRepeater(repeaterPath).getEntries();
330            for (IndexableRepeaterEntry entry : entries)
331            {
332                EducationalPath path = null;
333                Map<String, Object> values = new HashMap<>();
334                for (ModelItem childItem : repeaterDefinition.getModelItems())
335                {
336                    if (EducationalPathRepositoryElementType.EDUCATIONAL_PATH_ELEMENT_TYPE_ID.equals(childItem.getType().getId()))
337                    {
338                        path = entry.getValue(childItem.getName());
339                    }
340                    else if (entry.hasValue(childItem.getName()))
341                    {
342                        values.put(childItem.getName(), entry.getValue(childItem.getName()));
343                    }
344                }
345                
346                if (path != null)
347                {
348                    valuesByEducationalPath.put(path, values);
349                }
350            }
351        }
352        return valuesByEducationalPath;
353    }
354    
355    /**
356     * Browse a repeater entries' values to reorganize values by educational path
357     * @param repeaterValues The repeater entries' values
358     * @param repeaterDefinition The repeater definition
359     * @return a Map of {@link EducationalPath} with related values by names
360     */
361    public Map<EducationalPath, Map<String, Object>> getValuesByEducationalPath(List<Map<String, Object>> repeaterValues, RepeaterDefinition repeaterDefinition)
362    {
363        Map<EducationalPath, Map<String, Object>> valuesByEducationalPath = new HashMap<>();
364        
365        for (Map<String, Object> entryValues : repeaterValues)
366        {
367            EducationalPath path = null;
368            Map<String, Object> values = new HashMap<>();
369            
370            for (ModelItem childItem : repeaterDefinition.getModelItems())
371            {
372                if (entryValues.containsKey(childItem.getName()))
373                {
374                    Object value = entryValues.get(childItem.getName());
375                    value = value instanceof SynchronizableValue synchronizableValue ? synchronizableValue.getLocalValue() : value;
376                    
377                    if (value instanceof EducationalPath educationPath)
378                    {
379                        path = educationPath;
380                    }
381                    else
382                    {
383                        values.put(childItem.getName(), value);
384                    }
385                }
386            }
387            
388            if (path != null)
389            {
390                valuesByEducationalPath.put(path, values);
391            }
392        }
393        
394        return valuesByEducationalPath;
395    }
396    
397    private void _triggerWorkflowAction(Content content, int actionId) throws WorkflowException
398    {
399        if (content instanceof WorkflowAwareContent)
400        {
401            // The content has already been modified by this function
402            SynchronizationResult synchronizationResult = new SynchronizationResult();
403            synchronizationResult.setHasChanged(true);
404
405            Map<String, Object> parameters = new HashMap<>();
406            parameters.put(ValidateContentFunction.IS_MAJOR, false);
407            parameters.put(EditContentFunction.SYNCHRONIZATION_RESULT, synchronizationResult);
408            parameters.put(EditContentFunction.QUIT, true);
409
410            Map<String, Object> inputs = new HashMap<>();
411            inputs.put(AbstractWorkflowComponent.CONTEXT_PARAMETERS_KEY, parameters);
412            
413            // Do not check right
414            inputs.put(CheckRightsCondition.FORCE, true);
415            
416            _workflowHelper.doAction((WorkflowAwareContent) content, actionId, inputs);
417        }
418    }
419    
420    private Pair<ProgramItem, String> _resolveEntryPath(Node entryNode) throws RepositoryException
421    {
422        List<String> entryPath = new ArrayList<>();
423        
424        String entryPos = StringUtils.substringAfter(entryNode.getName(), RepositoryConstants.NAMESPACE_PREFIX + ":");
425        Node repeaterNode = entryNode.getParent();
426        String repeaterName = StringUtils.substringAfter(repeaterNode.getName(), RepositoryConstants.NAMESPACE_PREFIX + ":");
427        
428        entryPath.add(repeaterName + "[" + entryPos + "]");
429        
430        Node currentNode = repeaterNode.getParent();
431        
432        while (!currentNode.isNodeType("ametys:content"))
433        {
434            String currentNodeName = StringUtils.substringAfter(currentNode.getName(), RepositoryConstants.NAMESPACE_PREFIX + ":");
435            Node parentNode = currentNode.getParent();
436            
437            if (_isRepeaterNode(parentNode, currentNodeName))
438            {
439                entryPath.add(StringUtils.substringAfter(parentNode.getName(), RepositoryConstants.NAMESPACE_PREFIX + ":") + "[" + currentNodeName + "]");
440                currentNode = parentNode.getParent();
441            }
442            else
443            {
444                entryPath.add(currentNodeName);
445                currentNode = parentNode;
446            }
447        }
448        
449        ProgramItem programItem = _resolver.resolve(currentNode, false);
450        
451        Collections.reverse(entryPath);
452        return Pair.of(programItem, StringUtils.join(entryPath, "/"));
453    }
454    
455    private boolean _isRepeaterNode(Node node, String childName) throws RepositoryException 
456    {
457        return node.isNodeType("ametys:repeater") 
458                || node.isNodeType("ametys:compositeMetadata") && StringUtils.isNumeric(childName); // legacy mode : old repeater nodes are of type ametys:compositeMetadata
459    }
460    
461    private NodeIterator _getEducationalPathNodes(String... programItemIds) throws InvalidQueryException, RepositoryException
462    {
463        List<Expression> exprs = new ArrayList<>();
464        for (String programItemId : programItemIds)
465        {
466            exprs.add(new StringExpression(EducationalPath.PATH_SEGMENTS_IDENTIFIER, Operator.EQ, programItemId));
467        }
468        
469        String xPathQuery = QueryHelper.getXPathQuery(null, EducationalPathRepositoryElementType.EDUCATIONAL_PATH_NODETYPE, new AndExpression(exprs.toArray(Expression[]::new)));
470        
471        Session session = _repository.login();
472        Query query = session.getWorkspace().getQueryManager().createQuery(xPathQuery, Query.XPATH);
473        
474        return query.execute().getNodes();
475    }
476    
477    /**
478     * Record for a {@link EducationalPath} references
479     * @param programItem The program item holding the educational path
480     * @param repeaterEntryPath The path of repeater entry holding the educational path
481     * @param attributeName The name of attribute holding the educational path
482     * @param value The value of the educational path
483     */
484    public record EducationalPathReference(ProgramItem programItem, String repeaterEntryPath, String attributeName, EducationalPath value) { /* empty */ }
485}