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