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.cms.trash.element;
017
018import java.time.ZonedDateTime;
019import java.util.Arrays;
020import java.util.HashMap;
021import java.util.HashSet;
022import java.util.Map;
023import java.util.Map.Entry;
024import java.util.Optional;
025import java.util.Set;
026import java.util.function.Function;
027import java.util.stream.Collectors;
028
029import javax.jcr.Repository;
030import javax.jcr.RepositoryException;
031import javax.jcr.Session;
032
033import org.apache.avalon.framework.component.Component;
034import org.apache.avalon.framework.service.ServiceException;
035import org.apache.avalon.framework.service.ServiceManager;
036import org.apache.avalon.framework.service.Serviceable;
037import org.apache.jackrabbit.util.ISO9075;
038
039import org.ametys.cms.ObservationConstants;
040import org.ametys.cms.trash.TrashConstants;
041import org.ametys.cms.trash.model.TrashElementModel;
042import org.ametys.core.observation.Event;
043import org.ametys.core.observation.ObservationManager;
044import org.ametys.core.user.CurrentUserProvider;
045import org.ametys.core.util.DateUtils;
046import org.ametys.plugins.repository.AmetysObject;
047import org.ametys.plugins.repository.AmetysObjectIterable;
048import org.ametys.plugins.repository.AmetysObjectResolver;
049import org.ametys.plugins.repository.AmetysRepositoryException;
050import org.ametys.plugins.repository.ModifiableTraversableAmetysObject;
051import org.ametys.plugins.repository.RemovableAmetysObject;
052import org.ametys.plugins.repository.collection.AmetysObjectCollection;
053import org.ametys.plugins.repository.collection.AmetysObjectCollectionFactory;
054import org.ametys.plugins.repository.jcr.JCRAmetysObject;
055import org.ametys.plugins.repository.jcr.NameHelper;
056import org.ametys.plugins.repository.jcr.NameHelper.NameComputationMode;
057import org.ametys.plugins.repository.provider.AbstractRepository;
058import org.ametys.plugins.repository.query.QueryHelper;
059import org.ametys.plugins.repository.query.expression.AndExpression;
060import org.ametys.plugins.repository.query.expression.BooleanExpression;
061import org.ametys.plugins.repository.query.expression.DateExpression;
062import org.ametys.plugins.repository.query.expression.Expression;
063import org.ametys.plugins.repository.query.expression.Expression.Operator;
064import org.ametys.plugins.repository.query.expression.StringExpression;
065import org.ametys.plugins.repository.trash.ConflictingNameException;
066import org.ametys.plugins.repository.trash.TrashElement;
067import org.ametys.plugins.repository.trash.TrashElementTypeExtensionPoint;
068import org.ametys.plugins.repository.trash.TrashableAmetysObject;
069import org.ametys.plugins.repository.trash.TrashableAmetysObject.MergeComputationMode;
070import org.ametys.plugins.repository.trash.UnknownParentException;
071import org.ametys.runtime.plugin.component.AbstractLogEnabled;
072
073/**
074 * {@link TrashElementDAO} to manage {@link TrashElement}s.
075 */
076public class TrashElementDAO extends AbstractLogEnabled implements org.ametys.plugins.repository.trash.TrashElementDAO, Serviceable, Component
077{
078    /** The name of the trash root node */
079    protected static final String __TRASH_ROOT_NODE_NAME = "ametys-internal:trash";
080
081    /** the Ametys object resolver */
082    protected AmetysObjectResolver _resolver;
083
084    private Repository _repository;
085    private ObservationManager _observationManager;
086    private CurrentUserProvider _currentUserProvider;
087
088    private TrashElementTypeExtensionPoint _trashTypeEP;
089    
090    public void service(ServiceManager serviceManager) throws ServiceException
091    {
092        _repository = (Repository) serviceManager.lookup(AbstractRepository.ROLE);
093        _resolver = (AmetysObjectResolver) serviceManager.lookup(AmetysObjectResolver.ROLE);
094        _observationManager = (ObservationManager) serviceManager.lookup(ObservationManager.ROLE);
095        _currentUserProvider = (CurrentUserProvider) serviceManager.lookup(CurrentUserProvider.ROLE);
096        _trashTypeEP = (TrashElementTypeExtensionPoint) serviceManager.lookup(TrashElementTypeExtensionPoint.ROLE);
097    }
098    
099    /**
100     * Resolve the {@link AmetysObject} into the trash workspace.
101     * @param <A> The type of the returned {@link AmetysObject}.
102     * @param ametysObjectId The identifier
103     * @return the resolved Ametys object, can be null if not found
104     */
105    public <A extends AmetysObject> A resolve(String ametysObjectId)
106    {
107        return executeInTrashSession(session -> _resolveSilently(ametysObjectId, session));
108    }
109    
110    private <A extends AmetysObject> A _resolveSilently(String ametysObjectId, Session session)
111    {
112        try
113        {
114            return _resolver.resolveById(ametysObjectId, session);
115        }
116        catch (RepositoryException e)
117        {
118            throw new AmetysRepositoryException(e);
119        }
120    }
121    
122    /**
123     * Find a trash element linked to a trashed ametys object.
124     * @param ametysObjectId The ametys object id
125     * @return the {@link TrashElement}, can be null if not found
126     */
127    public TrashElement find(String ametysObjectId)
128    {
129        return executeInTrashSession(
130            session ->
131            {
132                try
133                {
134                    Expression expression = new StringExpression(TrashElementModel.DELETED_OBJECT, Operator.EQ, ametysObjectId);
135                    String xPathQuery = QueryHelper.getXPathQuery(null, TrashElementFactory.TRASH_ELEMENT_NODETYPE, expression);
136                    return _resolver.<TrashElement>query(xPathQuery, session).stream().findFirst().orElse(null);
137                }
138                catch (RepositoryException e)
139                {
140                    throw new AmetysRepositoryException(e);
141                }
142            }
143        );
144    }
145    
146    /**
147     * Empty the trash.
148     * @param filter indicates how to filter element
149     * @throws AmetysRepositoryException if an error occurs
150     */
151    public void empty(TrashElementFilter filter) throws AmetysRepositoryException
152    {
153        empty(Optional.empty(), filter.getMinimumAge());
154    }
155    
156    /**
157     * Remove trash elements.
158     * @param rootPath an optional JCR path to filter element to remove
159     * @param lifetime Number of days before the trash has to be emptied. 0 or less empty all elements.
160     */
161    protected void empty(Optional<String> rootPath, long lifetime)
162    {
163        executeInTrashSession(
164            session ->
165            {
166                Expression expression = lifetime > 0
167                    ? new AndExpression(
168                        // Only delete visible trash elements. Hidden trash elements will be deleted by cascading if needed
169                        new BooleanExpression(TrashElementModel.HIDDEN, false),
170                        new DateExpression(TrashElementModel.DATE, Operator.LT, DateUtils.asDate(ZonedDateTime.now().minusDays(lifetime)))
171                    )
172                    // If lifetime is not set, we want to delete all contents in the trash
173                    : null;
174                
175                String xPathQuery = QueryHelper.getXPathQuery(null, TrashElementFactory.TRASH_ELEMENT_NODETYPE, expression);
176                
177                if (rootPath.isPresent())
178                {
179                    xPathQuery = rootPath.get() + xPathQuery;
180                }
181                try (AmetysObjectIterable<TrashElement> trashElements = _resolver.query(xPathQuery, session))
182                {
183                    for (TrashElement trashElement : trashElements)
184                    {
185                        _remove(trashElement, lifetime > 0);
186                    }
187                }
188                catch (RepositoryException e)
189                {
190                    throw new AmetysRepositoryException(e);
191                }
192                finally
193                {
194                    session.logout();
195                }
196                
197                return null;
198            }
199        );
200    }
201    
202    /**
203     * Compute the JCR path of the object and encode it to be used in XPath query
204     * @param object the JCR Ametys object
205     * @return the path
206     * @throws RepositoryException if an error occurs
207     */
208    protected String getEncodedJCRPath(JCRAmetysObject object) throws RepositoryException
209    {
210        String path = object.getNode().getPath();
211        
212        return "/jcr:root" + Arrays.stream(path.split("/"))
213            .map(ISO9075::encode)
214            .collect(Collectors.joining("/"));
215    }
216    
217    /**
218     * Trash the {@link AmetysObject}.
219     * @param ametysObject The Ametys object
220     * @return the {@link TrashElement} created while moving the object to the trash
221     */
222    public TrashElement trash(TrashableAmetysObject ametysObject)
223    {
224        return trash(ametysObject, false, null);
225    }
226    
227    /**
228     * Trash the {@link AmetysObject}.
229     * @param ametysObject The Ametys object
230     * @param hidden <code>true</code> if it is an object trashed by another one
231     * @param linkedObjects The list of linked objects to the current object to trash. Linked objects can be also trashed, but it is not mandatory.
232     * @return the {@link TrashElement} created while moving the object to the trash
233     */
234    public TrashElement trash(TrashableAmetysObject ametysObject, boolean hidden, String[] linkedObjects)
235    {
236        String ametysObjectId = ametysObject.getId();
237        
238        // Move the trashed node under the trash element
239        JCRAmetysObject originalParent = ametysObject.getParent();
240        TrashElement trashElement = ametysObject.moveToTrash();
241        trashElement.setHidden(hidden);
242        trashElement.addLinkedObjects(linkedObjects);
243        
244        // Save changes to trash first. That way if the save fails, the original node is not destroyed
245        trashElement.saveChanges();
246        originalParent.saveChanges();
247        
248        // Notify observers
249        Map<String, Object> eventParams = new HashMap<>();
250        eventParams.put(ObservationConstants.ARGS_TRASH_ELEMENT_ID, trashElement.getId());
251        eventParams.put(ObservationConstants.ARGS_AMETYS_OBJECT_ID, ametysObjectId);
252        _observationManager.notify(new Event(ObservationConstants.EVENT_TRASH_ADDED, _currentUserProvider.getUser(), eventParams));
253        
254        return trashElement;
255    }
256    
257    @Override
258    public TrashElement createTrashElement(TrashableAmetysObject ametysObject, String title)
259    {
260        ModifiableTraversableAmetysObject trash = getOrCreateRoot(ametysObject);
261        
262        // Create the trash element
263        String nodeName = NameHelper.getUniqueAmetysObjectName(trash, "trash", NameComputationMode.GENERATED_KEY, false);
264        TrashElement trashElement = trash.createChild(nodeName, TrashElementFactory.TRASH_ELEMENT_NODETYPE);
265        trashElement.setValue(TrashElementModel.TITLE, title);
266        trashElement.setValue(TrashElementModel.AUTHOR, _currentUserProvider.getUser());
267        trashElement.setValue(TrashElementModel.DATE, ZonedDateTime.now());
268        trashElement.setValue(TrashElementModel.DELETED_OBJECT, ametysObject.getId());
269        trashElement.setValue(TrashElementModel.PARENT_PATH, ametysObject.getParentPath());
270        trashElement.setValue(TrashElementModel.HIDDEN, false);
271        _trashTypeEP.getFirstSupportingExtension(ametysObject)
272            .ifPresentOrElse(
273                type -> trashElement.setValue(TrashElementModel.TRASH_TYPE, type.getId()),
274                () -> {
275                    // remove the node to prevent the presence of inconsistent data
276                    trashElement.remove();
277                    throw new IllegalStateException("No supporting type for trashable '" + ametysObject.getId() + "'.");
278                }
279            );
280        
281        return trashElement;
282    }
283    
284    /**
285     * Remove the {@link TrashElement} and its linked objects.
286     * @param trashElementId The trash element identifier to remove
287     */
288    public void remove(String trashElementId)
289    {
290        _remove(resolve(trashElementId), true);
291    }
292    
293    private void _remove(TrashElement trashElement, boolean removeLinkedElement)
294    {
295        // Get event param before deletion
296        Map<String, Object> eventParams = new HashMap<>();
297        eventParams.put(ObservationConstants.ARGS_TRASH_ELEMENT_ID, trashElement.getId());
298        eventParams.put(ObservationConstants.ARGS_AMETYS_OBJECT_ID, trashElement.getAmetysObjectId());
299        
300        if (removeLinkedElement)
301        {
302            // Remove linked objects
303            trashElement.getLinkedElements(true).forEach(e -> _remove(e, true));
304        }
305        
306        // Remove the trash element itself
307        _removeSingleObject(trashElement);
308        
309        // Notify observers
310        _observationManager.notify(new Event(ObservationConstants.EVENT_TRASH_DELETED, _currentUserProvider.getUser(), eventParams));
311    }
312    
313    private void _removeSingleObject(RemovableAmetysObject ametysObject)
314    {
315        JCRAmetysObject parent = ametysObject.getParent();
316        ametysObject.remove();
317        parent.saveChanges();
318    }
319
320    /**
321     * Restore the {@link TrashElement} and its linked objects.
322     * @param trashElementId The trash element identifier to restore
323     * @param mergeComputationMode the merge computation mode, used to know how to behave when restoring an existing element. May be null, as all implementations may not use it.
324     * @return a report of the operation
325     * @throws UnknownParentException if its not possible to obtains a parent to restore into
326     * @throws ConflictingNameException if there is a conflict between restored element or on of child element with existing element
327     */
328    public RestorationReport restore(String trashElementId, MergeComputationMode mergeComputationMode) throws UnknownParentException, ConflictingNameException
329    {
330        TrashElement trashElement = resolve(trashElementId);
331        
332        // Restore the object and all its linked objects
333        Map<String, TrashableAmetysObject> result = _restore(trashElement, mergeComputationMode);
334        
335        // Perform additional action now that all object are restored
336        for (TrashableAmetysObject restoredObject : result.values())
337        {
338            _trashTypeEP.getFirstSupportingExtension(restoredObject)
339                .ifPresent(type -> type.additionnalRestoreAction(restoredObject));
340        }
341        
342        // Then notify after all the restoration to unsure that all reference between restored elements are valid
343        Set<TrashableAmetysObject> restoredLinkedObject = new HashSet<>();
344        for (Entry<String, TrashableAmetysObject> entry : result.entrySet())
345        {
346            String elementId = entry.getKey();
347            TrashableAmetysObject restoredObject = entry.getValue();
348            
349            // Notify observers
350            Map<String, Object> eventParams = new HashMap<>();
351            eventParams.put(ObservationConstants.ARGS_TRASH_ELEMENT_ID, elementId);
352            eventParams.put(ObservationConstants.ARGS_AMETYS_OBJECT_ID, restoredObject.getId());
353            _observationManager.notify(new Event(ObservationConstants.EVENT_TRASH_RESTORED, _currentUserProvider.getUser(), eventParams));
354            
355            // add to the linked object set if its not the original
356            if (!elementId.equals(trashElementId))
357            {
358                restoredLinkedObject.add(restoredObject);
359            }
360        }
361        
362        return new RestorationReport(result.get(trashElementId), restoredLinkedObject);
363    }
364    
365    /**
366     * Restore a trash element, and its hidden linked element (if available) recursively
367     * @param trashElement the trash element to restore
368     * @return a map with the ids of the restored trash element as key and the restored trashables as value
369     * @throws ConflictingNameException if there is a conflict between restored element (or on of his children element) with existing element
370     */
371    private Map<String, TrashableAmetysObject> _restore(TrashElement trashElement, MergeComputationMode mergeComputationMode) throws UnknownParentException, ConflictingNameException
372    {
373        Map<String, TrashableAmetysObject> result = new HashMap<>();
374        
375        String trashElementId = trashElement.getId();
376        String trashableAOId = trashElement.getAmetysObjectId();
377        TrashableAmetysObject trashableAO = resolve(trashableAOId);
378        
379        // We first restored linked object because the page implementation has a physical reference to its contents.
380        // So if we restored the page before the contents, we have an exception from the repository.
381        // It seems to be unavoidable.
382        
383        Set<TrashElement> linkedElements = trashElement.getLinkedElements(true);
384        
385        Set<TrashElement> failedLinkedElements = new HashSet<>();
386        
387        for (TrashElement linkedElement: linkedElements)
388        {
389            try
390            {
391                result.putAll(_restore(linkedElement, mergeComputationMode));
392            }
393            catch (UnknownParentException | ConflictingNameException e)
394            {
395                // Do not prevent restoring the original element because
396                // a linked element failed, however we want to store them in order to make them visible in trash if original element is restored
397                failedLinkedElements.add(linkedElement);
398                getLogger().warn("An error prevented to restore the element '{}' that is linked to the restoration of '{}'", linkedElement.getId(), trashableAO.getId());
399            }
400        }
401        
402        // Restore the current trash element
403        TrashableAmetysObject restoredAO = trashableAO.restoreFromTrash(mergeComputationMode);
404        
405        result.put(trashElementId, restoredAO);
406        
407        // If some linked element failed to be restored, we need to update them to be visible in the trash, as they are still linked to the original element that is now restored
408        failedLinkedElements.stream().forEach(failedLinkedElement -> {
409            try
410            {
411                failedLinkedElement.setHidden(false);
412                failedLinkedElement.saveChanges();
413            }
414            catch (Exception e)
415            {
416                getLogger().error("Error while changing visibility of linked element '" + failedLinkedElement.getId() + "'.", e);
417            }
418        });
419        
420        // Remove the trash element
421        _removeSingleObject(trashElement);
422        
423        return result;
424    }
425    
426    /**
427     * Get or create the trash root
428     * @param ametysObject the object that need to be trashed
429     * @return the trash where the ametys will be trashed
430     */
431    protected ModifiableTraversableAmetysObject getOrCreateRoot(TrashableAmetysObject ametysObject)
432    {
433        return executeInTrashSession(
434            session ->
435            {
436                ModifiableTraversableAmetysObject root = _resolver.resolveByPath("/", session);
437                return getOrCreateCollection(root, __TRASH_ROOT_NODE_NAME);
438            }
439        );
440    }
441    
442    /**
443     * Get or create an Ametys object collection on the given parent
444     * @param parent The parent
445     * @param collectionName The collection name
446     * @return The {@link AmetysObjectCollection}
447     */
448    protected ModifiableTraversableAmetysObject getOrCreateCollection(ModifiableTraversableAmetysObject parent, String collectionName)
449    {
450        if (parent.hasChild(collectionName))
451        {
452            return parent.getChild(collectionName);
453        }
454        else
455        {
456            ModifiableTraversableAmetysObject child = parent.createChild(collectionName, AmetysObjectCollectionFactory.COLLECTION_NODETYPE);
457            parent.saveChanges();
458            return child;
459        }
460    }
461    
462    
463    /**
464     * Execute a function into the trash workspace.
465     * 
466     * This method only handles the creation of the session.
467     * The caller is responsible for logging out (either in the function itself or by using the returned value).
468     * 
469     * @param <A> The returned type, can be anything. If it is {@link Void}, the function should return <code>null</code>
470     * @param functionToExecute The function to execute, it takes a {@link Session} as parameter
471     * @return The result of the function
472     */
473    protected <A> A executeInTrashSession(Function<Session, A> functionToExecute)
474    {
475        Session trashSession = null;
476        try
477        {
478            // Login the trash workspace
479            trashSession = _repository.login(TrashConstants.TRASH_WORKSPACE);
480            
481            // Execute the function with the trash session
482            return functionToExecute.apply(trashSession);
483        }
484        catch (RepositoryException e)
485        {
486            throw new AmetysRepositoryException(e);
487        }
488    }
489
490    /**
491     * Describe the result of a restore operation.
492     * The report contains :
493     * <ul>
494     * <li>the restored object
495     * <li>a list of all the object restored with it
496     * </ul>
497     * @param restoredObject the restored object
498     * @param restoredLinkedObject the object restored along the {@code restoredObject}
499     */
500    public record RestorationReport(TrashableAmetysObject restoredObject, Set<TrashableAmetysObject> restoredLinkedObject) { }
501    
502    /**
503     * Information related to filtering trash element targeted by a mass operation
504     */
505    public static class TrashElementFilter
506    {
507        private long _age;
508        
509        /**
510         * Information related to filtering trash element targeted by a mass operation
511         * @param age Minimum age (in days) of the element. 0 or less means no filtering.
512         */
513        public TrashElementFilter(long age)
514        {
515            _age = age;
516        }
517        
518        /**
519         * Get the minimum age of an element
520         * @return the age in days
521         */
522        public long getMinimumAge()
523        {
524            return _age;
525        }
526    }
527}