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