001/*
002 *  Copyright 2010 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.synchronization;
017
018import java.io.IOException;
019import java.time.ZonedDateTime;
020import java.util.ArrayList;
021import java.util.Arrays;
022import java.util.Iterator;
023import java.util.List;
024import java.util.Map;
025
026import javax.jcr.ItemNotFoundException;
027import javax.jcr.Node;
028import javax.jcr.NodeIterator;
029import javax.jcr.Property;
030import javax.jcr.PropertyIterator;
031import javax.jcr.RepositoryException;
032import javax.jcr.Session;
033import javax.jcr.version.VersionHistory;
034
035import org.apache.avalon.framework.activity.Initializable;
036import org.apache.avalon.framework.component.Component;
037import org.apache.avalon.framework.logger.AbstractLogEnabled;
038import org.apache.avalon.framework.service.ServiceException;
039import org.apache.avalon.framework.service.ServiceManager;
040import org.apache.avalon.framework.service.Serviceable;
041import org.apache.commons.collections.Predicate;
042import org.apache.commons.collections.PredicateUtils;
043import org.apache.commons.lang.StringUtils;
044
045import org.ametys.cms.CmsConstants;
046import org.ametys.cms.repository.CloneComponent;
047import org.ametys.cms.repository.Content;
048import org.ametys.cms.support.AmetysPredicateUtils;
049import org.ametys.core.util.I18nUtils;
050import org.ametys.core.util.mail.SendMailHelper;
051import org.ametys.plugins.repository.AmetysObject;
052import org.ametys.plugins.repository.AmetysObjectIterable;
053import org.ametys.plugins.repository.AmetysObjectResolver;
054import org.ametys.plugins.repository.AmetysRepositoryException;
055import org.ametys.plugins.repository.UnknownAmetysObjectException;
056import org.ametys.plugins.repository.jcr.JCRAmetysObject;
057import org.ametys.plugins.repository.version.VersionableAmetysObject;
058import org.ametys.runtime.config.Config;
059import org.ametys.runtime.i18n.I18nizableText;
060import org.ametys.web.repository.page.Page;
061import org.ametys.web.repository.page.Page.LinkType;
062import org.ametys.web.repository.page.Page.PageType;
063import org.ametys.web.repository.page.SitemapElement;
064import org.ametys.web.repository.page.Zone;
065import org.ametys.web.repository.page.ZoneItem;
066import org.ametys.web.repository.page.jcr.DefaultPage;
067import org.ametys.web.repository.sitemap.Sitemap;
068import org.ametys.web.skin.Skin;
069import org.ametys.web.skin.SkinTemplate;
070import org.ametys.web.skin.SkinTemplateZone;
071import org.ametys.web.skin.SkinsManager;
072
073import jakarta.mail.MessagingException;
074
075/**
076 * Helper for common processing used while synchronizing.
077 */
078public class SynchronizeComponent extends AbstractLogEnabled implements Component, Serviceable, Initializable
079{
080    /** Avalon Role */
081    public static final String ROLE = SynchronizeComponent.class.getName();
082    
083    private static SynchronizeComponent _instance;
084    
085    private CloneComponent _cloneComponent;
086    private AmetysObjectResolver _resolver;
087    private SkinsManager _skinsManager;
088    private I18nUtils _i18nUtils;
089
090    @Override
091    public void service(ServiceManager smanager) throws ServiceException
092    {
093        _cloneComponent = (CloneComponent) smanager.lookup(CloneComponent.ROLE);
094        _resolver = (AmetysObjectResolver) smanager.lookup(AmetysObjectResolver.ROLE);
095        _skinsManager = (SkinsManager) smanager.lookup(SkinsManager.ROLE);
096        _i18nUtils = (I18nUtils) smanager.lookup(I18nUtils.ROLE);
097    }
098    
099    @Override
100    public void initialize() throws Exception
101    {
102        _instance = this;
103    }
104    
105    /**
106     * Get the unique instance
107     * @return the unique instance
108     */
109    @Deprecated
110    public static SynchronizeComponent getInstance()
111    {
112        return _instance;
113    }
114    
115    /**
116     * Returns true if the hierarchy of the given Page is valid, ie if all its ancestors are valid and synchronized in the live workspace.
117     * @param page the source page.
118     * @param liveSession the live Session.
119     * @return the hierarchy validity status.
120     * @throws RepositoryException if failed to check page hierarchy
121     */
122    public boolean isHierarchyValid(Page page, Session liveSession) throws RepositoryException
123    {
124        AmetysObject parent = page.getParent();
125        if (parent instanceof Sitemap)
126        {
127            return true;
128        }
129        
130        Page parentPage = (Page) parent;
131        if (!(parentPage instanceof JCRAmetysObject))
132        {
133            return isPageValid(parentPage, _skinsManager.getSkin(page.getSite().getSkinId())) && isHierarchyValid(parentPage, liveSession);
134        }
135        else if (parentPage.getType() == PageType.NODE)
136        {
137            return isDateValid(parentPage) && isHierarchyValid(parentPage, liveSession);
138        }
139        else
140        {
141            return liveSession.itemExists(((JCRAmetysObject) parentPage).getNode().getPath());
142        }
143    }
144    
145    /**
146     * Returns true if the given page should be synchronized in the live workspace.
147     * @param page the page to test.
148     * @param skin the skin of the page's site.
149     * @return true if the page is valid
150     */
151    public boolean isPageValid(Page page, Skin skin)
152    {
153        if (!isDateValid(page))
154        {
155            return false;
156        }
157        
158        switch (page.getType())
159        {
160            case LINK:
161                return _isLinkPageValid(page);
162            case NODE:
163                return _isNodePageValid(page, skin);
164            case CONTAINER:
165                return _isContainerPageValid(page, skin);
166            default:
167                return false;
168        }   
169    }
170    
171    /**
172     * Returns true if the publication date of the given page are valid.
173     * @param page the page to test.
174     * @return true if the publication dates are valid
175     */
176    public boolean isDateValid (Page page)
177    {
178        ZonedDateTime startDate = page.getValue(DefaultPage.METADATA_PUBLICATION_START_DATE);
179        ZonedDateTime endDate = page.getValue(DefaultPage.METADATA_PUBLICATION_END_DATE);
180        
181        if (startDate != null && startDate.isAfter(ZonedDateTime.now()))
182        {
183            return false;
184        }
185        
186        if (endDate != null && endDate.isBefore(ZonedDateTime.now()))
187        {
188            return false;
189        }
190        
191        return true;
192    }
193    
194    private boolean _isInfiniteRedirection (Page page, List<String> pagesSequence)
195    {
196        Page redirectPage = _getPageRedirection (page);
197        if (redirectPage == null)
198        {
199            return false;
200        }
201        
202        if (pagesSequence.contains(redirectPage.getId()))
203        {
204            return true;
205        }
206        
207        pagesSequence.add(redirectPage.getId());
208        return _isInfiniteRedirection (redirectPage, pagesSequence);
209    }
210    
211    private Page _getPageRedirection (Page page)
212    {
213        if (PageType.LINK.equals(page.getType()) && LinkType.PAGE.equals(page.getURLType()))
214        {
215            try
216            {
217                String pageId = page.getURL();
218                return _resolver.resolveById(pageId);
219            }
220            catch (AmetysRepositoryException e)
221            {
222                return null;
223            }
224        }
225        else if (PageType.NODE.equals(page.getType()))
226        {
227            AmetysObjectIterable<? extends Page> childPages = page.getChildrenPages();
228            Iterator<? extends Page> it = childPages.iterator();
229            if (it.hasNext())
230            {
231                return it.next();
232            }
233        }
234        
235        return null;
236    }
237    
238    private boolean _isLinkPageValid(Page page)
239    {
240        if (LinkType.WEB.equals(page.getURLType()))
241        {
242            return true;
243        }
244        
245        // Check for infinitive loop redirection
246        List<String> pagesSequence = new ArrayList<>();
247        pagesSequence.add(page.getId());
248        if (_isInfiniteRedirection (page, pagesSequence))
249        {
250            getLogger().error("An infinite loop redirection was detected for page '" + page.getSiteName() + "/" + page.getSitemapName() + "/" + page.getPathInSitemap() + "' (" + page.getId() + ") in the sequence " + pagesSequence);
251            _sendErrorMailForInfiniteRedirection (page);
252            return false;
253        }
254        
255        try
256        {
257            String pageId = page.getURL();
258            Page linkedPage = _resolver.resolveById(pageId);
259            
260            Skin linkedPageSkin = _skinsManager.getSkin(linkedPage.getSite().getSkinId());
261            return isPageValid(linkedPage, linkedPageSkin);
262        }
263        catch (UnknownAmetysObjectException e)
264        {
265            getLogger().error("Page '" + page.getSiteName() + "/" + page.getSitemapName() + "/" + page.getPathInSitemap() + "' (" + page.getId() + ") redirects to an unexisting page '" + page.getURL() + "'", e);
266            return false;
267        }
268        catch (AmetysRepositoryException e)
269        {
270            getLogger().error("Unable to check page validity for page link '" + page.getId() + "'", e);
271            return false;
272        }
273    }
274    
275    private boolean _isNodePageValid(Page page, Skin skin)
276    {
277        // Check for infinitive loop redirection
278        List<String> pagesSequence = new ArrayList<>();
279        pagesSequence.add(page.getId());
280        if (_isInfiniteRedirection (page, pagesSequence))
281        {
282            getLogger().error("An infinite loop redirection was detected for page '" + page.getSiteName() + "/" + page.getSitemapName() + "/" + page.getPathInSitemap() + "' (" + page.getId() + ") in the sequence " + pagesSequence);
283            _sendErrorMailForInfiniteRedirection (page);
284            return false;
285        }
286        
287        // a node page is valid if at least one of its child pages is valid
288        AmetysObjectIterable<? extends Page> childPages = page.getChildrenPages();
289        boolean hasOneConcreteChildPage = false;
290        Iterator<? extends Page> it = childPages.iterator();
291        
292        while (!hasOneConcreteChildPage && it.hasNext())
293        {
294            Page childPage = it.next();
295            if (isPageValid(childPage, skin))
296            {
297                hasOneConcreteChildPage = true;
298            }
299        }
300        
301        return hasOneConcreteChildPage;
302    }
303    
304    private boolean _isContainerPageValid(Page page, Skin skin)
305    {
306        // a container page is valid if it has no zones or at least one zone is valid
307        if (skin == null)
308        {
309            return false;
310        }
311        else
312        {
313            SkinTemplate template = skin.getTemplate(page.getTemplate());
314            if (template == null)
315            {
316                return false;
317            }
318            
319            Map<String, SkinTemplateZone> modelZones = template.getZones();
320            Iterator<String> zoneNames = modelZones.keySet().iterator();
321            
322            boolean pageIsValid = modelZones.size() == 0;
323            
324            while (!pageIsValid && zoneNames.hasNext())
325            {
326                // a zone is valid if it is not empty and if at least one ZoneItem is valid
327                String zoneName = zoneNames.next();
328                
329                if (page.hasZone(zoneName))
330                {
331                    Zone zone = page.getZone(zoneName);
332                    AmetysObjectIterable<? extends ZoneItem> zoneItems = zone.getZoneItems();
333                    Iterator<? extends ZoneItem> it = zoneItems.iterator();
334                    boolean zoneIsValid = false;
335                    
336                    while (!zoneIsValid && it.hasNext())
337                    {
338                        ZoneItem zoneItem = it.next();
339                        zoneIsValid = _isZoneItemValid(zoneItem);
340                    }
341                    
342                    pageIsValid = zoneIsValid;
343                }
344            }
345            
346            return pageIsValid;
347        }
348    }
349    
350    private boolean _isZoneItemValid(ZoneItem zoneItem)
351    {
352        switch (zoneItem.getType())
353        {
354            case SERVICE:
355                // a service is always valid
356                return true;
357            case CONTENT:
358                try
359                {
360                    // a content is valid if it has been validated at least once
361                    Content content = zoneItem.getContent();
362                    if (content instanceof VersionableAmetysObject)
363                    {
364                        return Arrays.asList(((VersionableAmetysObject) content).getAllLabels()).contains(CmsConstants.LIVE_LABEL);
365                    }
366                    else
367                    {
368                        return false;
369                    }
370                }
371                catch (AmetysRepositoryException e)
372                {
373                    getLogger().error("Unable to get content property", e);
374                    return false;
375                }
376            default:
377                throw new IllegalArgumentException("A zoneItem must be either a service or a content.");
378        }
379    }
380
381    /**
382     * Adds a node to a parent node using a source node for name, type
383     * and potential UUID.
384     * @param srcNode the source node.
385     * @param parentNode the parent node for the newly created node.
386     * @param nodeName the node name to use.
387     * @return the created node.
388     * @throws RepositoryException if an error occurs.
389     */
390    public Node addNodeWithUUID(Node srcNode, Node parentNode, String nodeName) throws RepositoryException
391    {
392        Node node = null;
393        
394        if (AmetysPredicateUtils.isAllowedForLiveContent().evaluate(srcNode))
395        {
396            node = _cloneComponent.addNodeWithUUID(srcNode, parentNode, nodeName);
397        }
398        
399        return node;
400    }
401    
402    /**
403     * Clones properties of a node
404     * @param srcNode the source node.
405     * @param clonedNode the cloned node.
406     * @param propertyPredicate the property selector.
407     * @throws RepositoryException if an error occurs.
408     */
409    public void cloneProperties(Node srcNode, Node clonedNode, Predicate propertyPredicate) throws RepositoryException
410    {
411        if (srcNode == null || clonedNode == null)
412        {
413            return;
414        }
415        
416        // Ignore protected properties + filter node/property for live
417        final Predicate predicate = AmetysPredicateUtils.ignoreProtectedProperties(propertyPredicate);
418        
419        // First remove existing matching properties from cloned Node
420        PropertyIterator clonedProperties = clonedNode.getProperties();
421        while (clonedProperties.hasNext())
422        {
423            Property property = clonedProperties.nextProperty();
424            if (predicate.evaluate(property))
425            {
426                property.remove();
427            }
428        }
429        
430        // Then copy properties
431        PropertyIterator itProperties = srcNode.getProperties();
432        
433        while (itProperties.hasNext())
434        {
435            Property property =  itProperties.nextProperty();
436            
437            
438            if (predicate.evaluate(property))
439            {
440                boolean propertyHandled = false;
441                /*
442                if (property.getType() == PropertyType.REFERENCE)
443                {
444                    try
445                    {
446                        Node referencedNode = property.getNode();
447                        if (property.getName().equals("ametys-internal:initial-content"))
448                        {
449                            // Do not clone the initial-content reference.
450                            propertyHandled = true;
451                        }
452                        
453                    }
454                    catch (ItemNotFoundException e)
455                    {
456                        // the target node does not exist anymore, this could be due to workflow having been deleted
457                        propertyHandled = true;
458                    }
459                }*/
460                
461                if (!propertyHandled)
462                {
463                    if (property.getDefinition().isMultiple())
464                    {
465                        clonedNode.setProperty(property.getName(), property.getValues());
466                    }
467                    else
468                    {
469                        clonedNode.setProperty(property.getName(), property.getValue());
470                    }
471                }
472            }
473        }
474    }
475
476    /**
477     * Clones a node by preserving the source node UUID for a content.
478     * @param srcNode the source node.
479     * @param clonedNode the cloned node.
480     * @param propertyPredicate the property selector.
481     * @param nodePredicate the node selector.
482     * @throws RepositoryException if an error occurs.
483     */
484    public void cloneContentNodeAndPreserveUUID(Node srcNode, Node clonedNode, Predicate propertyPredicate, Predicate nodePredicate) throws RepositoryException
485    {
486        Predicate finalNodePredicate = PredicateUtils.andPredicate(AmetysPredicateUtils.isAllowedForLiveContent(), nodePredicate);
487        Predicate finalPropertiesPredicate = PredicateUtils.andPredicate(AmetysPredicateUtils.isAllowedForLiveContent(), propertyPredicate);
488
489        cloneNodeAndPreserveUUID(srcNode, clonedNode, finalPropertiesPredicate, finalNodePredicate);
490    }
491    
492    /**
493     * Clones properties of a content node
494     * @param srcNode the source node.
495     * @param clonedNode the cloned node.
496     * @param propertyPredicate the property selector.
497     * @throws RepositoryException if an error occurs.
498     */
499    public void cloneContentProperties(Node srcNode, Node clonedNode, Predicate propertyPredicate) throws RepositoryException
500    {
501        Predicate finalPropertiesPredicate = PredicateUtils.andPredicate(AmetysPredicateUtils.isAllowedForLiveContent(), propertyPredicate);
502        
503        cloneProperties(srcNode, clonedNode, finalPropertiesPredicate);
504    }
505    
506    /**
507     * Clones a node by preserving the source node UUID.
508     * @param srcNode the source node.
509     * @param clonedNode the cloned node.
510     * @param propertyPredicate the property selector.
511     * @param nodePredicate the node selector.
512     * @throws RepositoryException if an error occurs.
513     */
514    public void cloneNodeAndPreserveUUID(Node srcNode, Node clonedNode, Predicate propertyPredicate, Predicate nodePredicate) throws RepositoryException
515    {
516        if (srcNode == null || clonedNode == null)
517        {
518            return;
519        }
520        
521        // Clone properties
522        cloneProperties (srcNode, clonedNode, propertyPredicate);
523        
524        // Remove all matching subNodes before cloning, for better handling of same name siblings
525        NodeIterator subNodes = clonedNode.getNodes();
526        
527        while (subNodes.hasNext())
528        {
529            Node subNode = subNodes.nextNode();
530            
531            if (nodePredicate.evaluate(subNode))
532            {
533                subNode.remove();
534            }
535        }
536        
537        // Then copy sub nodes
538        NodeIterator itNodes = srcNode.getNodes();
539        
540        while (itNodes.hasNext())
541        {
542            Node subNode = itNodes.nextNode();
543            
544            if (nodePredicate.evaluate(subNode))
545            {
546                Node clonedSubNode = addNodeWithUUID(subNode, clonedNode, subNode.getName());
547                cloneNodeAndPreserveUUID(subNode, clonedSubNode, propertyPredicate, nodePredicate);
548            }
549        }
550    }
551    
552    /**
553     * Will copy the LIVE version of the content into the LIVE workspace. Works only with JCRAmetysObject.
554     * This method DO NOT save the liveSession.
555     * @param content The content to copy
556     * @param liveSession The session for live 
557     * @throws RepositoryException If an error occurred
558     */
559    public void synchronizeContent(Content content, Session liveSession) throws RepositoryException
560    {
561        if (getLogger().isDebugEnabled())
562        {
563            getLogger().debug("Synchronizing content " + content.getId());
564        }
565        
566        if (!(content instanceof JCRAmetysObject))
567        {
568            return;
569        }
570        
571        Node node = ((JCRAmetysObject) content).getNode();
572        VersionHistory versionHistory = node.getSession().getWorkspace().getVersionManager().getVersionHistory(node.getPath());
573        if (Arrays.asList(versionHistory.getVersionLabels()).contains(CmsConstants.LIVE_LABEL))
574        {
575            Node validatedContentNode = versionHistory.getVersionByLabel(CmsConstants.LIVE_LABEL).getFrozenNode();
576            
577            Node clonedNode;
578            try
579            {
580                // content already exists in the live workspace 
581                clonedNode = liveSession.getNodeByIdentifier(node.getIdentifier());
582            }
583            catch (ItemNotFoundException e)
584            {
585                // content does not exist in the live workspace
586                
587                // clone content itself
588                Node clonedContentParentNode = cloneAncestorsAndPreserveUUID(node, liveSession);
589                
590                clonedNode = addNodeWithUUID(validatedContentNode, clonedContentParentNode, node.getName());
591             
592            }
593            
594            // First, let's remove all child node and all properties (versionned or not)
595            // Second, clone all versionned node and properties 
596            cloneContentNodeAndPreserveUUID(validatedContentNode, clonedNode, PredicateUtils.truePredicate(), PredicateUtils.truePredicate());
597            
598            // Third, clone all unversionned node and properties (such as acl, tags or all unversionned metadata but except initial-content or workflow)
599            cloneContentNodeAndPreserveUUID(node, clonedNode, AmetysPredicateUtils.isNonVersionned(node), AmetysPredicateUtils.isNonVersionned(node));
600            
601            // Clone validation metadata from the current version.
602            cloneContentProperties(node, clonedNode, AmetysPredicateUtils.propertyNamesPredicate("ametys:lastValidationDate", "ametys:lastMajorValidationDate", "ametys:privacy")); // FIXME CMS-7630 privacy should be not versionned
603        }
604        else
605        {
606            try
607            {
608                // content already exists in the live workspace 
609                Node clonedNode = liveSession.getNodeByIdentifier(node.getIdentifier());
610                clonedNode.remove();
611            }
612            catch (ItemNotFoundException e)
613            {
614                // Ok, the content was not in live
615            }
616        }
617    }
618    
619    /**
620     * Clones ancestors of a node by preserving the source node UUID.
621     * @param srcNode the source node.
622     * @param liveSession the session to the live workspace.
623     * @return the parent node in the live workspace.
624     * @throws RepositoryException if an error occurs.
625     */
626    public Node cloneAncestorsAndPreserveUUID(Node srcNode, Session liveSession) throws RepositoryException
627    {
628        if (srcNode.getName().length() == 0)
629        {
630            // We are on the root node which already exists
631            return liveSession.getRootNode();
632        }
633        else
634        {
635            Node liveRootNode = liveSession.getRootNode();
636            Node parentNode = srcNode.getParent();
637            String parentNodePath = parentNode.getPath().substring(1);
638            
639            if (liveRootNode.hasNode(parentNodePath))
640            {
641                // Found existing parent
642                return liveRootNode.getNode(parentNodePath);
643            }
644            else
645            {
646                Node clonedAncestorNode = cloneAncestorsAndPreserveUUID(parentNode, liveSession);
647                Node clonedParentNode = null;
648                
649                if (clonedAncestorNode.hasNode(parentNode.getName()))
650                {
651                    // Possible with autocreated children
652                    clonedParentNode = clonedAncestorNode.getNode(parentNode.getName());
653                }
654                else
655                {
656                    clonedParentNode = addNodeWithUUID(parentNode, clonedAncestorNode, parentNode.getName());
657                    
658                }
659                
660                // reorder node when possible
661                if (parentNode.getParent().getPrimaryNodeType().hasOrderableChildNodes())
662                {
663                    orderNode(parentNode.getParent(), parentNode.getName(), clonedParentNode);
664                }
665                
666                // update existing acl
667                synchronizeACL(parentNode, liveSession);
668                
669                // Copy only properties
670                cloneNodeAndPreserveUUID(parentNode, clonedParentNode, PredicateUtils.truePredicate(), PredicateUtils.falsePredicate());
671                
672                return clonedParentNode;
673            }
674        }
675    }
676    
677    /**
678     * Clones a page and its eligible child pages, recursively.
679     * It is assumed that parent page already exist in the live workspace
680     * @param page the page to clone.
681     * @param skin the skin of the page's site.
682     * @param liveSession the session to the live workspace.
683     * @throws RepositoryException if an error occurs.
684     */
685    public void cloneEligiblePage(Page page, Skin skin, Session liveSession) throws RepositoryException
686    {
687        if (!(page instanceof JCRAmetysObject))
688        {
689            return;
690        }
691        
692        Node pageNode = ((JCRAmetysObject) page).getNode();
693        String pagePathInJcr = pageNode.getPath().substring(1);
694        Node rootNode = liveSession.getRootNode();
695        
696        boolean isNew = false;
697        Node liveNode = null;
698        
699        if (!rootNode.hasNode(pagePathInJcr))
700        {
701            isNew = true;
702            Node parentNode = pageNode.getParent();
703            String parentPath = parentNode.getPath().substring(1);
704            String pageName = pageNode.getName();
705            
706            // We assume that the parent Node exists.
707            Node liveParentNode = rootNode.getNode(parentPath);
708            liveNode = addNodeWithUUID(pageNode, liveParentNode, pageNode.getName());
709            
710            // reorder correctly
711            orderNode(parentNode, pageName, liveNode);
712        }
713        else
714        {
715            liveNode = rootNode.getNode(pagePathInJcr);
716        }
717        
718        // Clone all but child pages and zones
719        Predicate childPredicate = PredicateUtils.andPredicate(PredicateUtils.notPredicate(AmetysPredicateUtils.nodeTypePredicate("ametys:page")), 
720                PredicateUtils.notPredicate(AmetysPredicateUtils.nodeTypePredicate("ametys:zones")));
721        
722        cloneNodeAndPreserveUUID(pageNode, liveNode, PredicateUtils.truePredicate(), childPredicate);
723        
724        // Clone zones, if relevant
725        cloneZones(pageNode, page, liveNode);
726        
727        // In case of a new page , there may be valid children pages
728        if (isNew)
729        {
730            // Clone each child page
731            for (Page childPage : page.getChildrenPages())
732            {
733                if (childPage instanceof JCRAmetysObject && isPageValid(childPage, skin))
734                {
735                    cloneEligiblePage(childPage, skin, liveSession);
736                }
737            }
738        }
739    }
740    
741    /**
742     * Reorder a node, mirroring the order in the default workspace.
743     * @param parentNode the parent of the source Node in the default workspace.
744     * @param nodeName the node name.
745     * @param liveNode the node in the live workspace to be reordered.
746     * @throws RepositoryException if an error occurs.
747     */
748    public void orderNode(Node parentNode, String nodeName, Node liveNode) throws RepositoryException
749    {
750        _cloneComponent.orderNode(parentNode, nodeName, liveNode);
751    }
752    
753    /**
754     * Clones the zones of a page.
755     * @param pageNode the JCR Node of the page.
756     * @param sitemapElement the page.
757     * @param liveNode the node in the live workspace.
758     * @throws RepositoryException if an error occurs.
759     */
760    public void cloneZones(Node pageNode, SitemapElement sitemapElement, Node liveNode) throws RepositoryException
761    {
762        if (pageNode.hasNode("ametys-internal:zones"))
763        {
764            if (liveNode.hasNode("ametys-internal:zones"))
765            {
766                liveNode.getNode("ametys-internal:zones").remove();
767            }
768            
769            Node zonesNode = addNodeWithUUID(pageNode.getNode("ametys-internal:zones"), liveNode, "ametys-internal:zones");
770            
771            for (Zone zone : sitemapElement.getZones())
772            {
773                Node zoneNode = addNodeWithUUID(((JCRAmetysObject) zone).getNode(), zonesNode, zone.getName());
774                Node zoneItemsNode = zoneNode.getNode("ametys-internal:zoneItems");
775                
776                for (ZoneItem zoneItem : zone.getZoneItems())
777                {
778                    if (_isZoneItemValid(zoneItem))
779                    {
780                        Node srcNode = ((JCRAmetysObject) zoneItem).getNode();
781                        Node zoneItemNode = addNodeWithUUID(srcNode, zoneItemsNode, "ametys:zoneItem");
782                        cloneNodeAndPreserveUUID(srcNode, zoneItemNode, PredicateUtils.truePredicate(), PredicateUtils.truePredicate());
783                    }
784                }
785            }
786        }
787    }
788    
789    /**
790     * Invalidates the hierarchy of a page if needed
791     * @param page the page.
792     * @param skin the skin of the page's site.
793     * @param liveSession the session to the live workspace.
794     * @throws RepositoryException if an error occurs.
795     */
796    public void invalidateHierarchy(Page page, Skin skin, Session liveSession) throws RepositoryException
797    {
798        String jcrPath = ((JCRAmetysObject) page).getNode().getPath();
799        if (liveSession.itemExists(jcrPath))
800        {
801            liveSession.getItem(jcrPath).remove();
802        }
803
804        AmetysObject parent = page.getParent();
805        
806        if (parent instanceof Page)
807        {
808            if (!isPageValid((Page) parent, skin))
809            {
810                invalidateHierarchy((Page) parent, skin, liveSession);
811            }
812        }
813    }
814    
815    /**
816     * Synchronizes a page with the live workspace. Also synchronizes hierarchy.
817     * @param page the page.
818     * @param skin the skin of the page's site.
819     * @param liveSession the session to the live workspace.
820     * @throws RepositoryException if an error occurs.
821     */
822    public void synchronizePage(Page page, Skin skin, Session liveSession) throws RepositoryException
823    {
824        if (isHierarchyValid(page, liveSession))
825        {
826            if (isPageValid(page, skin))
827            {
828                //FIXME clone ancestor pages, not only ancestor nodes
829                cloneAncestorsAndPreserveUUID(((JCRAmetysObject) page).getNode(), liveSession);
830                
831                // clone page and valid children
832                cloneEligiblePage(page, skin, liveSession);
833            }
834            else
835            {
836                // page is invalid, remove it from live if it was previously valid, then potentially invalidate hierarchy
837                invalidateHierarchy(page, skin, liveSession);
838            }
839        }
840    }
841    
842    private void _sendErrorMailForInfiniteRedirection (Page page)
843    {
844        String recipient = Config.getInstance().getValue("smtp.mail.sysadminto");
845        try
846        {
847            List<String> i18nParams = new ArrayList<>();
848            i18nParams.add(page.getSite().getTitle());
849            
850            I18nizableText i18nSubject = new I18nizableText("plugin.web", "PLUGINS_WEB_SYNCHRONIZE_INFINITE_REDIRECTION_MAIL_SUBJECT", i18nParams);
851            String subject = _i18nUtils.translate(i18nSubject);
852            
853            i18nParams.add(page.getSitemapName() + "/" + page.getPathInSitemap());
854            i18nParams.add(StringUtils.stripEnd(StringUtils.removeEndIgnoreCase(Config.getInstance().getValue("cms.url"), "index.html"), "/"));
855            String body = _i18nUtils.translate(new I18nizableText("plugin.web", "PLUGINS_WEB_SYNCHRONIZE_INFINITE_REDIRECTION_MAIL_BODY", i18nParams));
856            
857            SendMailHelper.newMail()
858                          .withSubject(subject)
859                          .withTextBody(body)
860                          .withRecipient(recipient)
861                          .sendMail();
862        }
863        catch (MessagingException | IOException e)
864        {
865            if (getLogger().isWarnEnabled())
866            {
867                getLogger().warn("Could not send an alert e-mail to " + recipient, e);
868            }
869        }
870    }
871    
872    /**
873     * Synchronize a node ACL information with node in live session if available
874     * This method does NOT save live session
875     * @param node The trunk node in default workspace
876     * @param liveSession The live session
877     */
878    public void synchronizeACL(Node node, Session liveSession)
879    {
880        try
881        {
882            if (getLogger().isDebugEnabled())
883            {
884                getLogger().debug("Synchronizing ACL for node " + node.getIdentifier());
885            }
886
887            try
888            {
889                // content already exists in the live workspace 
890                Node clonedNode = liveSession.getNodeByIdentifier(node.getIdentifier());
891                
892                // Clone acl
893                String aclNodeNode = "ametys-internal:acl";
894                if (node.hasNode(aclNodeNode))
895                {
896                    Node aclNode = node.getNode(aclNodeNode);
897                    
898                    Node aclClonedNode;
899                    if (clonedNode.hasNode(aclNodeNode))
900                    {
901                        aclClonedNode = clonedNode.getNode(aclNodeNode);
902                    }
903                    else
904                    {
905                        aclClonedNode = clonedNode.addNode(aclNodeNode, aclNode.getPrimaryNodeType().getName());
906                    }
907
908                    cloneNodeAndPreserveUUID(aclNode, aclClonedNode, PredicateUtils.truePredicate(), PredicateUtils.truePredicate());
909                }
910            }
911            catch (ItemNotFoundException e)
912            {
913                // Nothing to synchronize: the content is not in live
914            }
915        }
916        catch (RepositoryException e)
917        {
918            throw new RuntimeException("Can not copy ACL for node", e);
919        }
920    }
921
922    /**
923     * Synchronize a list of content to the targeted session
924     * @param contents the set of contents
925     * @param liveSession the target session
926     * @throws AmetysRepositoryException if an error occurred while synchronizing
927     */
928    public void synchronizeContents(AmetysObjectIterable<Content> contents, Session liveSession) throws AmetysRepositoryException
929    {
930        String logMessage = "/" + contents.getSize() + " contents synchronized…";
931        Long counter = 0L;
932        for (Content content : contents)
933        {
934            try
935            {
936                synchronizeContent(content, liveSession);
937                liveSession.save();
938            }
939            catch (RepositoryException e)
940            {
941                throw new AmetysRepositoryException("Synchronization failed for content " + content.getId() + ". See below for more information.", e);
942            }
943            
944            if (++counter % 1000 == 0)
945            {
946                getLogger().info(counter + logMessage);
947            }
948        }
949    }
950}