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.HashMap;
020import java.util.HashSet;
021import java.util.Map;
022import java.util.Set;
023import java.util.function.Function;
024
025import javax.jcr.Repository;
026import javax.jcr.RepositoryException;
027import javax.jcr.Session;
028
029import org.apache.avalon.framework.component.Component;
030import org.apache.avalon.framework.service.ServiceException;
031import org.apache.avalon.framework.service.ServiceManager;
032import org.apache.avalon.framework.service.Serviceable;
033
034import org.ametys.cms.ObservationConstants;
035import org.ametys.cms.trash.TrashConstants;
036import org.ametys.cms.trash.model.TrashElementModel;
037import org.ametys.core.observation.Event;
038import org.ametys.core.observation.ObservationManager;
039import org.ametys.core.user.CurrentUserProvider;
040import org.ametys.core.util.DateUtils;
041import org.ametys.plugins.repository.AmetysObject;
042import org.ametys.plugins.repository.AmetysObjectIterable;
043import org.ametys.plugins.repository.AmetysObjectResolver;
044import org.ametys.plugins.repository.AmetysRepositoryException;
045import org.ametys.plugins.repository.ModifiableTraversableAmetysObject;
046import org.ametys.plugins.repository.RemovableAmetysObject;
047import org.ametys.plugins.repository.collection.AmetysObjectCollection;
048import org.ametys.plugins.repository.collection.AmetysObjectCollectionFactory;
049import org.ametys.plugins.repository.jcr.JCRAmetysObject;
050import org.ametys.plugins.repository.jcr.NameHelper;
051import org.ametys.plugins.repository.jcr.NameHelper.NameComputationMode;
052import org.ametys.plugins.repository.provider.AbstractRepository;
053import org.ametys.plugins.repository.query.QueryHelper;
054import org.ametys.plugins.repository.query.expression.AndExpression;
055import org.ametys.plugins.repository.query.expression.BooleanExpression;
056import org.ametys.plugins.repository.query.expression.DateExpression;
057import org.ametys.plugins.repository.query.expression.Expression;
058import org.ametys.plugins.repository.query.expression.Expression.Operator;
059import org.ametys.plugins.repository.query.expression.StringExpression;
060import org.ametys.plugins.repository.trash.TrashElement;
061import org.ametys.plugins.repository.trash.TrashElementTypeExtensionPoint;
062import org.ametys.plugins.repository.trash.TrashableAmetysObject;
063import org.ametys.plugins.repository.trash.UnknownParentException;
064import org.ametys.runtime.plugin.component.AbstractLogEnabled;
065
066/**
067 * {@link TrashElementDAO} to manage {@link TrashElement}s.
068 */
069public class TrashElementDAO extends AbstractLogEnabled implements org.ametys.plugins.repository.trash.TrashElementDAO, Serviceable, Component
070{
071    /** the Ametys object resolver */
072    protected AmetysObjectResolver _resolver;
073
074    private Repository _repository;
075    private ObservationManager _observationManager;
076    private CurrentUserProvider _currentUserProvider;
077
078    private TrashElementTypeExtensionPoint _trashTypeEP;
079    
080    public void service(ServiceManager serviceManager) throws ServiceException
081    {
082        _repository = (Repository) serviceManager.lookup(AbstractRepository.ROLE);
083        _resolver = (AmetysObjectResolver) serviceManager.lookup(AmetysObjectResolver.ROLE);
084        _observationManager = (ObservationManager) serviceManager.lookup(ObservationManager.ROLE);
085        _currentUserProvider = (CurrentUserProvider) serviceManager.lookup(CurrentUserProvider.ROLE);
086        _trashTypeEP = (TrashElementTypeExtensionPoint) serviceManager.lookup(TrashElementTypeExtensionPoint.ROLE);
087    }
088    
089    /**
090     * Resolve the {@link AmetysObject} into the trash workspace.
091     * @param <A> The type of the returned {@link AmetysObject}.
092     * @param ametysObjectId The identifier
093     * @return the resolved Ametys object, can be null if not found
094     */
095    public <A extends AmetysObject> A resolve(String ametysObjectId)
096    {
097        return executeInTrashSession(session -> _resolveSilently(ametysObjectId, session));
098    }
099    
100    private <A extends AmetysObject> A _resolveSilently(String ametysObjectId, Session session)
101    {
102        try
103        {
104            return _resolver.resolveById(ametysObjectId, session);
105        }
106        catch (RepositoryException e)
107        {
108            throw new AmetysRepositoryException(e);
109        }
110    }
111    
112    /**
113     * Find a trash element linked to a trashed ametys object.
114     * @param ametysObjectId The ametys object id
115     * @return the {@link TrashElement}, can be null if not found
116     */
117    public TrashElement find(String ametysObjectId)
118    {
119        return executeInTrashSession(
120            session ->
121            {
122                try
123                {
124                    Expression expression = new StringExpression(TrashElementModel.DELETED_OBJECT, Operator.EQ, ametysObjectId);
125                    String xPathQuery = QueryHelper.getXPathQuery(null, TrashElementFactory.TRASH_ELEMENT_NODETYPE, expression);
126                    return _resolver.<TrashElement>query(xPathQuery, session).stream().findFirst().orElse(null);
127                }
128                catch (RepositoryException e)
129                {
130                    throw new AmetysRepositoryException(e);
131                }
132            }
133        );
134    }
135    
136    /**
137     * Empty the trash.
138     * @param lifetime Number of days before the trash has to be emptied. 0 or less empty all elements.
139     * @throws RepositoryException if an error occurs
140     */
141    public void empty(long lifetime) throws RepositoryException
142    {
143        executeInTrashSession(
144            session ->
145            {
146                Expression expression = lifetime > 0
147                    ? new AndExpression(
148                        // Only delete visible trash elements. Hidden trash elements will be deleted by cascading if needed
149                        new BooleanExpression(TrashElementModel.HIDDEN, false),
150                        new DateExpression(TrashElementModel.DATE, Operator.LT, DateUtils.asDate(ZonedDateTime.now().minusDays(lifetime)))
151                    )
152                    // If lifetime is not set, we want to delete all contents in the trash
153                    : null;
154                
155                String xPathQuery = QueryHelper.getXPathQuery(null, TrashElementFactory.TRASH_ELEMENT_NODETYPE, expression);
156                
157                try (AmetysObjectIterable<TrashElement> trashElements = _resolver.query(xPathQuery, session))
158                {
159                    for (TrashElement trashElement : trashElements)
160                    {
161                        _remove(trashElement, lifetime > 0);
162                    }
163                }
164                catch (RepositoryException e)
165                {
166                    throw new AmetysRepositoryException(e);
167                }
168                finally
169                {
170                    session.logout();
171                }
172                
173                return null;
174            }
175        );
176    }
177
178    /**
179     * Trash the {@link AmetysObject}.
180     * @param ametysObject The Ametys object
181     * @return the {@link TrashElement} created while moving the object to the trash
182     */
183    public TrashElement trash(TrashableAmetysObject ametysObject)
184    {
185        return trash(ametysObject, false, null);
186    }
187    
188    /**
189     * Trash the {@link AmetysObject}.
190     * @param ametysObject The Ametys object
191     * @param hidden <code>true</code> if it is an object trashed by another one
192     * @param linkedObjects The list of linked objects to the current object to trash. Linked objects can be also trashed, but it is not mandatory.
193     * @return the {@link TrashElement} created while moving the object to the trash
194     */
195    public TrashElement trash(TrashableAmetysObject ametysObject, boolean hidden, String[] linkedObjects)
196    {
197        String ametysObjectId = ametysObject.getId();
198        
199        // Move the trashed node under the trash element
200        JCRAmetysObject originalParent = ametysObject.getParent();
201        TrashElement trashElement = ametysObject.moveToTrash();
202        trashElement.setHidden(hidden);
203        trashElement.addLinkedObjects(linkedObjects);
204        
205        // Save changes to trash first. That way if the save fails, the original node is not destroyed
206        trashElement.saveChanges();
207        originalParent.saveChanges();
208        
209        // Notify observers
210        Map<String, Object> eventParams = new HashMap<>();
211        eventParams.put(ObservationConstants.ARGS_TRASH_ELEMENT_ID, trashElement.getId());
212        eventParams.put(ObservationConstants.ARGS_AMETYS_OBJECT_ID, ametysObjectId);
213        _observationManager.notify(new Event(ObservationConstants.EVENT_TRASH_ADDED, _currentUserProvider.getUser(), eventParams));
214        
215        return trashElement;
216    }
217    
218    @Override
219    public TrashElement createTrashElement(TrashableAmetysObject ametysObject, String title)
220    {
221        ModifiableTraversableAmetysObject trash = getOrCreateRoot(ametysObject);
222        
223        // Create the trash element
224        String nodeName = NameHelper.getUniqueAmetysObjectName(trash, "trash", NameComputationMode.GENERATED_KEY, false);
225        TrashElement trashElement = trash.createChild(nodeName, TrashElementFactory.TRASH_ELEMENT_NODETYPE);
226        trashElement.setValue(TrashElementModel.TITLE, title);
227        trashElement.setValue(TrashElementModel.AUTHOR, _currentUserProvider.getUser());
228        trashElement.setValue(TrashElementModel.DATE, ZonedDateTime.now());
229        trashElement.setValue(TrashElementModel.DELETED_OBJECT, ametysObject.getId());
230        trashElement.setValue(TrashElementModel.PARENT_PATH, ametysObject.getParentPath());
231        _trashTypeEP.getFirstSupportingExtension(ametysObject)
232            .ifPresentOrElse(
233                type -> trashElement.setValue(TrashElementModel.TRASH_TYPE, type.getId()),
234                () -> {
235                    // remove the node to prevent the presence of inconsistent data
236                    trashElement.remove();
237                    throw new IllegalStateException("No supporting type for trashable '" + ametysObject.getId() + "'.");
238                }
239            );
240        
241        return trashElement;
242    }
243    
244    /**
245     * Remove the {@link TrashElement} and its linked objects.
246     * @param trashElementId The trash element identifier to remove
247     */
248    public void remove(String trashElementId)
249    {
250        _remove(resolve(trashElementId), true);
251    }
252    
253    private void _remove(TrashElement trashElement, boolean removeLinkedElement)
254    {
255        // Get event param before deletion
256        Map<String, Object> eventParams = new HashMap<>();
257        eventParams.put(ObservationConstants.ARGS_TRASH_ELEMENT_ID, trashElement.getId());
258        eventParams.put(ObservationConstants.ARGS_AMETYS_OBJECT_ID, trashElement.getAmetysObjectId());
259        
260        if (removeLinkedElement)
261        {
262            // Remove linked objects
263            trashElement.getLinkedElement(true).forEach(e -> _remove(e, true));
264        }
265        
266        // Remove the trash element itself
267        _removeSingleObject(trashElement);
268        
269        // Notify observers
270        _observationManager.notify(new Event(ObservationConstants.EVENT_TRASH_DELETED, _currentUserProvider.getUser(), eventParams));
271    }
272    
273    private void _removeSingleObject(RemovableAmetysObject ametysObject)
274    {
275        JCRAmetysObject parent = ametysObject.getParent();
276        ametysObject.remove();
277        parent.saveChanges();
278    }
279    
280    /**
281     * Restore the {@link TrashElement} and its linked objects.
282     * @param trashElementId The trash element identifier to restore
283     * @return a report of the operation
284     * @throws UnknownParentException if its not possible to obtains a parent to restore into
285     */
286    public RestorationReport restore(String trashElementId) throws UnknownParentException
287    {
288        return _restore(resolve(trashElementId));
289    }
290    
291    private RestorationReport _restore(TrashElement trashElement) throws UnknownParentException
292    {
293        String trashableAOId = trashElement.getAmetysObjectId();
294        TrashableAmetysObject trashableAO = resolve(trashableAOId);
295        
296        // We first restored linked object because the page implementation has a physical reference to its contents.
297        // So if we restored the page before the contents, we have an exception from the repository.
298        // It seems to be unavoidable.
299        
300        // Handle linked objects
301        Set<TrashableAmetysObject> restoredLinkedObject = new HashSet<>();
302        trashElement.getLinkedElement(true).stream()
303            .forEach(linkedElement -> {
304                try
305                {
306                    RestorationReport report = _restore(linkedElement);
307                    restoredLinkedObject.add(report.restoredObject());
308                    restoredLinkedObject.addAll(report.restoredLinkedObject());
309                }
310                catch (UnknownParentException e)
311                {
312                    // Do not prevent restoring the original element because
313                    // a linked element failed
314                    getLogger().warn("An error prevented to restore the element '{}' that is linked to the restoration of '{}'", linkedElement.getId(), trashableAO.getId());
315                }
316            });
317        
318        // Restore the current trash element
319        TrashableAmetysObject restoredAO = trashableAO.restoreFromTrash();
320        restoredAO.saveChanges();
321        
322        // Remove the trash element
323        _removeSingleObject(trashElement);
324        
325        // Notify observers
326        Map<String, Object> eventParams = new HashMap<>();
327        eventParams.put(ObservationConstants.ARGS_TRASH_ELEMENT_ID, trashElement.getId());
328        eventParams.put(ObservationConstants.ARGS_AMETYS_OBJECT_ID, trashableAOId);
329        _observationManager.notify(new Event(ObservationConstants.EVENT_TRASH_RESTORED, _currentUserProvider.getUser(), eventParams));
330        
331        return new RestorationReport(restoredAO, restoredLinkedObject);
332    }
333    
334    /**
335     * Get or create the trash root
336     * @param ametysObject the object that need to be trashed
337     * @return the trash where the ametys will be trashed
338     */
339    protected ModifiableTraversableAmetysObject getOrCreateRoot(TrashableAmetysObject ametysObject)
340    {
341        return executeInTrashSession(
342            session ->
343            {
344                ModifiableTraversableAmetysObject root = _resolver.resolveByPath("/", session);
345                return getOrCreateCollection(root, "ametys-internal:trash");
346            }
347        );
348    }
349    
350    /**
351     * Get or create an Ametys object collection on the given parent
352     * @param parent The parent
353     * @param collectionName The collection name
354     * @return The {@link AmetysObjectCollection}
355     */
356    protected ModifiableTraversableAmetysObject getOrCreateCollection(ModifiableTraversableAmetysObject parent, String collectionName)
357    {
358        if (parent.hasChild(collectionName))
359        {
360            return parent.getChild(collectionName);
361        }
362        else
363        {
364            ModifiableTraversableAmetysObject child = parent.createChild(collectionName, AmetysObjectCollectionFactory.COLLECTION_NODETYPE);
365            parent.saveChanges();
366            return child;
367        }
368    }
369    
370    
371    /**
372     * Execute a function into the trash workspace.
373     * 
374     * This method only handles the creation of the session.
375     * The caller is responsible for logging out (either in the function itself or by using the returned value).
376     * 
377     * @param <A> The returned type, can be anything. If it is {@link Void}, the function should return <code>null</code>
378     * @param functionToExecute The function to execute, it takes a {@link Session} as parameter
379     * @return The result of the function
380     */
381    protected <A> A executeInTrashSession(Function<Session, A> functionToExecute)
382    {
383        Session trashSession = null;
384        try
385        {
386            // Login the trash workspace
387            trashSession = _repository.login(TrashConstants.TRASH_WORKSPACE);
388            
389            // Execute the function with the trash session
390            return functionToExecute.apply(trashSession);
391        }
392        catch (RepositoryException e)
393        {
394            throw new AmetysRepositoryException(e);
395        }
396    }
397
398    /**
399     * Describe the result of a restore operation.
400     * The report contains :
401     * <ul>
402     * <li>the restored object
403     * <li>a list of all the object restored with it
404     * </ul>
405     * @param restoredObject the restored object
406     * @param restoredLinkedObject the object restored along the {@code restoredObject}
407     */
408    public record RestorationReport(TrashableAmetysObject restoredObject, Set<TrashableAmetysObject> restoredLinkedObject) { }
409}