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