001/*
002 *  Copyright 2015 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.web.repository.page;
017
018import java.util.ArrayList;
019import java.util.Collections;
020import java.util.Date;
021import java.util.HashMap;
022import java.util.HashSet;
023import java.util.Iterator;
024import java.util.List;
025import java.util.Map;
026import java.util.Objects;
027import java.util.Set;
028import java.util.function.Predicate;
029import java.util.stream.Collectors;
030
031import javax.jcr.Node;
032import javax.jcr.Repository;
033import javax.jcr.RepositoryException;
034import javax.jcr.Session;
035
036import org.apache.avalon.framework.activity.Initializable;
037import org.apache.avalon.framework.component.Component;
038import org.apache.avalon.framework.service.ServiceException;
039import org.apache.avalon.framework.service.ServiceManager;
040import org.apache.commons.collections4.CollectionUtils;
041import org.apache.commons.lang3.LocaleUtils;
042import org.apache.commons.lang3.StringUtils;
043
044import org.ametys.cms.contenttype.ContentType;
045import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
046import org.ametys.cms.repository.Content;
047import org.ametys.cms.repository.ContentDAO.TagMode;
048import org.ametys.cms.repository.ModifiableContent;
049import org.ametys.cms.repository.WorkflowAwareContent;
050import org.ametys.cms.tag.CMSTag;
051import org.ametys.cms.tag.CMSTag.TagVisibility;
052import org.ametys.cms.tag.TagHelper;
053import org.ametys.cms.tag.TagProviderExtensionPoint;
054import org.ametys.cms.trash.element.TrashElementDAO;
055import org.ametys.core.cache.AbstractCacheManager;
056import org.ametys.core.cache.Cache;
057import org.ametys.core.observation.Event;
058import org.ametys.core.observation.ObservationManager;
059import org.ametys.core.observation.Observer;
060import org.ametys.core.right.RightManager;
061import org.ametys.core.right.RightManager.RightResult;
062import org.ametys.core.ui.Callable;
063import org.ametys.core.user.CurrentUserProvider;
064import org.ametys.core.user.UserIdentity;
065import org.ametys.plugins.core.impl.cache.AbstractCacheKey;
066import org.ametys.plugins.explorer.ExplorerNode;
067import org.ametys.plugins.explorer.resources.ModifiableResourceCollection;
068import org.ametys.plugins.explorer.resources.Resource;
069import org.ametys.plugins.repository.AmetysObject;
070import org.ametys.plugins.repository.AmetysObjectIterable;
071import org.ametys.plugins.repository.AmetysObjectIterator;
072import org.ametys.plugins.repository.AmetysObjectResolver;
073import org.ametys.plugins.repository.AmetysRepositoryException;
074import org.ametys.plugins.repository.CopiableAmetysObject;
075import org.ametys.plugins.repository.ModifiableAmetysObject;
076import org.ametys.plugins.repository.ModifiableTraversableAmetysObject;
077import org.ametys.plugins.repository.RemovableAmetysObject;
078import org.ametys.plugins.repository.RepositoryConstants;
079import org.ametys.plugins.repository.TraversableAmetysObject;
080import org.ametys.plugins.repository.UnknownAmetysObjectException;
081import org.ametys.plugins.repository.jcr.JCRAmetysObject;
082import org.ametys.plugins.repository.jcr.NameHelper;
083import org.ametys.plugins.repository.jcr.SimpleAmetysObject;
084import org.ametys.plugins.repository.lock.LockHelper;
085import org.ametys.plugins.repository.lock.LockableAmetysObject;
086import org.ametys.plugins.repository.query.expression.Expression.Operator;
087import org.ametys.plugins.repository.tag.TaggableAmetysObject;
088import org.ametys.plugins.repository.trash.TrashElement;
089import org.ametys.plugins.repository.trash.TrashableAmetysObject;
090import org.ametys.plugins.repository.version.VersionableAmetysObject;
091import org.ametys.plugins.workflow.support.WorkflowProvider;
092import org.ametys.plugins.workflow.support.WorkflowProvider.AmetysObjectWorkflow;
093import org.ametys.runtime.i18n.I18nizableText;
094import org.ametys.runtime.model.type.ModelItemTypeConstants;
095import org.ametys.web.ObservationConstants;
096import org.ametys.web.WebConstants;
097import org.ametys.web.alias.Alias.TargetType;
098import org.ametys.web.alias.AliasHelper;
099import org.ametys.web.alias.DefaultAlias;
100import org.ametys.web.repository.content.SharedContent;
101import org.ametys.web.repository.content.WebContent;
102import org.ametys.web.repository.content.shared.SharedContentManager;
103import org.ametys.web.repository.page.Page.LinkType;
104import org.ametys.web.repository.page.Page.PageType;
105import org.ametys.web.repository.page.ZoneItem.ZoneType;
106import org.ametys.web.repository.page.jcr.DefaultPage;
107import org.ametys.web.repository.site.Site;
108import org.ametys.web.repository.sitemap.Sitemap;
109import org.ametys.web.rights.PageRightAssignmentContext;
110import org.ametys.web.service.Service;
111import org.ametys.web.service.ServiceExtensionPoint;
112import org.ametys.web.site.CopyUpdaterExtensionPoint;
113import org.ametys.web.skin.Skin;
114import org.ametys.web.skin.SkinTemplate;
115import org.ametys.web.skin.SkinTemplateZone;
116import org.ametys.web.skin.SkinsManager;
117import org.ametys.web.skin.TemplatesAssignmentHandler;
118import org.ametys.web.synchronization.SynchronizeComponent;
119import org.ametys.web.tags.TagExpression;
120
121/**
122 * DAO for manipulating pages
123 */
124public class PageDAO extends AbstractSitemapElementsDAO implements Component, Initializable, Observer
125{
126    /** Constant for untouched binary metadata. */
127    public static final String __SERVICE_PARAM_UNTOUCHED_BINARY = "untouched";
128    
129    /** Avalon Role */
130    public static final String ROLE = PageDAO.class.getName();
131    
132    /** Constant for the {@link Cache} id for the pages in cache by sitename, lang, tag */
133    private static final String MEMORY_PAGESTAGCACHE = PageDAO.class.getName() + "$PagesUUIDByTag";
134    
135    private AmetysObjectResolver _resolver;
136    private ObservationManager _observationManager;
137    private CurrentUserProvider _currentUserProvider;
138    private SkinsManager _skinsManager;
139    private TemplatesAssignmentHandler _templatesHandler;
140    private ServicesAssignmentHandler _serviceHandler;
141    private ContentTypesAssignmentHandler _cTypeHandler;
142    private ContentTypeExtensionPoint _contentTypeExtensionPoint;
143    private ServiceExtensionPoint _serviceExtensionPoint;
144    private WorkflowProvider _workflowProvider;
145    private SharedContentManager _sharedContentManager;
146    private TagProviderExtensionPoint _tagProvider;
147    private CopySiteComponent _copySiteComponent;
148    private RightManager _rightManager;
149    private SynchronizeComponent _synchronizeComponent;
150    private Repository _repository;
151    private AmetysObjectResolver _ametysObjectResolver;
152    private AbstractCacheManager _cacheManager;
153    private CopyUpdaterExtensionPoint _copyUpdaterEP;
154    private TrashElementDAO _trashElementDAO;
155    
156    @Override
157    public void service(ServiceManager smanager) throws ServiceException
158    {
159        super.service(smanager);
160        
161        _resolver = (AmetysObjectResolver) smanager.lookup(AmetysObjectResolver.ROLE);
162        _ametysObjectResolver = (AmetysObjectResolver) smanager.lookup(AmetysObjectResolver.ROLE);
163        _observationManager = (ObservationManager) smanager.lookup(ObservationManager.ROLE);
164        _currentUserProvider = (CurrentUserProvider) smanager.lookup(CurrentUserProvider.ROLE);
165        _skinsManager = (SkinsManager) smanager.lookup(SkinsManager.ROLE);
166        _templatesHandler = (TemplatesAssignmentHandler) smanager.lookup(TemplatesAssignmentHandler.ROLE);
167        _cTypeHandler = (ContentTypesAssignmentHandler) smanager.lookup(ContentTypesAssignmentHandler.ROLE);
168        _serviceHandler = (ServicesAssignmentHandler) smanager.lookup(ServicesAssignmentHandler.ROLE);
169        _contentTypeExtensionPoint = (ContentTypeExtensionPoint) smanager.lookup(ContentTypeExtensionPoint.ROLE);
170        _serviceExtensionPoint = (ServiceExtensionPoint) smanager.lookup(ServiceExtensionPoint.ROLE);
171        _workflowProvider = (WorkflowProvider) smanager.lookup(WorkflowProvider.ROLE);
172        _sharedContentManager = (SharedContentManager) smanager.lookup(SharedContentManager.ROLE);
173        _tagProvider = (TagProviderExtensionPoint) smanager.lookup(TagProviderExtensionPoint.ROLE);
174        _copySiteComponent = (CopySiteComponent) smanager.lookup(CopySiteComponent.ROLE);
175        _rightManager = (RightManager) smanager.lookup(RightManager.ROLE);
176        _synchronizeComponent = (SynchronizeComponent) smanager.lookup(SynchronizeComponent.ROLE);
177        _repository = (Repository) smanager.lookup(Repository.class.getName());
178        _cacheManager = (AbstractCacheManager) smanager.lookup(AbstractCacheManager.ROLE);
179        _copyUpdaterEP = (CopyUpdaterExtensionPoint) smanager.lookup(CopyUpdaterExtensionPoint.ROLE);
180        _trashElementDAO = (TrashElementDAO) smanager.lookup(TrashElementDAO.ROLE);
181    }
182    
183    public void initialize() throws Exception
184    {
185        _createCaches();
186        _observationManager.registerObserver(this);
187    }
188    
189    private void _createCaches()
190    {
191        _cacheManager.createMemoryCache(MEMORY_PAGESTAGCACHE,
192                new I18nizableText("plugin.web", "PLUGINS_WEB_PAGEDAO_CACHE_PAGES_BY_TAG_LABEL"),
193                new I18nizableText("plugin.web", "PLUGINS_WEB_PAGEDAO_CACHE_PAGES_BY_TAG_DESCRIPTION"),
194                true,
195                null);
196    }
197    
198    public int getPriority()
199    {
200        return 0;
201    }
202    
203    public boolean supports(Event event)
204    {
205        return event.getId().equals(ObservationConstants.EVENT_PAGE_UPDATED) && event.getArguments().containsKey(ObservationConstants.ARGS_PAGE_TAGS);
206    }
207    
208    public void observe(Event event, Map<String, Object> transientVars) throws Exception
209    {
210        Page page = (Page) event.getArguments().get(ObservationConstants.ARGS_PAGE);
211        if (page != null)
212        {
213            _getMemoryPageTagCache().invalidate(PageTagCacheKey.of(page.getSiteName(), page.getSitemapName()));
214        }
215    }
216
217    /**
218     * Get the properties of given pages
219     * @param pageIds the id of pages
220     * @param zoneName The optional zone name to limit informations of zones to that zone. Can be null or empty to avoid limitation.
221     * @param zoneItemId The optional zone item identifier to limit informations of zones to that zone item. Can be null or empty to avoid limitation.
222     * @return the properties of pages in a result map
223     */
224    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
225    public Map<String, Object> getPagesInfos (List<String> pageIds, String zoneName, String zoneItemId)
226    {
227        // Assume that no read access is checked (required for bus message target)
228        Map<String, Object> result = new HashMap<>();
229        
230        List<Map<String, Object>> pages = new ArrayList<>();
231        List<String> pagesNotFound = new ArrayList<>();
232        
233        for (String pageId : pageIds)
234        {
235            try
236            {
237                Page page = _resolver.resolveById(pageId);
238                pages.add(getPageInfos(page, zoneName, zoneItemId));
239            }
240            catch (UnknownAmetysObjectException e)
241            {
242                pagesNotFound.add(pageId);
243            }
244        }
245        
246        result.put("pages", pages);
247        result.put("pagesNotFound", pagesNotFound);
248        
249        return result;
250    }
251    
252    /**
253     * Get the properties of given pages
254     * @param pageIds the id of pages
255     * @return the properties of pages in a result map
256     */
257    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
258    public Map<String, Object> getPagesInfos (List<String> pageIds)
259    {
260        // Assume that no read access is checked (required for bus message target)
261        return getPagesInfos(pageIds, null, null);
262    }
263    
264    /**
265     * Get the page's properties
266     * @param pageId the page ID
267     * @return the properties
268     */
269    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
270    public Map<String, Object> getPageInfos (String pageId)
271    {
272        // Assume that no read access is checked (required for bus message target)
273        return getPageInfos(pageId, null, null);
274    }
275    
276    /**
277     * Get the page's properties
278     * @param pageId the page ID
279     * @param zoneName The optional zone name to limit informations of zones to that zone. Can be null or empty to avoid limitation.
280     * @param zoneItemId The optional zone item identifier to limit informations of zones to that zone item. Can be null or empty to avoid limitation.
281     * @return the properties
282     */
283    public Map<String, Object> getPageInfos (String pageId, String zoneName, String zoneItemId)
284    {
285        // Assume that no read access is checked (required for bus message target)
286        Page page = _resolver.resolveById(pageId);
287        return getPageInfos(page, zoneName, zoneItemId);
288    }
289
290    /**
291     * Get the page's properties
292     * @param page the page
293     * @param zoneName The optional zone name to limit informations of zones to that zone. Can be null or empty to avoid limitation.
294     * @param zoneItemId The optional zone item identifier to limit informations of zones to that zone item. Can be null or empty to avoid limitation.
295     * @return the properties
296     */
297    public Map<String, Object> getPageInfos (Page page, String zoneName, String zoneItemId)
298    {
299        Map<String, Object> infos = new HashMap<>();
300        boolean isLocked = page instanceof LockablePage lockablePage && lockablePage.isLocked();
301        infos.put("id", page.getId());
302        infos.put("name", page.getName());
303        infos.put("parentId", page.getParent().getId());
304        infos.put("title", page.getTitle());
305        infos.put("longTitle", page.getLongTitle());
306        infos.put("path", page.getPathInSitemap());
307        infos.put("siteName", page.getSiteName());
308        infos.put("type", page.getType());
309        infos.put("lang", page.getSitemapName());
310        infos.put("isModifiable", page instanceof ModifiablePage);
311        infos.put("isMoveable", page instanceof MoveablePage && !isLocked);
312        infos.put("isTaggable", page instanceof TaggableAmetysObject);
313        infos.put("isVisible", page.isVisible());
314        infos.put("isParentInvisible", _isParentInvisible(page));
315        infos.put("locked", isLocked);
316        
317        // Publication information
318        infos.put("publication", _publication2Json(page));
319        infos.put("isPreviewable", _isPreviewable(page));
320        
321        // limitation information
322        if (StringUtils.isNotBlank(zoneName))
323        {
324            infos.put("zoneName", zoneName);
325        }
326        if (StringUtils.isNotBlank(zoneItemId))
327        {
328            infos.put("zoneItemId", zoneItemId);
329        }
330
331        String skinId = page.getSite().getSkinId();
332        Skin skin = _skinsManager.getSkin(skinId);
333        infos.put("isPageValid", _synchronizeComponent.isPageValid(page, skin));
334
335        Session liveSession = null;
336        try
337        {
338            liveSession = _repository.login(WebConstants.LIVE_WORKSPACE);
339            infos.put("isLiveHierarchyValid", _synchronizeComponent.isHierarchyValid(page, liveSession));
340        }
341        catch (RepositoryException e)
342        {
343            throw new RuntimeException("Unable to check live workspace", e);
344        }
345        finally
346        {
347            if (liveSession != null)
348            {
349                liveSession.logout();
350            }
351        }
352
353        PageType type = page.getType();
354        switch (type)
355        {
356            case CONTAINER:
357                infos.putAll(_sitemapElement2json(page, zoneName, zoneItemId));
358                break;
359            
360            case LINK:
361                infos.put("url", page.getURL());
362                
363                LinkType urlType = page.getURLType();
364                infos.put("urlType", urlType.toString());
365                
366                switch (urlType)
367                {
368                    case PAGE:
369                        try
370                        {
371                            Page targetPage = _resolver.resolveById(page.getURL());
372                            infos.put("urlTitle", targetPage.getTitle());
373                        }
374                        catch (UnknownAmetysObjectException e)
375                        {
376                            getLogger().error("Page '" + page.getId() + "' redirects to an unexisting page '" + page.getURL() + "'");
377                        }
378                        break;
379
380                    default:
381                        break;
382                }
383                break;
384            default:
385                AmetysObjectIterator< ? extends Page> iterator = page.getChildrenPages().iterator();
386                if (iterator.hasNext())
387                {
388                    Page firstSubPage = iterator.next();
389                    infos.put("url", firstSubPage.getId());
390                    infos.put("urlTitle", firstSubPage.getTitle());
391                    infos.put("urlType", LinkType.PAGE.toString());
392                    break;
393
394                }
395                break;
396        }
397        
398        
399        infos.put("rights", getUserRights(page));
400        
401        return infos;
402    }
403    
404    /**
405     * Get the page's properties
406     * @param page the page
407     * @return the properties
408     */
409    public Map<String, Object> getPageInfos (Page page)
410    {
411        return getPageInfos(page, null, null);
412    }
413    
414    /**
415     * Check current user right on given page or sitemap
416     * @param id The id of the page or sitemap
417     * @param rightId The if of right to check
418     * @return true if user has right
419     */
420    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
421    public boolean hasRight(String id, String rightId)
422    {
423        UserIdentity user = _currentUserProvider.getUser();
424        SitemapElement page = _resolver.resolveById(id);
425        
426        return _rightManager.hasRight(user, rightId, page) == RightResult.RIGHT_ALLOW;
427    }
428    
429    /**
430     * Create a new page
431     * @param parentId The parent id. Can not be null.
432     * @param title The page's title. Can not be null.
433     * @param longTitle The page's long title. Can be null or blank.
434     * @return The result map with id of created page
435     */
436    @Callable (rights = "Web_Rights_Page_Create", rightContext = PageRightAssignmentContext.ID, paramIndex = 0)
437    public Map<String, Object> createPage (String parentId, String title, String longTitle)
438    {
439        Map<String, Object> result = new HashMap<>();
440        
441        SitemapElement parent = _resolver.resolveById(parentId);
442        
443        assert parent instanceof ModifiableSitemapElement;
444        
445        try
446        {
447            ModifiablePage page = createPage((ModifiableSitemapElement) parent, null, title, longTitle);
448            result.put("id", page.getId());
449            result.put("parentId", parent.getId());
450            result.put("lang", page.getSitemapName());
451            result.put("title", page.getTitle());
452        }
453        catch (IllegalArgumentException e)
454        {
455            result.put("invalid-name", title);
456        }
457        return result;
458    }
459
460    /**
461     * Create a new page blank page.
462     * 
463     * In the case where {@code name} is {@code null}, the value of title will be used.
464     * In all case, the name (or title) will be used to derive the page name (and therefore
465     * path by filtering it with {@link NameHelper#filterName(String)} and appending an integer
466     * if a page with the same name already exist.
467     * 
468     * @param parent the sitemap element where the new page is created
469     * @param name the name to use as path for the page. (if null, the title will be used)
470     * @param title the title of the page
471     * @param longTitle the long title of the page
472     * @return The result map with id of the created page
473     * @throws IllegalArgumentException If the provided name (or title) can not be used in URI
474     */
475    public ModifiablePage createPage(ModifiableSitemapElement parent, String name, String title, String longTitle) throws IllegalArgumentException
476    {
477        Site site = parent.getSite();
478        
479        String pageName = PageDAO.findFreePageNameByTitle(parent, name != null ? name : title);
480        
481        ModifiablePage page = parent.createChild(pageName, "ametys:defaultPage");
482        
483        page.setTitle(title);
484        page.setType(PageType.NODE);
485        page.setSiteName(site.getName());
486        page.setSitemapName(page.getSitemap().getName());
487        
488        if (!StringUtils.isBlank(longTitle))
489        {
490            page.setLongTitle(longTitle);
491        }
492        
493        site.saveChanges();
494        
495        Map<String, Object> eventParams = new HashMap<>();
496        eventParams.put(ObservationConstants.ARGS_PAGE, page);
497        _observationManager.notify(new Event(ObservationConstants.EVENT_PAGE_ADDED, _currentUserProvider.getUser(), eventParams));
498        
499        return page;
500    }
501
502    /**
503     * Copy a page
504     * @param id The id of page to copy
505     * @param target The id of parent target page
506     * @param keepReferences true to keep references
507     * @return the result map
508     * @throws RepositoryException if an error occurred during copy
509     */
510    @Callable (rights = "Web_Rights_Page_Create", rightContext = PageRightAssignmentContext.ID, paramIndex = 1)
511    public Map<String, String> copyPage (String id, String target, boolean keepReferences) throws RepositoryException
512    {
513        Map<String, String> result = new HashMap<>();
514        
515        Page page = _resolver.resolveById(id);
516        
517        if (!(page instanceof CopiableAmetysObject))
518        {
519            throw new IllegalArgumentException("The page '" + page.getId() + "' is not a copiable ametys object, it can not be copied");
520        }
521        
522        if (!(page instanceof JCRAmetysObject))
523        {
524            throw new IllegalArgumentException("The page '" + page.getId() + "' is not a JCR ametys object, it can not be copied");
525        }
526        
527        SitemapElement parent = _resolver.resolveById(target);
528        
529        if (parent instanceof ModifiableSitemapElement modifiableParent)
530        {
531            ModifiablePage cPage = null;
532            
533            String initialTitle;
534            if (page instanceof DefaultPage defaultPage)
535            {
536                initialTitle = defaultPage.getInitialTitle();
537            }
538            else
539            {
540                initialTitle = page.getTitle();
541            }
542            
543            String newPageName = PageDAO.findFreePageNameByTitle(parent, initialTitle);
544            
545            if (!keepReferences)
546            {
547                // Restrict the copy to the page and its current children to avoid infinitive loop
548                List<String> pagesToCopy = new ArrayList<>();
549                pagesToCopy.add(page.getId());
550                pagesToCopy.addAll(_getChildrenPageIds(page));
551                
552                // Copy and duplicate contents
553                cPage = (ModifiablePage) ((CopiableAmetysObject) page).copyTo(modifiableParent, newPageName, pagesToCopy);
554                _copySiteComponent.updateReferencesAfterCopy(page, cPage);
555                
556                // Creates the first version on all copied contents
557                _updateContentsAfterCopy(page, cPage);
558            }
559            else
560            {
561                // Copy without duplicating contents (keep references)
562                
563                if (!page.getSitemapName().equals(modifiableParent.getSitemapName()))
564                {
565                    throw new IllegalArgumentException("The page '" + page.getId() + "' from sitemap '" + page.getSitemapName() + "' cannot be copied to a different sitemap '" + parent.getSitemapName() + "' while keeping references to contents");
566                }
567                
568                
569                String pagePath = ((SimpleAmetysObject) parent).getNode().getPath() + "/" + newPageName;
570                Node node = ((JCRAmetysObject) page).getNode();
571                node.getSession().getWorkspace().copy(node.getPath(), pagePath);
572                
573                cPage = modifiableParent.getChild(newPageName);
574            }
575            
576            cPage.setTitle(PageDAO.findFreeTitle(modifiableParent, initialTitle));
577            if (cPage instanceof DefaultPage defaultPage)
578            {
579                defaultPage.setInitialTitle(initialTitle);
580            }
581            
582            ((ModifiableTraversableAmetysObject) parent).saveChanges();
583            result.put("id", cPage.getId());
584            
585            Map<String, Object> eventParams = new HashMap<>();
586            eventParams.put(ObservationConstants.ARGS_PAGE, cPage);
587            _observationManager.notify(new Event(ObservationConstants.EVENT_PAGE_ADDED, _currentUserProvider.getUser(), eventParams));
588        }
589       
590        return result;
591    }
592
593    /**
594     * find a free name under a given parent node
595     * @param parent The parent
596     * @param initialTitle The title of the page
597     * @return The free name
598     */
599    public static String findFreePageNameByTitle(TraversableAmetysObject parent, String initialTitle)
600    {
601        return findFreePageNameByTitle(parent, initialTitle, null);
602    }
603    
604    /**
605     * find a free name under a given parent node
606     * @param parent The parent
607     * @param initialTitle The title of the page
608     * @param currentName The current name of the node, that can thus be authorized
609     * @return The free name
610     */
611    public static String findFreePageNameByTitle(TraversableAmetysObject parent, String initialTitle, String currentName)
612    {
613        String originalPageName = NameHelper.filterName(initialTitle);
614        
615        return findFreePageName(parent, originalPageName, currentName);
616    }
617
618    /**
619     * find a free name under a given parent node
620     * @param parent The parent
621     * @param originalPageName The name of the page
622     * @return The free name
623     */
624    public static String findFreePageName(TraversableAmetysObject parent, String originalPageName)
625    {
626        return findFreePageName(parent, originalPageName, null);
627    }
628    
629    /**
630     * find a free name under a given parent node
631     * @param parent The parent
632     * @param originalPageName The name of the page
633     * @param currentName The current name of the node, that can thus be authorized
634     * @return The free name
635     */
636    public static String findFreePageName(TraversableAmetysObject parent, String originalPageName, String currentName)
637    {
638        int index = 2;
639        
640        String pageName = originalPageName;
641        while (parent.hasChild(pageName) && !pageName.equals(currentName))
642        {
643            // Find unused name on new parent node
644            pageName = originalPageName + "-" + index++;
645        }
646        return pageName;
647    }
648    
649    /**
650     * find a free title under a given parent node
651     * @param parent The parent
652     * @param initialTitle The title of the page
653     * @return The free title
654     */
655    public static String findFreeTitle(SitemapElement parent, String initialTitle)
656    {
657        List<String> existingTitles = parent.getChildrenPages().stream().map(Page::getTitle).toList();
658        
659        int index = 2;
660        
661        String title = initialTitle;
662        while (existingTitles.contains(title))
663        {
664            title = initialTitle + " (" + index++ + ")";
665        }
666        
667        return title;
668    }
669    
670    /**
671     * Copy a page under another page
672     * @param targetId the page to copy in
673     * @param sourceId the page to copy
674     * @param keepReferences true to keep references for contents, or false to duplicate contents
675     * @return The id of created page is a result map.
676     * @throws RepositoryException if an error occurs
677     */
678    @Callable (rights = "Web_Rights_Page_Create", rightContext = PageRightAssignmentContext.ID, paramIndex = 0)
679    @Deprecated
680    public Map<String, String> pastePage(String targetId, String sourceId, boolean keepReferences) throws RepositoryException
681    {
682        return copyPage(sourceId, targetId, keepReferences);
683    }
684    
685    /**
686     * Move a page
687     * @param id The page id
688     * @param parentId The id of parent destination
689     * @param index The position in parent child nodes where page will be inserted. -1 means as the last child.
690     * @return the result map
691     */
692    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
693    public Map<String, Object> movePage (String id, String parentId, int index)
694    {
695        Map<String, Object> result = new HashMap<>();
696        
697        String oldPathInSitemap = null;
698        String newPathInSitemap = null;
699     
700        Page page = _resolver.resolveById(id);
701        SitemapElement srcParent = page.getParent();
702        oldPathInSitemap = page.getPathInSitemap();
703        Sitemap sitemap = page.getSitemap();
704        
705        // check rights of deletion on old parent page
706        if (!srcParent.getId().equals(parentId) && !_canDelete(page))
707        {
708            throw new IllegalStateException("You do not have the rights to delete the page '/" + page.getSitemapName() + "/" + oldPathInSitemap + "'");
709        }
710        
711        if (!(page instanceof MoveablePage))
712        {
713            throw new IllegalArgumentException("The page '/" + page.getSitemapName() + "/" + oldPathInSitemap + "' is not a moveable page");
714        }
715        
716        if (srcParent.getId().equals(parentId) && index != -1)
717        {
718            try
719            {
720                // in case of reorder, check right of creation on current parent page
721                if (!_canCreate(srcParent))
722                {
723                    throw new IllegalStateException("You do not have to reorder a page under '/" + srcParent.getSitemapName() + "/" + srcParent.getPathInSitemap() + "'");
724                }
725                
726                Page brother = srcParent.getChildPageAt(index);
727                ((MoveablePage) page).orderBefore(brother);
728            }
729            catch (UnknownAmetysObjectException e)
730            {
731                // Move the last child position
732                ((MoveablePage) page).orderBefore(null);
733            }
734            
735            // Path is not modified
736            newPathInSitemap = oldPathInSitemap;
737        }
738        else
739        {
740            SitemapElement newParentPage = _resolver.resolveById(parentId);
741            
742            if (page instanceof LockablePage lockablePage && lockablePage.isLocked())
743            {
744                throw new IllegalStateException("You cannot move page '/" + lockablePage.getSitemapName() + "/" + lockablePage.getPathInSitemap() + "' because it is locked");
745            }
746
747            // check right of creation on new parent page
748            if (!_canCreate(newParentPage))
749            {
750                throw new IllegalStateException("You do not have the rights to create a page under '/" + newParentPage.getSitemapName() + "/" + newParentPage.getPathInSitemap() + "'");
751            }
752
753            ((MoveablePage) page).moveTo(newParentPage, true);
754            if (index != -1)
755            {
756                Page brother = newParentPage.getChildPageAt(index);
757                
758                ((MoveablePage) page).orderBefore(brother);
759            }
760            
761            // Path is modified
762            newPathInSitemap = page.getPathInSitemap();
763        }
764        
765        if (sitemap.needsSave())
766        {
767            sitemap.saveChanges();
768        }
769
770        // Notify observers that the page has been moved
771        Map<String, Object> eventParams = new HashMap<>();
772        eventParams.put(ObservationConstants.ARGS_SITEMAP, sitemap);
773        eventParams.put(ObservationConstants.ARGS_PAGE, page);
774        eventParams.put("page.old.path", oldPathInSitemap);
775        eventParams.put("page.old.parent", srcParent);
776        eventParams.put(ObservationConstants.ARGS_PAGE_PATH, newPathInSitemap);
777        _observationManager.notify(new Event(ObservationConstants.EVENT_PAGE_MOVED, _currentUserProvider.getUser(), eventParams));
778        
779        result.put("id", page.getId());
780        result.put("parentId", page.getParent().getId());
781        
782        return result;
783    }
784    
785    private boolean _canCreate(SitemapElement parentPage)
786    {
787        UserIdentity user = _currentUserProvider.getUser();
788        if (_rightManager.hasRight(user, "Web_Rights_Page_Create", parentPage) == RightResult.RIGHT_ALLOW)
789        {
790            return true;
791        }
792        
793        if (getLogger().isInfoEnabled())
794        {
795            getLogger().info("The user '" + user + "' tried to create page under '/" + parentPage.getSitemapName() + "/" + parentPage.getPathInSitemap() + "' without sufficient rights");
796        }
797        
798        return false;
799    }
800    
801    private boolean _canDelete(Page page)
802    {
803        UserIdentity user = _currentUserProvider.getUser();
804        SitemapElement parent = page.getParent();
805        if (_rightManager.hasRight(user, "Web_Rights_Page_Delete", parent) == RightResult.RIGHT_ALLOW)
806        {
807            return true;
808        }
809        
810        if (getLogger().isInfoEnabled())
811        {
812            getLogger().info("The user '" + user + "' tried to move page '/" + page.getSitemapName() + "/" + page.getPathInSitemap() + "' without sufficient rights");
813        }
814        
815        return false;
816    }
817    
818    /**
819     * Set pages as redirection
820     * @param pageIds the id of pages to modify
821     * @param url the url of redirection
822     * @param urlType the type of redirection
823     * @return the id of pages which succeeded or failed.
824     */
825    @Callable (rights =  Callable.SKIP_BUILTIN_CHECK)
826    public Map<String, Object> setLink (List<String> pageIds, String url, String urlType)
827    {
828        List<String> allRightIds = new ArrayList<>();
829        List<Map<String, Object>> noRightPages = new ArrayList<>();
830        List<Map<String, Object>> errorPages = new ArrayList<>();
831        List<Map<String, Object>> noModifiablePages = new ArrayList<>();
832        List<Map<String, Object>> lockedPages = new ArrayList<>();
833        
834        if (StringUtils.isEmpty(url))
835        {
836            throw new IllegalArgumentException("Can not set page as a redirection with an empty url");
837        }
838        
839        for (String pageId : pageIds)
840        {
841            Page page = _resolver.resolveById(pageId);
842            
843            try
844            {
845                if (_rightManager.currentUserHasRight("Web_Rights_Page_LinkPage", page) != RightResult.RIGHT_ALLOW)
846                {
847                    noRightPages.add(Map.of("id", pageId, "title", page.getTitle()));
848                }
849                else if (!(page instanceof ModifiablePage))
850                {
851                    noModifiablePages.add(Map.of("id", pageId, "title", page.getTitle()));
852                }
853                else if (page instanceof LockablePage lockablePage && lockablePage.isLocked())
854                {
855                    lockedPages.add(Map.of("id", pageId, "title", page.getTitle()));
856                }
857                else
858                {
859                    ModifiablePage mPage = (ModifiablePage) page;
860                    
861                    if (page.getType().equals(PageType.CONTAINER))
862                    {
863                        // Remove zones
864                        for (ModifiableZone zone : mPage.getZones())
865                        {
866                            zone.remove();
867                        }
868                    }
869                    
870                    if (pageId.equals(url))
871                    {
872                        throw new IllegalArgumentException("A page can not redirect to itself");
873                    }
874                    
875                    mPage.setType(PageType.LINK);
876                    mPage.setURL(LinkType.valueOf(urlType), url);
877                    mPage.getSitemap().saveChanges();
878                    
879                    allRightIds.add(pageId);
880                    
881                    Map<String, Object> eventParams = new HashMap<>();
882                    eventParams.put(ObservationConstants.ARGS_PAGE, page);
883                    _observationManager.notify(new Event(ObservationConstants.EVENT_PAGE_CHANGED, _currentUserProvider.getUser(), eventParams));
884                }
885            }
886            catch (Exception e)
887            {
888                getLogger().error("Cannot set the page '" + pageId + "' as link [" + url + ", " + urlType.toString() + "]", e);
889                errorPages.add(Map.of("id", pageId, "title", page.getTitle()));
890            }
891        }
892
893        return Map.of("allright-pages", allRightIds, "noright-pages", noRightPages, "error-pages", errorPages, "nomodifiable-pages", noModifiablePages, "locked-pages", lockedPages);
894    }
895    
896    /**
897     * Get available template for specified page
898     * @param pageId The page's id
899     * @return the list of available template as JSON
900     */
901    private List<Map<String, Object>> _getAvailableTemplates (String pageId)
902    {
903        List<Map<String, Object>> templates = new ArrayList<>();
904        
905        Page page = _resolver.resolveById(pageId);
906        
907        Set<String> availableTemplateIds = _templatesHandler.getAvailablesTemplates(page);
908        for (String templateName : availableTemplateIds)
909        {
910            String skinId = page.getSite().getSkinId();
911            Skin skin = _skinsManager.getSkin(skinId);
912            
913            SkinTemplate template = skin.getTemplate(templateName);
914            
915            Map<String, Object> template2json = new HashMap<>();
916            template2json.put("id", template.getId());
917            template2json.put("label", template.getLabel());
918            template2json.put("description", template.getDescription());
919            template2json.put("iconSmall", template.getSmallImage());
920            template2json.put("iconMedium", template.getMediumImage());
921            template2json.put("iconLarge", template.getLargeImage());
922            template2json.put("zone", template.getDefaultZoneId());
923            
924            templates.add(template2json);
925        }
926        
927        return templates;
928    }
929    
930    /**
931     * Get available content types for specified page
932     * @param pageId The page's id
933     * @param zoneName the name of the zone
934     * @return the list of available content types
935     */
936    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
937    public List<Map<String, Object>> getAvailableContentTypes (String pageId, String zoneName)
938    {
939        List<Map<String, Object>> contenttypes = new ArrayList<>();
940        
941        Page page = _resolver.resolveById(pageId);
942        
943        Set<String> contentTypeIds = _cTypeHandler.getAvailableContentTypes(page, zoneName);
944        for (String contentTypeId : contentTypeIds)
945        {
946            ContentType cType = _contentTypeExtensionPoint.getExtension(contentTypeId);
947            
948            if (cType != null && _hasRight(cType, page))
949            {
950                Map<String, Object> ctype2json = new HashMap<>();
951                ctype2json.put("id", cType.getId());
952                ctype2json.put("label", cType.getLabel());
953                ctype2json.put("description", cType.getDescription());
954                ctype2json.put("iconGlyph", cType.getIconGlyph());
955                ctype2json.put("iconDecorator", cType.getIconDecorator());
956                ctype2json.put("iconSmall", cType.getSmallIcon());
957                ctype2json.put("iconMedium", cType.getMediumIcon());
958                ctype2json.put("iconLarge", cType.getLargeIcon());
959                ctype2json.put("defaultTitle", cType.getDefaultTitle());
960                ctype2json.put("viewNames", cType.getViewNames(true));
961                ctype2json.put("category", cType.getCategory().isI18n() ? cType.getCategory().getKey() : cType.getCategory().getLabel());
962                ctype2json.put("categoryLabel", cType.getCategory());
963                
964                contenttypes.add(ctype2json);
965            }
966        }
967        
968        return contenttypes;
969    }
970    
971    /**
972     * Get available content types for a page being created
973     * @param pageId The page's id. Can be null of the page is not yet created
974     * @param zoneName the name of the zone
975     * @param parentId The id of parent page
976     * @param pageTitle The title of page to create
977     * @param template The template of page to create
978     * @return the list of available services
979     */
980    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
981    public List<Map<String, Object>> getAvailableContentTypesForCreation(String pageId, String zoneName, String parentId, String pageTitle, String template)
982    {
983        if (StringUtils.isNotEmpty(pageId))
984        {
985            // Get available services for a page
986            return getAvailableContentTypes(pageId, zoneName);
987        }
988        else if (StringUtils.isNotEmpty(parentId))
989        {
990            // Get available services for a not yet existing page
991            SitemapElement parent = _resolver.resolveById(parentId);
992            
993            // Create page temporarily
994            Page page = _createPage(parent, pageTitle, template);
995            
996            List<Map<String, Object>> availableContentTypes = getAvailableContentTypes(page.getId(), zoneName);
997            
998            // Cancel page creation
999            page.getSitemap().revertChanges();
1000            
1001            return availableContentTypes;
1002        }
1003        
1004        return Collections.EMPTY_LIST;
1005    }
1006    
1007    private boolean _hasRight(ContentType contentType, Page page)
1008    {
1009        String right = contentType.getRight();
1010        
1011        if (right == null)
1012        {
1013            return true;
1014        }
1015        else
1016        {
1017            UserIdentity user = _currentUserProvider.getUser();
1018            return _rightManager.hasRight(user, right, page) == RightResult.RIGHT_ALLOW;
1019        }
1020    }
1021    
1022    /**
1023     * Get available services for specified page
1024     * @param pageId The page's id
1025     * @param zoneName the name of the zone
1026     * @return the list of available services
1027     */
1028    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
1029    public List<Map<String, Object>> getAvailableServices (String pageId, String zoneName)
1030    {
1031        List<Map<String, Object>> services = new ArrayList<>();
1032        
1033        SitemapElement sitemapElement = _resolver.resolveById(pageId);
1034        
1035        Set<String> serviceIds = _serviceHandler.getAvailableServices(sitemapElement, zoneName);
1036        for (String serviceId : serviceIds)
1037        {
1038            Service service = _serviceExtensionPoint.getExtension(serviceId);
1039            if (service != null && _hasRight(service, sitemapElement))
1040            {
1041                Map<String, Object> serviceMap = new HashMap<>();
1042                serviceMap.put("id", service.getId());
1043                serviceMap.put("label", service.getLabel());
1044                serviceMap.put("description", service.getDescription());
1045                serviceMap.put("iconGlyph", service.getIconGlyph());
1046                serviceMap.put("iconDecorator", service.getIconDecorator());
1047                serviceMap.put("iconSmall", service.getSmallIcon());
1048                serviceMap.put("iconMedium", service.getMediumIcon());
1049                serviceMap.put("iconLarge", service.getLargeIcon());
1050                serviceMap.put("parametersAction", service.getParametersScript().getScriptClassname());
1051                serviceMap.put("category", service.getCategory().isI18n() ? service.getCategory().getKey() : service.getCategory().getLabel());
1052                serviceMap.put("categoryLabel", service.getCategory());
1053                
1054                services.add(serviceMap);
1055            }
1056        }
1057        
1058        return services;
1059    }
1060    
1061    /**
1062     * Get available services for a page being created
1063     * @param pageId The page's id. Can be null of the page is not yet created
1064     * @param zoneName the name of the zone
1065     * @param parentId The id of parent page
1066     * @param pageTitle The title of page to create
1067     * @param template The template of page to create
1068     * @return the list of available services
1069     */
1070    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
1071    public List<Map<String, Object>> getAvailableServicesForCreation(String pageId, String zoneName, String parentId, String pageTitle, String template)
1072    {
1073        if (StringUtils.isNotEmpty(pageId))
1074        {
1075            // Get available services for a page
1076            return getAvailableServices(pageId, zoneName);
1077        }
1078        else if (StringUtils.isNotEmpty(parentId))
1079        {
1080            // Get available services for a not yet existing page
1081            SitemapElement parent = _resolver.resolveById(parentId);
1082            
1083            // Create page temporarily
1084            Page page = _createPage(parent, pageTitle, template);
1085            
1086            List<Map<String, Object>> availableServices = getAvailableServices(page.getId(), zoneName);
1087            
1088            // Cancel page creation
1089            page.getSitemap().revertChanges();
1090            
1091            return availableServices;
1092        }
1093        
1094        return Collections.EMPTY_LIST;
1095    }
1096    
1097    private Page _createPage (SitemapElement parent, String pageTitle, String template)
1098    {
1099        Site site = parent.getSite();
1100        
1101        String pageName = PageDAO.findFreePageNameByTitle(parent, pageTitle);
1102        
1103        ModifiablePage page = ((ModifiableTraversableAmetysObject) parent).createChild(pageName, "ametys:defaultPage");
1104        
1105        page.setTitle(pageTitle);
1106        page.setType(PageType.NODE);
1107        page.setSiteName(site.getName());
1108        page.setSitemapName(page.getSitemap().getName());
1109
1110        if (template != null)
1111        {
1112            String skinId = page.getSite().getSkinId();
1113            SkinTemplate tpl = _skinsManager.getSkin(skinId).getTemplate(template);
1114            if (tpl == null)
1115            {
1116                throw new IllegalStateException("Template '" + template + "' does not exist on skin '" + skinId + "'");
1117            }
1118            
1119            // Set temporary the template to get available services
1120            page.setType(PageType.CONTAINER);
1121            page.setTemplate(template);
1122        }
1123        
1124        return page;
1125    }
1126    
1127    private boolean _hasRight(Service service, SitemapElement sitemapElement)
1128    {
1129        String right = service.getRight();
1130        
1131        if (right == null)
1132        {
1133            return true;
1134        }
1135        else
1136        {
1137            UserIdentity user = _currentUserProvider.getUser();
1138            return _rightManager.hasRight(user, right, sitemapElement) == RightResult.RIGHT_ALLOW;
1139        }
1140    }
1141    
1142    /**
1143     * Get available content types for a page being created
1144     * @param pageId The page's id. Can be null of the page is not yet created
1145     * @param parentId The id of parent page
1146     * @param pageTitle The title of page to create
1147     * @return the list of available services
1148     */
1149    @Callable (rights = "Web_Rights_Page_Create", rightContext = PageRightAssignmentContext.ID, paramIndex = 1)
1150    public List<Map<String, Object>> getAvailableTemplatesForCreation (String pageId, String parentId, String pageTitle)
1151    {
1152        if (StringUtils.isNotEmpty(pageId))
1153        {
1154            // Get available template for a page
1155            return _getAvailableTemplates(pageId);
1156        }
1157        else if (StringUtils.isNotEmpty(parentId))
1158        {
1159            // Get available template for a not yet existing page
1160            SitemapElement parent = _resolver.resolveById(parentId);
1161            
1162            // Create page temporarily
1163            Page page = _createPage(parent, pageTitle, null);
1164            
1165            List<Map<String, Object>> availableTemplates = _getAvailableTemplates(page.getId());
1166            
1167            // Cancel page creation
1168            page.getSitemap().revertChanges();
1169            
1170            return availableTemplates;
1171        }
1172        
1173        return Collections.EMPTY_LIST;
1174    }
1175    
1176    /**
1177     * Get service info
1178     * @param pageId Optional, the page id of the service. To get some basic info about the page.
1179     * @param serviceId The id of the service
1180     * @return a Map containing some info about the service (label, url..)
1181     */
1182    @Callable (rights = Callable.READ_ACCESS, rightContext = PageRightAssignmentContext.ID, paramIndex = 0)
1183    public Map<String, Object> getServiceInfo(String pageId, String serviceId)
1184    {
1185        Map<String, Object> info = new HashMap<>();
1186        
1187        if (StringUtils.isNotEmpty(pageId))
1188        {
1189            SitemapElement sitemapElement = _resolver.resolveById(pageId);
1190            info.put("page-id", sitemapElement.getId());
1191            info.put("page-title", sitemapElement.getTitle());
1192        }
1193
1194        Service service = _serviceExtensionPoint.getExtension(serviceId);
1195        info.put("id", service.getId());
1196        info.put("label", service.getLabel());
1197        info.put("url", service.getURL());
1198        info.put("smallIcon", service.getSmallIcon());
1199        info.put("iconGlyph", service.getIconGlyph());
1200        info.put("iconDecorator", service.getIconDecorator());
1201        return info;
1202    }
1203    
1204    /**
1205     * Rename a page
1206     * @param pageId The id of page to rename
1207     * @param title The page's  title
1208     * @param longTitle The page's long title.
1209     * @param updatePath true to update page's path
1210     * @param createAlias true to create a alias
1211     * @return the result map
1212     */
1213    @Callable (rights = "Web_Rights_Page_Rename", rightContext = PageRightAssignmentContext.ID, paramIndex = 0)
1214    public Map<String, Object> renamePage (String pageId, String title, String longTitle, boolean updatePath, boolean createAlias)
1215    {
1216        Map<String, Object> result = new HashMap<>();
1217        
1218        Page page = _resolver.resolveById(pageId);
1219        
1220        if (!(page instanceof ModifiablePage))
1221        {
1222            throw new IllegalArgumentException("Can not rename a non-modifiable page '/" + page.getSitemapName() + "/" + page.getPathInSitemap() + "'");
1223        }
1224        
1225        if (page instanceof LockablePage lockablePage && lockablePage.isLocked())
1226        {
1227            throw new IllegalArgumentException("Can not rename a locked page '/" + page.getSitemapName() + "/" + page.getPathInSitemap() + "'");
1228        }
1229        
1230        ModifiablePage mPage = (ModifiablePage) page;
1231        mPage.setTitle(title);
1232        mPage.setLongTitle(longTitle);
1233
1234        if (updatePath)
1235        {
1236            String oldPathInSitemap = page.getPathInSitemap();
1237            String oldPath = "/" + page.getSitemapName() + "/" + page.getPathInSitemap() + ".html";
1238            String oldPathForChild = "/" + page.getSitemapName() + "/" + page.getPathInSitemap() + "/**.html";
1239
1240            String pageName = "";
1241            try
1242            {
1243                pageName = PageDAO.findFreePageNameByTitle(mPage.getParent(), title, page.getName());
1244            }
1245            catch (IllegalArgumentException e)
1246            {
1247                result.put("invalid-name", title);
1248                return result;
1249            }
1250            
1251            if (!page.getName().equals(pageName))
1252            {
1253                mPage.rename(pageName);
1254                
1255                if (createAlias)
1256                {
1257                    ModifiableTraversableAmetysObject rootNode = AliasHelper.getRootNode(page.getSite());
1258                    
1259                    DefaultAlias alias = rootNode.createChild(AliasHelper.getAliasNextUniqueName(rootNode), "ametys:alias");
1260                    alias.setUrl(oldPath);
1261                    alias.setTarget(page.getId());
1262                    alias.setType(TargetType.PAGE);
1263                    alias.setCreationDate(new Date());
1264
1265                    // Alias for child pages
1266                    alias = rootNode.createChild(AliasHelper.getAliasNextUniqueName(rootNode), "ametys:alias");
1267                    alias.setUrl(oldPathForChild);
1268                    alias.setTarget("/" + page.getSitemapName() + "/" + page.getPathInSitemap() + "/{1}.html");
1269                    alias.setType(TargetType.URL);
1270                    alias.setCreationDate(new Date());
1271
1272                    rootNode.saveChanges();
1273                }
1274                
1275                // Notify observers that the page has been renamed
1276                Map<String, Object> eventParams = new HashMap<>();
1277                eventParams.put(ObservationConstants.ARGS_PAGE, page);
1278                eventParams.put("path.old.path", oldPathInSitemap);
1279                eventParams.put(ObservationConstants.ARGS_PAGE_PATH, page.getPathInSitemap());
1280                _observationManager.notify(new Event(ObservationConstants.EVENT_PAGE_RENAMED, _currentUserProvider.getUser(), eventParams));
1281                
1282            }
1283            else
1284            {
1285                // Notify observers that the page's title has been modified
1286                Map<String, Object> eventParams = new HashMap<>();
1287                eventParams.put(ObservationConstants.ARGS_PAGE, page);
1288                _observationManager.notify(new Event(ObservationConstants.EVENT_PAGE_UPDATED, _currentUserProvider.getUser(), eventParams));
1289            }
1290                
1291        }
1292        else
1293        {
1294            // Notify observers that the page's title has been modified
1295            Map<String, Object> eventParams = new HashMap<>();
1296            eventParams.put(ObservationConstants.ARGS_PAGE, page);
1297            _observationManager.notify(new Event(ObservationConstants.EVENT_PAGE_UPDATED, _currentUserProvider.getUser(), eventParams));
1298        }
1299        
1300        Sitemap sitemap = page.getSitemap();
1301        if (sitemap.needsSave())
1302        {
1303            sitemap.saveChanges();
1304        }
1305        
1306        result.put("id", page.getId());
1307        result.put("path", page.getPath());
1308        result.put("title", page.getTitle());
1309        
1310        return result;
1311    }
1312    
1313    /**
1314     * Unlock or lock page
1315     * @param pageId the id of the page to unlock or lock
1316     * @param mode the mode ('lock' or 'unlock')
1317     * @return the result JSON map
1318     */
1319    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
1320    public Map<String, Object> unlockOrLock(String pageId, String mode)
1321    {
1322        Map<String, Object> result = new HashMap<>();
1323        
1324        UserIdentity currentUser = _currentUserProvider.getUser();
1325        LockablePage lockablePage = (LockablePage) _resolver.resolveById(pageId);
1326        boolean canChangeLockStatus = _rightManager.hasRight(currentUser, "Web_Rights_Page_Lock", "/cms") == RightResult.RIGHT_ALLOW  && _rightManager.currentUserHasReadAccess(lockablePage);
1327        
1328        boolean unlock = "unlock".equals(mode);
1329        
1330        if (!canChangeLockStatus)
1331        {
1332            getLogger().warn("The user '" + currentUser + "' tried to unlock or lock page '" + pageId + "' but does not have the right");
1333            result.put("error", unlock ? "still-locked-page" : "fail-locked-page");
1334        }
1335        
1336        if (unlock && !lockablePage.isLocked())
1337        {
1338            getLogger().warn("The page '" + pageId + "' is not locked anymore.");
1339        }
1340        else if (!unlock && lockablePage.isLocked())
1341        {
1342            getLogger().warn("The page '" + pageId + "' is already locked.");
1343            result.put("error", "already-locked-page");
1344        }
1345        else
1346        {
1347            try
1348            {
1349                if (unlock)
1350                {
1351                    lockablePage.unlock();
1352                    if (getLogger().isDebugEnabled())
1353                    {
1354                        getLogger().debug("The user has unlocked page '" + pageId + "'");
1355                    }
1356                }
1357                else
1358                {
1359                    lockablePage.lock();
1360                }
1361                
1362                lockablePage.saveChanges();
1363                
1364            }
1365            catch (AmetysRepositoryException e)
1366            {
1367                getLogger().error("Unable to lock or unlock page '" + pageId + "'", e);
1368                result.put("error", unlock ? "fail-unlocked-page" : "fail-locked-page");
1369            }
1370        }
1371        
1372        result.put("pageId", pageId);
1373        result.put("pageTitle", lockablePage.getTitle());
1374        result.put("mode", mode);
1375        return result;
1376    }
1377    
1378    /**
1379     * Delete a page and its sub-pages.
1380     * The contents that belong only to the deleted page will be deleted if 'deleteBelongingContents' is set to true.
1381     * The newly created contents are deleted whatever the value if 'deleteBelongingContents'.
1382     * @param pageId the id of page to delete
1383     * @param deleteBelongingContents true to delete the contents that belong to the page and its sub-pages only
1384     * @return The id of deleted pages
1385     */
1386    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
1387    public Map<String, Object> deletePage(String pageId, boolean deleteBelongingContents)
1388    {
1389        ModifiablePage page = _resolver.resolveById(pageId);
1390        
1391        // Check rights on parent
1392        if (!_canDelete(page))
1393        {
1394            throw new IllegalStateException("You do not have the rights to delete the page '/" + page.getSitemapName() + "/" + page.getPathInSitemap() + "'");
1395        }
1396        
1397        if (page instanceof LockablePage lockablePage && lockablePage.isLocked())
1398        {
1399            throw new IllegalStateException("Can not delete the locked page '/" + page.getSitemapName() + "/" + page.getPathInSitemap() + "'");
1400        }
1401        
1402        return deletePage(page, deleteBelongingContents);
1403    }
1404    
1405    /**
1406     * Delete a page and its sub-pages.
1407     * The contents that belong only to the deleted page will be deleted if 'deleteBelongingContents' is set to true.
1408     * The newly created contents are deleted whatever the value if 'deleteBelongingContents'.
1409     * @param page the page to delete
1410     * @param deleteBelongingContents true to delete the contents that belong to the page and its sub-pages only
1411     * @return The id of deleted pages
1412     */
1413    public Map<String, Object> deletePage(ModifiablePage page, boolean deleteBelongingContents)
1414    {
1415        Map<String, Object> result = new HashMap<>();
1416        
1417        PageHierarchy pageHierarchy = _getPageContents(page, true);
1418        List<String> contentsToDelete = _getDeleteablePageContentIds(pageHierarchy, !deleteBelongingContents);
1419        
1420        Sitemap sitemap = page.getSitemap();
1421        SitemapElement parent = page.getParent();
1422        String pagePathInSitemap = page.getPathInSitemap();
1423        
1424        List<String> childPagesIds = _getChildrenPageIds(page);
1425        
1426        Map<String, Object> eventParams = new HashMap<>();
1427        eventParams.put(ObservationConstants.ARGS_PAGE_ID, page.getId());
1428        eventParams.put(ObservationConstants.ARGS_PAGE_PARENT, parent);
1429        eventParams.put(ObservationConstants.ARGS_PAGE_PATH, pagePathInSitemap);
1430        eventParams.put(ObservationConstants.ARGS_SITEMAP, sitemap);
1431        eventParams.put(ObservationConstants.ARGS_PAGE_CONTENTS, getPageContents(page));
1432        _observationManager.notify(new Event(ObservationConstants.EVENT_PAGE_DELETING, _currentUserProvider.getUser(), eventParams));
1433        
1434        // FIXME API test if this is not modifiable
1435        page.getParent();
1436        
1437        if (page instanceof TrashableAmetysObject trashableAO)
1438        {
1439            _trashElementDAO.trash(trashableAO, false, pageHierarchy.contents().stream().map(Content::getId).toArray(String[]::new));
1440            
1441            result.put("trashed", true);
1442        }
1443        else
1444        {
1445            page.remove();
1446            ((ModifiableAmetysObject) parent).saveChanges();
1447        }
1448        
1449        _observationManager.notify(new Event(ObservationConstants.EVENT_PAGE_DELETED, _currentUserProvider.getUser(), eventParams));
1450        
1451        result.put("id", page.getId());
1452        result.put("childPages", childPagesIds);
1453        result.putAll(_contentDAO.trashContents(contentsToDelete, true));
1454        
1455        for (String contentId : contentsToDelete)
1456        {
1457            TrashElement trashElement = _trashElementDAO.find(contentId);
1458            if (trashElement != null)
1459            {
1460                trashElement.setHidden(true);
1461                trashElement.saveChanges();
1462                
1463                // Notify observers
1464                eventParams = new HashMap<>();
1465                eventParams.put(org.ametys.cms.ObservationConstants.ARGS_TRASH_ELEMENT_ID, trashElement.getId());
1466                eventParams.put(org.ametys.cms.ObservationConstants.ARGS_AMETYS_OBJECT_ID, contentId);
1467                _observationManager.notify(new Event(org.ametys.cms.ObservationConstants.EVENT_TRASH_UPDATED, _currentUserProvider.getUser(), eventParams));
1468            }
1469        }
1470        
1471        return result;
1472    }
1473    
1474    /**
1475     * Set pages as blank page
1476     * @param pageIds the id of pages to modify
1477     * @return the id of pages which succeeded or failed.
1478     */
1479    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
1480    public Map<String, Object> setBlank (List<String> pageIds)
1481    {
1482        List<String> allRightIds = new ArrayList<>();
1483        List<Map<String, Object>> noRightPages = new ArrayList<>();
1484        List<Map<String, Object>> errorPages = new ArrayList<>();
1485        List<Map<String, Object>> noModifiablePages = new ArrayList<>();
1486        List<Map<String, Object>> lockedPages = new ArrayList<>();
1487        
1488        for (String pageId : pageIds)
1489        {
1490            Page page = _resolver.resolveById(pageId);
1491            
1492            try
1493            {
1494                if (_rightManager.currentUserHasRight("Web_Rights_Page_BlankPage", page) != RightResult.RIGHT_ALLOW)
1495                {
1496                    noRightPages.add(Map.of("id", pageId, "title", page.getTitle()));
1497                }
1498                else if (!(page instanceof ModifiablePage))
1499                {
1500                    noModifiablePages.add(Map.of("id", pageId, "title", page.getTitle()));
1501                }
1502                else if (page instanceof LockablePage lockablePage && lockablePage.isLocked())
1503                {
1504                    lockedPages.add(Map.of("id", pageId, "title", page.getTitle()));
1505                }
1506                else
1507                {
1508                    ModifiablePage mPage = (ModifiablePage) page;
1509                    
1510                    if (page.getType().equals(PageType.CONTAINER))
1511                    {
1512                        // Remove zones
1513                        for (ModifiableZone zone : mPage.getZones())
1514                        {
1515                            zone.remove();
1516                        }
1517                    }
1518                    
1519                    mPage.setType(PageType.NODE);
1520                    mPage.getSitemap().saveChanges();
1521                    
1522                    allRightIds.add(pageId);
1523                    
1524                    Map<String, Object> eventParams = new HashMap<>();
1525                    eventParams.put(ObservationConstants.ARGS_PAGE, page);
1526                    _observationManager.notify(new Event(ObservationConstants.EVENT_PAGE_CHANGED, _currentUserProvider.getUser(), eventParams));
1527                }
1528            }
1529            catch (Exception e)
1530            {
1531                getLogger().error("Cannot set the page '" + pageId + "' as blank page", e);
1532                errorPages.add(Map.of("id", pageId, "title", page.getTitle()));
1533            }
1534        }
1535
1536        return Map.of("allright-pages", allRightIds, "noright-pages", noRightPages, "error-pages", errorPages, "nomodifiable-pages", noModifiablePages, "locked-pages", lockedPages);
1537    }
1538    
1539    /**
1540     * Set a template to pages
1541     * @param pageIds the id of pages to update
1542     * @param templateName The template name
1543     * @return the id of pages which succeeded
1544     */
1545    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
1546    public Map<String, Object> setTemplate (List<String> pageIds, String templateName)
1547    {
1548        return setTemplate(pageIds, templateName, true);
1549    }
1550    
1551    /**
1552     * Set a template to pages
1553     * @param pageIds the id of pages to update
1554     * @param templateName The template name
1555     * @param checkAvailableTemplate true if you want to check available template
1556     * @return the id of pages which succeeded
1557     */
1558    public Map<String, Object> setTemplate (List<String> pageIds, String templateName, boolean checkAvailableTemplate)
1559    {
1560        List<String> allRightIds = new ArrayList<>();
1561        List<Map<String, Object>> noRightPages = new ArrayList<>();
1562        List<Map<String, Object>> errorPages = new ArrayList<>();
1563        List<Map<String, Object>> noModifiablePages = new ArrayList<>();
1564        
1565        String defaultZoneName = null;
1566        
1567        for (String pageId : pageIds)
1568        {
1569            Page page = _resolver.resolveById(pageId);
1570            
1571            if (_rightManager.currentUserHasRight("Web_Rights_Page_Templates", page) != RightResult.RIGHT_ALLOW)
1572            {
1573                noRightPages.add(Map.of("id", pageId, "title", page.getTitle()));
1574            }
1575            else if (!(page instanceof ModifiablePage))
1576            {
1577                noModifiablePages.add(Map.of("id", pageId, "title", page.getTitle()));
1578            }
1579            else if (page instanceof LockablePage lockablePage && lockablePage.isLocked())
1580            {
1581                throw new IllegalStateException("You cannot set template on page '/" + lockablePage.getSitemapName() + "/" + lockablePage.getPathInSitemap() + "' because it's locked");
1582            }
1583            else
1584            {
1585                ModifiablePage mPage = (ModifiablePage) page;
1586                
1587                if (defaultZoneName == null)
1588                {
1589                    String skinId = page.getSite().getSkinId();
1590                    SkinTemplate tpl = _skinsManager.getSkin(skinId).getTemplate(templateName);
1591                    if (tpl == null)
1592                    {
1593                        throw new IllegalStateException("Template '" + templateName + "' does not exist on skin '" + skinId + "'");
1594                    }
1595                    
1596                    defaultZoneName = tpl.getDefaultZoneId();
1597                }
1598                
1599                if (checkAvailableTemplate && !_templatesHandler.getAvailablesTemplates(mPage).contains(templateName))
1600                {
1601                    throw new IllegalStateException("Template '" + templateName + "' is not available for page '" + pageId + "'");
1602                }
1603                
1604                if (page.getType().equals(PageType.CONTAINER))
1605                {
1606                    _removeOldZones (mPage, templateName);
1607                }
1608                
1609                mPage.setTemplate(templateName);
1610                mPage.setType(PageType.CONTAINER);
1611                mPage.getSitemap().saveChanges();
1612                
1613                allRightIds.add(pageId);
1614                
1615                Map<String, Object> eventParams = new HashMap<>();
1616                eventParams.put(ObservationConstants.ARGS_PAGE, page);
1617                _observationManager.notify(new Event(ObservationConstants.EVENT_PAGE_CHANGED, _currentUserProvider.getUser(), eventParams));
1618            }
1619            
1620        }
1621        
1622        Map<String, Object> results = new HashMap<>();
1623        results.put("allright-pages", allRightIds);
1624        results.put("noright-pages", noRightPages);
1625        results.put("error-pages", errorPages);
1626        results.put("nomodifiable-pages", noModifiablePages);
1627        
1628        if (defaultZoneName != null)
1629        {
1630            results.put("zonename", defaultZoneName);
1631        }
1632        return results;
1633    }
1634    
1635    /**
1636     * Get the script class name to execute to add or update this service
1637     * @param serviceId the service id
1638     * @return the script class name. Can be empty
1639     */
1640    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
1641    public String getServiceParametersAction (String serviceId)
1642    {
1643        try
1644        {
1645            Service service = _serviceExtensionPoint.getExtension(serviceId);
1646            return service.getParametersScript().getScriptClassname();
1647        }
1648        catch (IllegalArgumentException e)
1649        {
1650            throw new IllegalArgumentException("Service with id '" + serviceId + "' does not exist", e);
1651        }
1652    }
1653    
1654    private void _removeOldZones (ModifiablePage page, String templateName)
1655    {
1656        String skinId = page.getSite().getSkinId();
1657        
1658        SkinTemplate oldTemplate = _skinsManager.getSkin(skinId).getTemplate(templateName);
1659        
1660        Map<String, SkinTemplateZone> templateZones = oldTemplate.getZones();
1661        
1662        for (ModifiableZone zone : page.getZones())
1663        {
1664            if (!templateZones.containsKey(zone.getName()))
1665            {
1666                zone.remove();
1667            }
1668        }
1669    }
1670    
1671    /**
1672     * Get the tags from the pages
1673     * @param pageIds The ids of the pages
1674     * @return the tags of the pages
1675     */
1676    @Callable(rights = Callable.SKIP_BUILTIN_CHECK)
1677    public Set<String> getTags (List<String> pageIds)
1678    {
1679        Set<String> tags = new HashSet<>();
1680        
1681        for (String pageId : pageIds)
1682        {
1683            Page page = _resolver.resolveById(pageId);
1684            if (_rightManager.currentUserHasReadAccess(page))
1685            {
1686                tags.addAll(page.getTags());
1687            }
1688        }
1689        
1690        return tags;
1691    }
1692    
1693    /**
1694     * Tag a list of pages
1695     * @param pageIds The ids of pages to tag
1696     * @param tagNames The tags
1697     * @param mode The mode for updating tags: 'REPLACE' to replace tags, 'INSERT' to add tags or 'REMOVE' to remove tags.
1698     * @param contextualParameters Contextual parameters. Must contain the site name
1699     * @return the result
1700     */
1701    @Callable(rights = Callable.SKIP_BUILTIN_CHECK)
1702    public Map<String, Object> tag (List<String> pageIds, List<String> tagNames, String mode, Map<String, Object> contextualParameters)
1703    {
1704        return tag(pageIds, tagNames, TagMode.valueOf(mode), contextualParameters, false);
1705    }
1706    
1707    /**
1708     * Tag a list of pages
1709     * @param pageIds The ids of pages to tag
1710     * @param tagNames The tags
1711     * @param mode The mode for updating tags: 'REPLACE' to replace tags, 'INSERT' to add tags or 'REMOVE' to remove tags.
1712     * @param contextualParameters Contextual parameters. Must contain the site name
1713     * @param ignoreRights <code>true</code> to ignore rights on tag
1714     * @return the result
1715     */
1716    public Map<String, Object> tag (List<String> pageIds, List<String> tagNames, TagMode mode, Map<String, Object> contextualParameters, boolean ignoreRights)
1717    {
1718        Map<String, Object> result = new HashMap<>();
1719        
1720        result.put("nomodifiable-pages", new ArrayList<>());
1721        result.put("invalid-tags", new ArrayList<>());
1722        result.put("allright-pages", new ArrayList<>());
1723        result.put("noright-pages", new ArrayList<>());
1724        
1725        for (String pageId : pageIds)
1726        {
1727            Page page = _resolver.resolveById(pageId);
1728
1729            Map<String, Object> page2json = new HashMap<>();
1730            page2json.put("id", page.getId());
1731            page2json.put("title", page.getTitle());
1732            
1733            if (!ignoreRights && !_hasTagRights(page, tagNames, mode, contextualParameters))
1734            {
1735                @SuppressWarnings("unchecked")
1736                List<Map<String, Object>> noRightPages = (List<Map<String, Object>>) result.get("noright-pages");
1737                noRightPages.add(page2json);
1738            }
1739            else
1740            {
1741                if (page instanceof ModifiablePage mPage)
1742                {
1743                    @SuppressWarnings("unchecked")
1744                    List<String> invalidTags = (List<String>) result.get("invalid-tags");
1745                    invalidTags.addAll(tag(mPage, tagNames, mode));
1746                    
1747                    page2json.put("tags", page.getTags());
1748                    @SuppressWarnings("unchecked")
1749                    List<Map<String, Object>> allRightPages = (List<Map<String, Object>>) result.get("allright-pages");
1750                    allRightPages.add(page2json);
1751                }
1752                else
1753                {
1754                    @SuppressWarnings("unchecked")
1755                    List<Map<String, Object>> nomodifiablePages = (List<Map<String, Object>>) result.get("nomodifiable-pages");
1756                    nomodifiablePages.add(page2json);
1757                }
1758            }
1759        }
1760        
1761        return result;
1762    }
1763    
1764    private boolean _hasTagRights(Page page, List<String> tagNames, TagMode mode, Map<String, Object> contextualParameters)
1765    {
1766        List<CMSTag> tags = tagNames.stream()
1767            .map(t -> _tagProvider.getTag(t, contextualParameters))
1768            .filter(Objects::nonNull)
1769            .toList();
1770        
1771        // In case of replace, only check the right on tag that are modified, ie added or removed tags
1772        if (TagMode.REPLACE.equals(mode))
1773        {
1774            List<CMSTag> existingTags = page.getTags().stream()
1775                    .map(t -> _tagProvider.getTag(t, contextualParameters))
1776                    .filter(Objects::nonNull)
1777                    .toList();
1778            
1779            tags = new ArrayList<>(CollectionUtils.disjunction(tags, existingTags));
1780        }
1781        
1782        boolean hasPublicTagRight = _rightManager.currentUserHasRight("Web_Rights_Page_Tag", page) == RightResult.RIGHT_ALLOW;
1783        boolean hasPrivateTagRight = _rightManager.currentUserHasRight("Web_Rights_Page_Private_Tag", page) == RightResult.RIGHT_ALLOW;
1784        
1785        // Test if the current user has the right to tag public tag on page only if there are at least one public tag
1786        boolean hasRight = TagHelper.filterTags(tags, TagVisibility.PUBLIC, "PAGE").isEmpty() || hasPublicTagRight || hasPrivateTagRight;
1787        
1788        // Test if the current user has the right to tag private tag on page only if there are at least one private tag
1789        return hasRight && (TagHelper.filterTags(tags, TagVisibility.PRIVATE, "PAGE").isEmpty() || hasPrivateTagRight);
1790    }
1791
1792    /**
1793     * Tag a page
1794     * @param page The page to tag
1795     * @param tagNames The tags
1796     * @param tagMode The mode for updating tags: 'REPLACE' to replace tags, 'INSERT' to add tags or 'REMOVE' to remove tags.
1797     * @return the list of invalid tags that were not set
1798     */
1799    public List<String> tag(ModifiablePage page, List<String> tagNames, TagMode tagMode)
1800    {
1801        List<String> invalidTags = new ArrayList<>();
1802        
1803        Set<String> oldTags = page.getTags();
1804        if (TagMode.REPLACE.equals(tagMode))
1805        {
1806            // First delete old tags
1807            for (String tagName : oldTags)
1808            {
1809                page.untag(tagName);
1810            }
1811        }
1812        
1813        // Then set new tags
1814        for (String tagName : tagNames)
1815        {
1816            if (_isTagValid(page, tagName))
1817            {
1818                if (TagMode.REMOVE.equals(tagMode))
1819                {
1820                    page.untag(tagName);
1821                }
1822                else if (TagMode.REPLACE.equals(tagMode) || !oldTags.contains(tagName))
1823                {
1824                    page.tag(tagName);
1825                }
1826            }
1827            else
1828            {
1829                invalidTags.add(tagName);
1830            }
1831        }
1832        
1833        page.saveChanges();
1834        
1835        if (!oldTags.equals(page.getTags()))
1836        {
1837            // Notify observers that the page has been tagged
1838            Map<String, Object> eventParams = new HashMap<>();
1839            eventParams.put(ObservationConstants.ARGS_PAGE, page);
1840            eventParams.put(ObservationConstants.ARGS_PAGE_TAGS, page.getTags());
1841            eventParams.put(ObservationConstants.ARGS_PAGE_OLD_TAGS, oldTags);
1842            _observationManager.notify(new Event(ObservationConstants.EVENT_PAGE_UPDATED, _currentUserProvider.getUser(), eventParams));
1843        }
1844        
1845        return invalidTags;
1846    }
1847    
1848    /**
1849     * Tag a list of contents with the given tags
1850     * @param pageIds The ids of pages to tag
1851     * @param contentIds The ids of contents to tag
1852     * @param tagNames The tags
1853     * @param contextualParameters The contextual parameters
1854     * @return the result map
1855     */
1856    @Callable(rights = Callable.SKIP_BUILTIN_CHECK)
1857    public Map<String, Object> tag (List<String> pageIds, List<String> contentIds, List<String> tagNames, Map<String, Object> contextualParameters)
1858    {
1859        return tag(pageIds, contentIds, tagNames, TagMode.REPLACE.toString(), contextualParameters);
1860    }
1861    
1862    /**
1863     * Tag a list of contents and/org pages
1864     * @param pageIds The ids of pages to tag
1865     * @param contentIds The ids of contents to tag
1866     * @param tagNames The tags
1867     * @param mode The mode for updating tags: 'REPLACE' to replace tags, 'INSERT' to add tags or 'REMOVE' to remove tags.
1868     * @param contextualParameters The contextual parameters
1869     * @return the result
1870     */
1871    @Callable(rights = Callable.SKIP_BUILTIN_CHECK)
1872    public Map<String, Object> tag (List<String> pageIds, List<String> contentIds, List<String> tagNames, String mode, Map<String, Object> contextualParameters)
1873    {
1874        TagMode tagMode = TagMode.valueOf(mode);
1875        
1876        // Tag pages
1877        Map<String, Object> result = tag(pageIds, tagNames, tagMode, contextualParameters, false);
1878        
1879        // Tag contents
1880        result.putAll(_contentDAO.tag(contentIds, tagNames, tagMode, contextualParameters, false));
1881        
1882        // Invalid tags are ignored
1883        result.remove("invalid-tags");
1884        return result;
1885    }
1886    
1887    /**
1888     * Test if a tag is valid for a specific page
1889     * @param page The page
1890     * @param tagName The tag name
1891     * @return True if the tag is valid
1892     */
1893    public boolean _isTagValid (Page page, String tagName)
1894    {
1895        Map<String, Object> params = new HashMap<>();
1896        params.put("siteName", page.getSiteName());
1897        CMSTag tag = _tagProvider.getTag(tagName, params);
1898        
1899        return tag != null && tag.getTarget().getName().equals("PAGE");
1900    }
1901    
1902    /**
1903     * Returns the ids of the pages matching the tag
1904     * @param sitename The site id
1905     * @param lang The language code
1906     * @param tag The tag id
1907     * @return Array of pages ids
1908     */
1909    public List<String> findPagedIdsByTag(String sitename, String lang, String tag)
1910    {
1911        return _getMemoryPageTagCache().get(PageTagCacheKey.of(sitename, lang, tag), __ -> _computePagesIds(sitename, lang, tag))
1912                    .stream()
1913                    .filter(_resolver::hasAmetysObjectForId) // Cache remember for tag in default, we have to check if page exists in current workspace
1914                    .collect(Collectors.toList());
1915    }
1916    
1917    private List<String> _computePagesIds(String sitename, String lang, String tag)
1918    {
1919        
1920        Session defaultSession = null;
1921        try
1922        {
1923            List<String> pagesIds = new ArrayList<>();
1924            
1925            // Force default workspace to execute query
1926            defaultSession = _repository.login(RepositoryConstants.DEFAULT_WORKSPACE);
1927            
1928            String xpath = PageQueryHelper.getPageXPathQuery(sitename, lang, null, new TagExpression(Operator.EQ, tag), null);
1929            AmetysObjectIterable<Page> pages = _ametysObjectResolver.query(xpath, defaultSession);
1930            Iterator<Page> it = pages.iterator();
1931    
1932            while (it.hasNext())
1933            {
1934                pagesIds.add(it.next().getId());
1935            }
1936            
1937            return pagesIds;
1938        }
1939        catch (RepositoryException e)
1940        {
1941            throw new AmetysRepositoryException(e);
1942        }
1943        finally
1944        {
1945            if (defaultSession != null)
1946            {
1947                defaultSession.logout();
1948            }
1949        }
1950    }
1951
1952    private Cache<PageTagCacheKey, List<String>> _getMemoryPageTagCache()
1953    {
1954        return _cacheManager.get(MEMORY_PAGESTAGCACHE);
1955    }
1956    
1957    /**
1958     * Set the visible of pages
1959     * @param pageIds The id of pages
1960     * @param visible <code>true</code> to set pages as visible, <code>false</code> otherwise
1961     * @return The result map
1962     */
1963    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
1964    public Map<String, Object> setVisibility (List<String> pageIds, boolean visible)
1965    {
1966        Map<String, Object> result = new HashMap<>();
1967        
1968        result.put("nomodifiable-pages", new ArrayList<>());
1969        result.put("noright-pages", new ArrayList<>());
1970        result.put("allright-pages", new ArrayList<>());
1971        result.put("locked-pages", new ArrayList<>());
1972        
1973        for (String id : pageIds)
1974        {
1975            Page page = _resolver.resolveById(id);
1976            
1977            Map<String, Object> page2json = new HashMap<>();
1978            page2json.put("id", page.getId());
1979            page2json.put("title", page.getTitle());
1980            
1981            if (_rightManager.currentUserHasRight("Web_Right_Page_Visibility", page) != RightResult.RIGHT_ALLOW)
1982            {
1983                @SuppressWarnings("unchecked")
1984                List<Map<String, Object>> norightPages = (List<Map<String, Object>>) result.get("noright-pages");
1985                norightPages.add(page2json);
1986            }
1987            else if (page instanceof LockablePage lockablePage && lockablePage.isLocked())
1988            {
1989                @SuppressWarnings("unchecked")
1990                List<Map<String, Object>> lockedPages = (List<Map<String, Object>>) result.get("nomodifiable-pages");
1991                lockedPages.add(page2json);
1992            }
1993            else if (page instanceof ModifiablePage mPage)
1994            {
1995                mPage.setVisible(visible);
1996                mPage.saveChanges();
1997                
1998                Map<String, Object> eventParams = new HashMap<>();
1999                eventParams.put(ObservationConstants.ARGS_PAGE, page);
2000                eventParams.put(ObservationConstants.ARGS_PAGE_ID, page.getId());
2001                _observationManager.notify(new Event(ObservationConstants.EVENT_PAGE_UPDATED, _currentUserProvider.getUser(), eventParams));
2002                
2003                @SuppressWarnings("unchecked")
2004                List<Map<String, Object>> allRightPages = (List<Map<String, Object>>) result.get("allright-pages");
2005                allRightPages.add(page2json);
2006            }
2007            else
2008            {
2009                @SuppressWarnings("unchecked")
2010                List<Map<String, Object>> nomodifiablePages = (List<Map<String, Object>>) result.get("nomodifiable-pages");
2011                nomodifiablePages.add(page2json);
2012            }
2013        }
2014        
2015        return result;
2016    }
2017    
2018    /**
2019     * Get the contents of a {@link Page} and its subpages which can be deleted.
2020     * A content is deleteable if user has right, the content is not locked and not referenced by other pages.
2021     * @param id The id of page
2022     * @return The list of deletable contents
2023     */
2024    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
2025    public Map<String, Object> getDeleteablePageContents (String id)
2026    {
2027        Map<String, Object> results = new HashMap<>();
2028        
2029        results.put("deleteable-contents", new ArrayList<>());
2030        results.put("referenced-contents", new ArrayList<>());
2031        results.put("unauthorized-contents", new ArrayList<>());
2032        results.put("locked-contents", new ArrayList<>());
2033        
2034        Page page = _resolver.resolveById(id);
2035        PageHierarchy pageHierarchy = _getPageContents(page, true);
2036        
2037        for (Content content : pageHierarchy.contents())
2038        {
2039            Map<String, Object> contentParams = new HashMap<>();
2040            contentParams.put("id", content.getId());
2041            contentParams.put("title", content.getTitle(LocaleUtils.toLocale(page.getSitemapName())));
2042            contentParams.put("name", content.getName());
2043            
2044            if (_isReferenced(content, pageHierarchy.pages()))
2045            {
2046                // Content is referenced by at least another page
2047                @SuppressWarnings("unchecked")
2048                List<Map<String, Object>> referencedContents = (List<Map<String, Object>>) results.get("referenced-contents");
2049                referencedContents.add(contentParams);
2050            }
2051            else if (!_contentDAO.canDelete(content))
2052            {
2053                @SuppressWarnings("unchecked")
2054                List<Map<String, Object>> unauthorizedContents = (List<Map<String, Object>>) results.get("unauthorized-contents");
2055                unauthorizedContents.add(contentParams);
2056            }
2057            else if (_isLocked(content))
2058            {
2059                // If the content is locked by other
2060                @SuppressWarnings("unchecked")
2061                List<Map<String, Object>> lockedContents = (List<Map<String, Object>>) results.get("locked-contents");
2062                lockedContents.add(contentParams);
2063            }
2064            else
2065            {
2066                Map<String, Object> content2json = new HashMap<>();
2067                content2json.put("id", content.getId());
2068                content2json.put("name", content.getName());
2069                content2json.put("title", content.getTitle(LocaleUtils.toLocale(page.getSitemapName())));
2070                content2json.put("isNew", _isNew(content));
2071                content2json.put("isShared", content instanceof SharedContent);
2072                content2json.put("hasShared", _sharedContentManager.hasSharedContents(content));
2073                
2074                @SuppressWarnings("unchecked")
2075                List<Map<String, Object>> allrightContents = (List<Map<String, Object>>) results.get("deleteable-contents");
2076                allrightContents.add(content2json);
2077            }
2078        }
2079        
2080        return results;
2081    }
2082    
2083    /**
2084     * Get the contents that belong to the {@link Page} and its sub-pages and that can be deleted.
2085     * A content is deleteable if user has right, the content is not locked and it's not referenced by other pages.
2086     * If 'onlyNewlyCreatedContents' is set to 'true', only newly created contents will be returned
2087     * @param pageHierarchy Object containing the page and its sub pages, and all referencing contents in these pages
2088     * @param onlyNewlyCreatedContents true to return only the newly created contents
2089     * @return The ids of deleteable contents
2090     */
2091    private List<String> _getDeleteablePageContentIds (PageHierarchy pageHierarchy, boolean onlyNewlyCreatedContents)
2092    {
2093        return pageHierarchy.contents()
2094                .stream()
2095                .filter(_contentDAO::canDelete)
2096                .filter(Predicate.not(this::_isLocked))
2097                .filter(c -> !_isReferenced(c, pageHierarchy.pages()))
2098                .filter(c -> !onlyNewlyCreatedContents || _isNew(c))
2099                .map(Content::getId)
2100                .toList();
2101    }
2102    
2103    /**
2104     * Returns the page's attachments root node
2105     * @param id the page's id
2106     * @return The attachments' root node informations
2107     */
2108    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
2109    public Map<String, Object> getAttachmentsRootNode (String id)
2110    {
2111        Map<String, Object> result = new HashMap<>();
2112        
2113        Page page = _resolver.resolveById(id);
2114        
2115        result.put("title", page.getTitle());
2116        result.put("pageId", page.getId());
2117        
2118        if (_rightManager.currentUserHasRight("Web_Rights_Page_Attachments", page) != RightResult.RIGHT_ALLOW
2119            && _rightManager.currentUserHasRight("Web_Rights_Page_AttachmentHandle", page) != RightResult.RIGHT_ALLOW)
2120        {
2121            return result;
2122        }
2123        
2124        TraversableAmetysObject attachments = page.getRootAttachments();
2125        
2126        if (attachments != null)
2127        {
2128            result.put("id", attachments.getId());
2129            if (attachments instanceof ModifiableAmetysObject)
2130            {
2131                result.put("isModifiable", true);
2132            }
2133            if (attachments instanceof ModifiableResourceCollection)
2134            {
2135                result.put("canCreateChild", true);
2136            }
2137            
2138            boolean hasChildNodes = false;
2139            boolean hasResources = false;
2140
2141            for (AmetysObject child : attachments.getChildren())
2142            {
2143                if (child instanceof Resource)
2144                {
2145                    hasResources = true;
2146                }
2147                else if (child instanceof ExplorerNode)
2148                {
2149                    hasChildNodes = true;
2150                }
2151            }
2152
2153            if (hasChildNodes)
2154            {
2155                result.put("hasChildNodes", true);
2156            }
2157
2158            if (hasResources)
2159            {
2160                result.put("hasResources", true);
2161            }
2162            
2163            return result;
2164        }
2165        
2166        throw new IllegalArgumentException("Page with id '" + id + "' does not support attachments.");
2167    }
2168    /**
2169     * Returns the page's parents ids
2170     * @param id the page's id
2171     * @return The attachments' root node informations
2172     */
2173    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
2174    public Map<String, Object> getPageParents (String id)
2175    {
2176        Map<String, Object> result = new HashMap<>();
2177        List<Map<String, Object>> pages = new ArrayList<>();
2178        Page page = _resolver.resolveById(id);
2179        pages.add(_page2Json(page));
2180        while (page.getParent() != null && page.getParent() instanceof Page)
2181        {
2182            page = page.getParent();
2183            pages.add(_page2Json(page));
2184        }
2185        result.put("parents", pages);
2186        return result;
2187    }
2188    
2189    /**
2190     * Get the contents of a page and its child pages
2191     * @param sitemapElement The page
2192     * @return The list of contents
2193     */
2194    public Set<Content> getPageContents (SitemapElement sitemapElement)
2195    {
2196        return _getPageContents(sitemapElement, false).contents();
2197    }
2198    
2199    /**
2200     * Get the contents of a page and its child pages
2201     * @param sitemapElement The page
2202     * @param ignoreContentsOfNonRemovablePage true to ignore contents of non-removable pages (virtual pages)
2203     * @return The page hierarchy with all pages, sub pages and referencing contents
2204     */
2205    private PageHierarchy _getPageContents (SitemapElement sitemapElement, boolean ignoreContentsOfNonRemovablePage)
2206    {
2207        Set<Content> contents = new HashSet<>();
2208        
2209        if ((!ignoreContentsOfNonRemovablePage || sitemapElement instanceof RemovableAmetysObject)
2210                && sitemapElement.getTemplate() != null)
2211        {
2212            for (Zone zone : sitemapElement.getZones())
2213            {
2214                try (AmetysObjectIterable< ? extends ZoneItem> zoneItems = zone.getZoneItems())
2215                {
2216                    for (ZoneItem zoneItem : zoneItems)
2217                    {
2218                        if (zoneItem.getType() == ZoneItem.ZoneType.CONTENT)
2219                        {
2220                            contents.add(zoneItem.getContent());
2221                        }
2222                    }
2223                }
2224            }
2225        }
2226        
2227        Set<Page> pages = new HashSet<>();
2228        if (sitemapElement instanceof Page page)
2229        {
2230            pages.add(page);
2231        }
2232        
2233        AmetysObjectIterable< ? extends Page> childrenPages = sitemapElement.getChildrenPages();
2234        for (Page childPage : childrenPages)
2235        {
2236            PageHierarchy pageHierarchy = _getPageContents(childPage, ignoreContentsOfNonRemovablePage);
2237            pages.addAll(pageHierarchy.pages());
2238            contents.addAll(pageHierarchy.contents());
2239        }
2240        
2241        return new PageHierarchy(pages, contents);
2242    }
2243    
2244    /**
2245     * Get the user rights on sitemap element (page or sitemap)
2246     * @param pagesCt The sitemap element
2247     * @return The user's rights
2248     */
2249    protected Set<String> getUserRights (SitemapElement pagesCt)
2250    {
2251        UserIdentity user = _currentUserProvider.getUser();
2252        
2253        Set<String> userRights = _rightManager.getUserRights(user, pagesCt);
2254        
2255        // Do some specific stuff here, because the right 'Web_Rights_Page_Delete' is a right to delete child pages and not the page itself.
2256        // So the right should be checked on parent context.
2257        if (pagesCt instanceof Page)
2258        {
2259            SitemapElement parent = pagesCt.getParent();
2260            boolean canDelete = _rightManager.hasRight(user, "Web_Rights_Page_Delete", parent) == RightResult.RIGHT_ALLOW;
2261            if (!canDelete)
2262            {
2263                // No right on parent page, so remove the right if exists.
2264                userRights.remove("Web_Rights_Page_Delete");
2265            }
2266        }
2267        else
2268        {
2269            // There is no right of deletion on the sitemap
2270            userRights.remove("Web_Rights_Page_Delete");
2271        }
2272        
2273        return userRights;
2274    }
2275    
2276    private boolean _isReferenced (Content content, Set<Page> pages)
2277    {
2278        if (content instanceof WebContent webContent)
2279        {
2280            // If only one page referencing the content is not inside the selected page hierarchy, it is a referenced content
2281            return webContent.getReferencingPages().stream().anyMatch(Predicate.not(pages::contains));
2282        }
2283        
2284        return false;
2285    }
2286    
2287    private boolean _isLocked (Content content)
2288    {
2289        if (content instanceof LockableAmetysObject lockableContent)
2290        {
2291            if (lockableContent.isLocked())
2292            {
2293                boolean canUnlockAll = _rightManager.hasRight(_currentUserProvider.getUser(), "CMS_Rights_UnlockAll", "/cms") == RightResult.RIGHT_ALLOW;
2294                if (!LockHelper.isLockOwner(lockableContent, _currentUserProvider.getUser()) && !canUnlockAll)
2295                {
2296                    return true;
2297                }
2298            }
2299        }
2300        
2301        return false;
2302    }
2303    
2304    private boolean _isNew (Content content)
2305    {
2306        boolean isNew = false;
2307        if (content instanceof WorkflowAwareContent waContent)
2308        {
2309            long workflowId = waContent.getWorkflowId();
2310            
2311            AmetysObjectWorkflow workflow = _workflowProvider.getAmetysObjectWorkflow(waContent);
2312            isNew = workflow.getHistorySteps(workflowId).isEmpty();
2313        }
2314        return isNew;
2315    }
2316
2317    private Map<String, Object> _page2Json (Page page)
2318    {
2319        Map<String, Object> page2json = new HashMap<>();
2320        page2json.put("id", page.getId());
2321        page2json.put("title", page.getTitle());
2322        page2json.put("siteName", page.getSiteName());
2323        page2json.put("path", page.getPathInSitemap());
2324        return page2json;
2325    }
2326    
2327    private Map<String, Object> _publication2Json (Page page)
2328    {
2329        Map<String, Object> pub2json = new HashMap<>();
2330        
2331        if (page.hasValue(DefaultPage.METADATA_PUBLICATION_START_DATE, ModelItemTypeConstants.DATETIME_TYPE_ID))
2332        {
2333            Object startDateAsJSON = page.dataToJSON(DefaultPage.METADATA_PUBLICATION_START_DATE);
2334            pub2json.put("startDate", startDateAsJSON);
2335        }
2336        
2337        if (page.hasValue(DefaultPage.METADATA_PUBLICATION_END_DATE, ModelItemTypeConstants.DATETIME_TYPE_ID))
2338        {
2339            Object endDateAsJSON = page.dataToJSON(DefaultPage.METADATA_PUBLICATION_END_DATE);
2340            pub2json.put("endDate", endDateAsJSON);
2341        }
2342        
2343        return pub2json;
2344        
2345    }
2346    
2347    private void _updateContentsAfterCopy(Page initialPage, Page createdPage) throws AmetysRepositoryException
2348    {
2349        for (Zone zone : createdPage.getZones())
2350        {
2351            Zone initialZone = initialPage.getZone(zone.getName());
2352            try (AmetysObjectIterable< ? extends ZoneItem> zoneItems = zone.getZoneItems(); AmetysObjectIterable< ? extends ZoneItem> initialZoneItems = initialZone.getZoneItems())
2353            {
2354                AmetysObjectIterator<? extends ZoneItem> iterator = zoneItems.iterator();
2355                AmetysObjectIterator< ? extends ZoneItem> initialIterator = initialZoneItems.iterator();
2356                if (iterator.getSize() != initialIterator.getSize())
2357                {
2358                    throw new IllegalStateException("An error occured during the copy of " + initialPage.getName() + " (" + initialPage.getId() + "). The resulting page have a different number of zoneItems.");
2359                }
2360                while (iterator.hasNext())
2361                {
2362                    ZoneItem zoneItem = iterator.next();
2363                    ZoneItem initialZoneItem = initialIterator.next();
2364                    if (zoneItem.getType().equals(ZoneType.CONTENT))
2365                    {
2366                        Content content = zoneItem.getContent();
2367                        WebContent initialContent = initialZoneItem.getContent();
2368                        
2369                        // Updaters
2370                        Set<String> ids = _copyUpdaterEP.getExtensionsIds();
2371                        for (String id : ids)
2372                        {
2373                            _copyUpdaterEP.getExtension(id).updateContent(initialPage.getSite(), createdPage.getSite(), initialContent, content);
2374                        }
2375                        
2376                        // Convert content language if necessary
2377                        if (content instanceof ModifiableContent modifiableContent && content.getLanguage() != null && content.getLanguage() != createdPage.getSitemapName())
2378                        {
2379                            modifiableContent.setLanguage(createdPage.getSitemapName());
2380                            modifiableContent.saveChanges();
2381                        }
2382                        
2383                        // Create the first version
2384                        if (content instanceof VersionableAmetysObject versionableContent)
2385                        {
2386                            versionableContent.checkpoint();
2387                        }
2388                    }
2389                }
2390            }
2391        }
2392        
2393        // Browse child pages
2394        for (Page childPage : createdPage.getChildrenPages())
2395        {
2396            Page initialChildPage = initialPage.getChild(childPage.getName());
2397            _updateContentsAfterCopy (initialChildPage, childPage);
2398        }
2399    }
2400    
2401    private List<String> _getChildrenPageIds (Page page)
2402    {
2403        List<String> childIds = new ArrayList<>();
2404        
2405        for (Page childPage : page.getChildrenPages())
2406        {
2407            childIds.add(childPage.getId());
2408            childIds.addAll(_getChildrenPageIds(childPage));
2409        }
2410        
2411        return childIds;
2412    }
2413
2414    /**
2415     * Check each parent and return true if one of them is invisible
2416     * @param page page to check
2417     * @return true if at least one parent is invisible
2418     */
2419    private boolean _isParentInvisible (Page page)
2420    {
2421        AmetysObject parent = page.getParent();
2422        while (parent != null && parent instanceof Page parentPage)
2423        {
2424            if (!parentPage.isVisible())
2425            {
2426                return true;
2427            }
2428            parent = parent.getParent();
2429        }
2430        return false;
2431    }
2432
2433    /* start of a group of methods for _isPreviewable */
2434    /**
2435     * Determine if this page is previewable
2436     * @param page The page to look at
2437     * @return true if the page can be previewed
2438     */
2439    private boolean _isPreviewable(Page page)
2440    {
2441        // Check for infinitive loop redirection
2442        ArrayList<String> pagesSequence = new ArrayList<>();
2443        pagesSequence.add(page.getId());
2444        if (_isInfiniteRedirection (page, pagesSequence))
2445        {
2446            getLogger().error("An infinite loop redirection was detected for page '" + page.getPathInSitemap() + "'");
2447            return false;
2448        }
2449
2450        if (page.getType() == PageType.LINK && LinkType.PAGE.equals(page.getURLType()))
2451        {
2452            return _isPageExist(page.getURL()) && _isPreviewable((Page) _resolver.resolveById(page.getURL()));
2453        }
2454
2455        if (page.getType() != PageType.NODE)
2456        {
2457            return true;
2458        }
2459        else
2460        {
2461            try (AmetysObjectIterable< ? extends Page> childrenPages = page.getChildrenPages())
2462            {
2463                for (Page subPage : childrenPages)
2464                {
2465                    if (_isPreviewable(subPage))
2466                    {
2467                        return true;
2468                    }
2469                }
2470            }
2471        }
2472        return false;
2473    }
2474
2475    private boolean _isPageExist (String id)
2476    {
2477        try
2478        {
2479            _resolver.resolveById(id);
2480            return true;
2481        }
2482        catch (UnknownAmetysObjectException e)
2483        {
2484            return false;
2485        }
2486    }
2487
2488    private boolean _isInfiniteRedirection (Page page, List<String> pagesSequence)
2489    {
2490        Page redirectPage = _getPageRedirection (page);
2491        if (redirectPage == null)
2492        {
2493            return false;
2494        }
2495
2496        if (pagesSequence.contains(redirectPage.getId()))
2497        {
2498            return true;
2499        }
2500
2501        pagesSequence.add(redirectPage.getId());
2502        return _isInfiniteRedirection (redirectPage, pagesSequence);
2503    }
2504
2505    private Page _getPageRedirection (Page page)
2506    {
2507        if (PageType.LINK.equals(page.getType()) && LinkType.PAGE.equals(page.getURLType()))
2508        {
2509            try
2510            {
2511                String pageId = page.getURL();
2512                return _resolver.resolveById(pageId);
2513            }
2514            catch (AmetysRepositoryException e)
2515            {
2516                return null;
2517            }
2518        }
2519        else if (PageType.NODE.equals(page.getType()))
2520        {
2521            AmetysObjectIterable<? extends Page> childPages = page.getChildrenPages();
2522            Iterator<? extends Page> it = childPages.iterator();
2523            if (it.hasNext())
2524            {
2525                return it.next();
2526            }
2527        }
2528
2529        return null;
2530    }
2531    /* end of a group of methods for _isPreviewable */
2532    
2533    private static final class PageTagCacheKey extends AbstractCacheKey
2534    {
2535        private PageTagCacheKey(String sitename, String lang, String tag)
2536        {
2537            super(sitename, lang, tag);
2538        }
2539        
2540        private PageTagCacheKey(String sitename, String lang)
2541        {
2542            super(sitename, lang);
2543        }
2544
2545        static PageTagCacheKey of(String sitename, String lang)
2546        {
2547            return new PageTagCacheKey(sitename, lang, null);
2548        }
2549
2550        static PageTagCacheKey of(String sitename, String lang, String tag)
2551        {
2552            return new PageTagCacheKey(sitename, lang, tag);
2553        }
2554    }
2555    
2556    private record PageHierarchy(Set<Page> pages, Set<Content> contents) { /* empty */ }
2557}