001/*
002 *  Copyright 2012 Anyware Services
003 *
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016package org.ametys.web.repository.content.shared;
017
018import java.time.ZonedDateTime;
019import java.util.Arrays;
020import java.util.Collection;
021import java.util.HashMap;
022import java.util.HashSet;
023import java.util.Map;
024import java.util.Set;
025
026import javax.jcr.Node;
027import javax.jcr.PropertyIterator;
028import javax.jcr.RepositoryException;
029
030import org.apache.avalon.framework.component.Component;
031import org.apache.avalon.framework.logger.AbstractLogEnabled;
032import org.apache.avalon.framework.service.ServiceException;
033import org.apache.avalon.framework.service.ServiceManager;
034import org.apache.avalon.framework.service.Serviceable;
035import org.apache.commons.lang.ArrayUtils;
036import org.apache.commons.lang.StringUtils;
037
038import org.ametys.cms.CmsConstants;
039import org.ametys.cms.ObservationConstants;
040import org.ametys.cms.repository.Content;
041import org.ametys.cms.repository.DefaultContent;
042import org.ametys.core.observation.Event;
043import org.ametys.core.observation.ObservationManager;
044import org.ametys.core.user.CurrentUserProvider;
045import org.ametys.core.user.UserIdentity;
046import org.ametys.plugins.explorer.resources.ModifiableResourceCollection;
047import org.ametys.plugins.explorer.resources.ResourceCollection;
048import org.ametys.plugins.repository.AmetysObjectResolver;
049import org.ametys.plugins.repository.AmetysRepositoryException;
050import org.ametys.plugins.repository.CopiableAmetysObject;
051import org.ametys.plugins.repository.ModifiableTraversableAmetysObject;
052import org.ametys.plugins.repository.RepositoryIntegrityViolationException;
053import org.ametys.plugins.repository.data.holder.ModifiableModelAwareDataHolder;
054import org.ametys.plugins.repository.data.holder.ModifiableModelLessDataHolder;
055import org.ametys.plugins.repository.jcr.JCRAmetysObject;
056import org.ametys.plugins.repository.jcr.NameHelper;
057import org.ametys.runtime.model.ElementDefinition;
058import org.ametys.runtime.model.ModelItem;
059import org.ametys.web.filter.SharedContentsHelper;
060import org.ametys.web.repository.content.ModifiableWebContent;
061import org.ametys.web.repository.content.SharedContent;
062import org.ametys.web.repository.content.WebContent;
063import org.ametys.web.repository.content.jcr.DefaultSharedContent;
064import org.ametys.web.repository.content.jcr.DefaultSharedContentFactory;
065import org.ametys.web.repository.content.jcr.DefaultWebContent;
066import org.ametys.web.repository.page.CopySiteComponent;
067import org.ametys.web.repository.page.ModifiableZoneItem;
068import org.ametys.web.repository.page.SitemapElement;
069import org.ametys.web.repository.page.ZoneItem;
070import org.ametys.web.repository.page.ZoneItem.ZoneType;
071import org.ametys.web.repository.site.Site;
072
073/**
074 * Component which provides methods to manage shared contents (creation, validation, and so on).
075 */
076public class SharedContentManager extends AbstractLogEnabled implements Serviceable, Component
077{
078    
079    /** The avalon role. */
080    public static final String ROLE = SharedContentManager.class.getName();
081    
082    /** The ametys object resolver. */
083    protected AmetysObjectResolver _resolver;
084    
085    /** The observation manager. */
086    protected ObservationManager _observationManager;
087    
088    /** The current user provider. */
089    protected CurrentUserProvider _currentUserProvider;
090    
091    /** The site copy component. */
092    protected CopySiteComponent _copySiteComponent;
093    
094    @Override
095    public void service(ServiceManager serviceManager) throws ServiceException
096    {
097        _resolver = (AmetysObjectResolver) serviceManager.lookup(AmetysObjectResolver.ROLE);
098        _observationManager = (ObservationManager) serviceManager.lookup(ObservationManager.ROLE);
099        _currentUserProvider = (CurrentUserProvider) serviceManager.lookup(CurrentUserProvider.ROLE);
100        _copySiteComponent = (CopySiteComponent) serviceManager.lookup(CopySiteComponent.ROLE);
101    }
102    
103    /**
104     * Create a {@link SharedContent} from an original content.
105     * @param site the site in which to create the shared content.
106     * @param originalContent the original content.
107     * @return the created shared content.
108     */
109    public DefaultSharedContent createSharedContent(Site site, DefaultContent originalContent)
110    {
111        try
112        {
113            ModifiableTraversableAmetysObject contentRoot = site.getRootContents();
114            
115            // Get a reference on the original node.
116            Node originalNode = originalContent.getNode();
117            
118            String copyName = originalContent.getName() + "-shared";
119            
120            DefaultSharedContent content = createContent(copyName, contentRoot);
121            
122            // Store the reference to the original content.
123            content.getNode().setProperty(DefaultSharedContent.INITIAL_CONTENT_PROPERTY, originalNode);
124            
125            String originalLanguage = originalContent.getLanguage();
126            if (originalLanguage != null)
127            {
128                content.setLanguage(originalContent.getLanguage());
129            }
130            
131            // Copy standard properties.
132            content.setTypes(originalContent.getTypes());
133            // Copy title needs model -> types must to be set before
134            SharedContentsHelper.copyTitle(originalContent, content);
135            
136            content.setCreator(originalContent.getCreator());
137            content.setCreationDate(originalContent.getCreationDate());
138            content.setLastContributor(originalContent.getLastContributor());
139            content.setLastModified(originalContent.getLastModified());
140            
141            content.saveChanges();
142            
143            // Copy the content data.
144            copyContentData(originalContent, content);
145            
146            // Create the first version
147            content.checkpoint();
148            
149            // Validate the shared content if the original content is validated.
150            if (ArrayUtils.contains(originalContent.getAllLabels(), CmsConstants.LIVE_LABEL))
151            {
152                validateContent(content);
153            }
154            
155            return content;
156        }
157        catch (RepositoryException e)
158        {
159            throw new AmetysRepositoryException(e);
160        }
161    }
162    
163    /**
164     * Copy the data of a content into a shared content.
165     * @param originalContent the content to copy data from.
166     * @param content the content to copy data to.
167     * @throws AmetysRepositoryException if an error occurs during copy
168     */
169    public void copyContentData(DefaultContent originalContent, DefaultSharedContent content) throws AmetysRepositoryException
170    {
171        String currentRevision = originalContent.getRevision();
172        
173        try
174        {
175            if (ArrayUtils.contains(originalContent.getAllLabels(), CmsConstants.LIVE_LABEL))
176            {
177                // Switch the content to its live revision.
178                originalContent.switchToLabel(CmsConstants.LIVE_LABEL);
179                
180                // Copy metadata.
181                removeAllModelAwareData(content);
182                originalContent.copyTo(content);
183                
184                // Copy unversioned metadata.
185                removeAllModelLessData(content.getUnversionedDataHolder());
186                originalContent.getUnversionedDataHolder().copyTo(content.getUnversionedDataHolder());
187                
188                // Copy attachments.
189                if (originalContent instanceof WebContent)
190                {
191                    WebContent originalWebContent = (WebContent) originalContent;
192                    ResourceCollection originalRootAttachments = originalWebContent.getRootAttachments();
193                    if (originalRootAttachments != null && originalRootAttachments instanceof CopiableAmetysObject)
194                    {
195                        // Remove the attachment root before copying.
196                        ModifiableResourceCollection rootAttachments = (ModifiableResourceCollection) content.getRootAttachments();
197                        if (rootAttachments != null) // root attachments can be missing if the content is an (unmodifiable) old version
198                        {
199                            rootAttachments.remove();
200                            ((CopiableAmetysObject) originalRootAttachments).copyTo(content, originalRootAttachments.getName());
201                        }
202                        
203                    }
204                    
205                    _copySiteComponent.updateSharedContent(originalWebContent, content);
206                }
207                
208                // The "site" metadata has been copied: revert it to the real value.
209                content.setSiteName(content.getSite().getName());
210                
211                content.saveChanges();
212            }
213        }
214        finally
215        {
216            originalContent.switchToRevision(currentRevision);
217        }
218    }
219    
220    /**
221     * Validate a shared content.
222     * @param content the content to validate.
223     */
224    public void validateContent(DefaultSharedContent content)
225    {
226        internalValidateContent(content);
227        
228        UserIdentity user = _currentUserProvider.getUser();
229        
230        // Notify observers that the content has been validated.
231        Map<String, Object> eventParams = new HashMap<>();
232        eventParams.put(ObservationConstants.ARGS_CONTENT, content);
233        eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId());
234        
235        _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_VALIDATED, user, eventParams));
236        
237    }
238    
239    /**
240     * Invalidate a shared content.
241     * @param content the content to invalidate.
242     */
243    public void invalidateSharedContent(DefaultSharedContent content)
244    {
245        internalUnpublishContent(content);
246        
247        UserIdentity user = _currentUserProvider.getUser();
248        
249        // Notify observers that the content has been unpublished.
250        Map<String, Object> eventParams = new HashMap<>();
251        eventParams.put(ObservationConstants.ARGS_CONTENT, content);
252        eventParams.put(org.ametys.web.ObservationConstants.ARGS_SITE_NAME, content.getSiteName());
253        
254        _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_UNTAG_LIVE, user, eventParams));
255    }
256    
257    /**
258     * Test if there are shared contents created from the given content.
259     * @param content the content to test.
260     * @return true if at least one shared content was created from the given content, false otherwise.
261     */
262    public boolean hasSharedContents(Content content)
263    {
264        try
265        {
266            if (content instanceof DefaultContent)
267            {
268                DefaultContent originalContent = (DefaultContent) content;
269                PropertyIterator references = originalContent.getNode().getReferences(DefaultSharedContent.INITIAL_CONTENT_PROPERTY);
270                
271                return references.hasNext();
272            }
273            
274            return false;
275        }
276        catch (RepositoryException e)
277        {
278            throw new AmetysRepositoryException(e);
279        }
280    }
281    
282    /**
283     * Get the list of shared contents created from the given content.
284     * @param content the content of which to get referencing shared contents.
285     * @return the shared contents created from the given content.
286     */
287    public Set<SharedContent> getSharedContents(Content content)
288    {
289        try
290        {
291            Set<SharedContent> sharedContents = new HashSet<>();
292            
293            if (content instanceof DefaultContent)
294            {
295                // Resolve the content to switch its revision without impacting the original object.
296                DefaultContent originalContent = (DefaultContent) content;
297                PropertyIterator references = originalContent.getNode().getReferences(DefaultSharedContent.INITIAL_CONTENT_PROPERTY);
298                while (references.hasNext())
299                {
300                    Node referer = references.nextProperty().getParent();
301                    DefaultSharedContent sharedContent = _resolver.resolve(referer, true);
302                    if (sharedContent != null)
303                    {
304                        sharedContents.add(sharedContent);
305                    }
306                }
307            }
308            
309            return sharedContents;
310        }
311        catch (RepositoryException e)
312        {
313            throw new AmetysRepositoryException(e);
314        }
315    }
316    
317    /**
318     * Remove the list of shared contents created from the given content.
319     * @param content the content of which to remove referencing shared content references.
320     */
321    public void removeSharedContentReferences(Content content)
322    {
323        try
324        {
325            // Get shared contents which reference the content.
326            Set<SharedContent> sharedContents = getSharedContents(content);
327            for (SharedContent sharedContent : sharedContents)
328            {
329                if (sharedContent instanceof JCRAmetysObject)
330                {
331                    Node node = ((JCRAmetysObject) sharedContent).getNode();
332                    node.getProperty(DefaultSharedContent.INITIAL_CONTENT_PROPERTY).remove();
333                }
334            }
335        }
336        catch (RepositoryException e)
337        {
338            throw new AmetysRepositoryException(e);
339        }
340    }
341    
342    /**
343     * Switch all shared contents created from the given content into default contents
344     * @param content the initial content with shared content references.
345     */
346    public void switchSharedContentReferences (Content content)
347    {
348        // Get shared contents which reference the content.
349        Set<SharedContent> sharedContents = getSharedContents(content);
350        
351        // Store migrated contents per site
352        Map<String, Content> migratedContents = new HashMap<>();
353        
354        for (SharedContent sharedContent : sharedContents)
355        {
356            String siteName = sharedContent.getSiteName();
357            String sharedContentName = sharedContent.getName();
358            String sharedContentId = sharedContent.getId();
359            String contentName = StringUtils.removeEnd(sharedContentName, "-shared");
360            
361            ModifiableTraversableAmetysObject parent = sharedContent.getParent();
362            
363            // Prepare deletion of shared content
364            Map<String, Object> sharedEventParams = new HashMap<>();
365            sharedEventParams.put(ObservationConstants.ARGS_CONTENT, sharedContent);
366            sharedEventParams.put(ObservationConstants.ARGS_CONTENT_NAME, sharedContentName);
367            sharedEventParams.put(ObservationConstants.ARGS_CONTENT_ID, sharedContentId);
368            sharedEventParams.put(org.ametys.web.ObservationConstants.ARGS_SITE_NAME, siteName);
369            _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_DELETING, _currentUserProvider.getUser(), sharedEventParams));
370            
371            if (content instanceof DefaultWebContent)
372            {
373                Content cContent = migratedContents.get(siteName);
374                
375                if (cContent == null)
376                {
377                    // Do a full copy of initial content - only one per site
378                    cContent = ((DefaultWebContent) content).copyTo(parent, contentName);
379                    if (cContent instanceof ModifiableWebContent)
380                    {
381                        ((ModifiableWebContent) cContent).setSiteName(siteName);
382                        _copySiteComponent.updateSharedContent((DefaultWebContent) content, (ModifiableWebContent) cContent, false);
383                    }
384                }
385                
386                // Update referenced zone items
387                Set<SitemapElement> refPages = new HashSet<>();
388                Collection<ZoneItem> refZoneItems = sharedContent.getReferencingZoneItems();
389                for (ZoneItem zoneItem : refZoneItems)
390                {
391                    SitemapElement sitemapElement = zoneItem.getZone().getSitemapElement();
392                    
393                    if (zoneItem instanceof ModifiableZoneItem)
394                    {
395                        // Update the zone item with the copied content
396                        ((ModifiableZoneItem) zoneItem).setContent(cContent);
397                        
398                        Map<String, Object> eventParams = new HashMap<>();
399                        eventParams.put(org.ametys.web.ObservationConstants.ARGS_SITEMAP_ELEMENT, sitemapElement);
400                        eventParams.put(org.ametys.web.ObservationConstants.ARGS_ZONE_ITEM, zoneItem);
401                        eventParams.put(org.ametys.web.ObservationConstants.ARGS_ZONE_ITEM_ID, zoneItem.getId());
402                        eventParams.put(org.ametys.web.ObservationConstants.ARGS_ZONE_TYPE, ZoneType.CONTENT);
403                        
404                        _observationManager.notify(new Event(org.ametys.web.ObservationConstants.EVENT_ZONEITEM_MODIFIED, _currentUserProvider.getUser(), eventParams));
405                    }
406                    
407                    refPages.add(sitemapElement);
408                }
409                
410                for (SitemapElement sitemapElement : refPages)
411                {
412                    Map<String, Object> eventParams = new HashMap<>();
413                    eventParams.put(ObservationConstants.ARGS_CONTENT, cContent);
414                    eventParams.put(ObservationConstants.ARGS_CONTENT_ID, cContent.getId());
415                    eventParams.put(org.ametys.web.ObservationConstants.ARGS_SITEMAP_ELEMENT, sitemapElement);
416                    
417                    String eventId = migratedContents.containsKey(siteName) ? ObservationConstants.EVENT_CONTENT_MODIFIED : ObservationConstants.EVENT_CONTENT_ADDED;
418                    _observationManager.notify(new Event(eventId, _currentUserProvider.getUser(), eventParams));
419                }
420                
421                migratedContents.put(siteName, cContent);
422            }
423            
424            // Remove the shared content
425            sharedContent.remove();
426            parent.saveChanges();
427            
428            _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_DELETED, _currentUserProvider.getUser(), sharedEventParams));
429        }
430    }
431    
432    /**
433     * Create a shared content in the given contents root.
434     * @param desiredContentName the desired content name.
435     * @param contentsNode the contents root.
436     * @return the created content.
437     */
438    protected DefaultSharedContent createContent(String desiredContentName, ModifiableTraversableAmetysObject contentsNode)
439    {
440        DefaultSharedContent content = null;
441        
442        String contentName = NameHelper.filterName(desiredContentName);
443        int errorCount = 0;
444        do
445        {
446            if (errorCount != 0)
447            {
448                contentName = NameHelper.filterName(desiredContentName + " " + (errorCount + 1));
449            }
450            try
451            {
452                content = contentsNode.createChild(contentName, DefaultSharedContentFactory.SHARED_CONTENT_NODETYPE);
453            }
454            catch (RepositoryIntegrityViolationException e)
455            {
456                // Content name is already used
457                errorCount++;
458            }
459        }
460        while (content == null);
461        
462        return content;
463    }
464    
465    /**
466     * Validate a shared content.
467     * @param content the content to validate.
468     */
469    protected void internalValidateContent(DefaultSharedContent content)
470    {
471        ZonedDateTime validationDate = ZonedDateTime.now();
472        
473        boolean isValid = Arrays.asList(content.getAllLabels()).contains(CmsConstants.LIVE_LABEL);
474        if (!isValid)
475        {
476            content.setLastMajorValidationDate(validationDate);
477        }
478        
479        content.setLastValidationDate(validationDate);
480        if (content.getFirstValidationDate() == null)
481        {
482            content.setFirstValidationDate(validationDate);
483        }
484        
485        content.saveChanges();
486        
487        content.checkpoint();
488        content.addLabel(CmsConstants.LIVE_LABEL, true);
489    }
490    
491    /**
492     * Unpublish a shared content.
493     * @param content the content to unpublish.
494     */
495    protected void internalUnpublishContent(DefaultSharedContent content)
496    {
497        if (ArrayUtils.contains(content.getAllLabels(), CmsConstants.LIVE_LABEL))
498        {
499            content.removeLabel(CmsConstants.LIVE_LABEL);
500        }
501        
502        content.saveChanges();
503    }
504    
505    /**
506     * Remove all data of a model aware data holder.
507     * @param dataHolder the model aware data holder to empty.
508     */
509    protected void removeAllModelAwareData(ModifiableModelAwareDataHolder dataHolder)
510    {
511        for (String dataName : dataHolder.getDataNames())
512        {
513            ModelItem modelItem = dataHolder.getDefinition(dataName);
514            if (!(modelItem instanceof ElementDefinition) || ((ElementDefinition) modelItem).isEditable())
515            {
516                dataHolder.removeValue(dataName);
517            }
518        }
519    }
520    
521    /**
522     * Remove all data of a model less data holder.
523     * @param dataHolder the model less data holder to empty.
524     */
525    protected void removeAllModelLessData(ModifiableModelLessDataHolder dataHolder)
526    {
527        dataHolder.getDataNames()
528                  .stream()
529                  .forEach(dataHolder::removeValue);
530    }
531    
532}