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.plugins.repository.version.VersionableAmetysObject;
058import org.ametys.runtime.model.ElementDefinition;
059import org.ametys.runtime.model.ModelItem;
060import org.ametys.web.filter.SharedContentsHelper;
061import org.ametys.web.repository.content.ModifiableWebContent;
062import org.ametys.web.repository.content.SharedContent;
063import org.ametys.web.repository.content.WebContent;
064import org.ametys.web.repository.content.jcr.DefaultSharedContent;
065import org.ametys.web.repository.content.jcr.DefaultSharedContentFactory;
066import org.ametys.web.repository.content.jcr.DefaultWebContent;
067import org.ametys.web.repository.page.CopySiteComponent;
068import org.ametys.web.repository.page.ModifiableZoneItem;
069import org.ametys.web.repository.page.SitemapElement;
070import org.ametys.web.repository.page.ZoneItem;
071import org.ametys.web.repository.page.ZoneItem.ZoneType;
072import org.ametys.web.repository.site.Site;
073
074/**
075 * Component which provides methods to manage shared contents (creation, validation, and so on).
076 */
077public class SharedContentManager extends AbstractLogEnabled implements Serviceable, Component
078{
079    
080    /** The avalon role. */
081    public static final String ROLE = SharedContentManager.class.getName();
082    
083    /** The ametys object resolver. */
084    protected AmetysObjectResolver _resolver;
085    
086    /** The observation manager. */
087    protected ObservationManager _observationManager;
088    
089    /** The current user provider. */
090    protected CurrentUserProvider _currentUserProvider;
091    
092    /** The site copy component. */
093    protected CopySiteComponent _copySiteComponent;
094    
095    @Override
096    public void service(ServiceManager serviceManager) throws ServiceException
097    {
098        _resolver = (AmetysObjectResolver) serviceManager.lookup(AmetysObjectResolver.ROLE);
099        _observationManager = (ObservationManager) serviceManager.lookup(ObservationManager.ROLE);
100        _currentUserProvider = (CurrentUserProvider) serviceManager.lookup(CurrentUserProvider.ROLE);
101        _copySiteComponent = (CopySiteComponent) serviceManager.lookup(CopySiteComponent.ROLE);
102    }
103    
104    /**
105     * Create a {@link SharedContent} from an original content.
106     * @param site the site in which to create the shared content.
107     * @param originalContent the original content.
108     * @return the created shared content.
109     */
110    public DefaultSharedContent createSharedContent(Site site, DefaultContent originalContent)
111    {
112        try
113        {
114            ModifiableTraversableAmetysObject contentRoot = site.getRootContents();
115            
116            // Get a reference on the original node.
117            Node originalNode = originalContent.getNode();
118            
119            String copyName = originalContent.getName() + "-shared";
120            
121            DefaultSharedContent content = createContent(copyName, contentRoot);
122            
123            // Store the reference to the original content.
124            content.getNode().setProperty(DefaultSharedContent.INITIAL_CONTENT_PROPERTY, originalNode);
125            
126            String originalLanguage = originalContent.getLanguage();
127            if (originalLanguage != null)
128            {
129                content.setLanguage(originalContent.getLanguage());
130            }
131            
132            // Copy standard properties.
133            content.setTypes(originalContent.getTypes());
134            // Copy title needs model -> types must to be set before
135            SharedContentsHelper.copyTitle(originalContent, content);
136            
137            content.setCreator(originalContent.getCreator());
138            content.setCreationDate(originalContent.getCreationDate());
139            content.setLastContributor(originalContent.getLastContributor());
140            content.setLastModified(originalContent.getLastModified());
141            content.setSiteName(site.getName());
142            
143            content.saveChanges();
144            
145            // Copy the content data.
146            copyContentData(originalContent, content);
147
148            return content;
149        }
150        catch (RepositoryException e)
151        {
152            throw new AmetysRepositoryException(e);
153        }
154    }
155    
156    /**
157     * Copy the data of a content into a shared content.
158     * @param originalContent the content to copy data from.
159     * @param content the content to copy data to.
160     * @throws AmetysRepositoryException if an error occurs during copy
161     */
162    public void copyContentData(DefaultContent originalContent, DefaultSharedContent content) throws AmetysRepositoryException
163    {
164        String currentRevision = originalContent.getRevision();
165        
166        try
167        {
168            if (ArrayUtils.contains(originalContent.getAllLabels(), CmsConstants.LIVE_LABEL))
169            {
170                // Switch the content to its live revision.
171                originalContent.switchToLabel(CmsConstants.LIVE_LABEL);
172                
173                // Copy metadata.
174                removeAllModelAwareData(content);
175                originalContent.copyTo(content);
176                
177                // Copy unversioned metadata.
178                removeAllModelLessData(content.getUnversionedDataHolder());
179                originalContent.getUnversionedDataHolder().copyTo(content.getUnversionedDataHolder());
180                
181                // Copy attachments.
182                ResourceCollection originalRootAttachments = originalContent.getRootAttachments();
183                if (originalRootAttachments != null && originalRootAttachments instanceof CopiableAmetysObject)
184                {
185                    // Remove the attachment root before copying.
186                    ModifiableResourceCollection rootAttachments = (ModifiableResourceCollection) content.getRootAttachments();
187                    if (rootAttachments != null) // root attachments can be missing if the content is an (unmodifiable) old version
188                    {
189                        rootAttachments.remove();
190                        ((CopiableAmetysObject) originalRootAttachments).copyTo(content, originalRootAttachments.getName());
191                    }
192                }
193
194                if (originalContent instanceof WebContent originalWebContent)
195                {
196                    _copySiteComponent.updateSharedContent(originalWebContent, content);
197                }
198                
199                content.saveChanges();
200                content.checkpoint();
201                
202                Map<String, Object> eventParams = new HashMap<>();
203                eventParams.put(ObservationConstants.ARGS_CONTENT, content);
204                eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId());
205                
206                _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_MODIFIED, _currentUserProvider.getUser(), eventParams));
207
208                // Validate the shared content if the original content is validated, and only create a new version otherwise.
209                if (ArrayUtils.contains(originalContent.getAllLabels(), CmsConstants.LIVE_LABEL))
210                {
211                    validateContent(content);
212                }
213            }
214        }
215        finally
216        {
217            originalContent.switchToRevision(currentRevision);
218        }
219    }
220    
221    /**
222     * Validate a shared content.
223     * @param content the content to validate.
224     */
225    public void validateContent(DefaultSharedContent content)
226    {
227        internalValidateContent(content);
228        
229        UserIdentity user = _currentUserProvider.getUser();
230        
231        // Notify observers that the content has been validated.
232        Map<String, Object> eventParams = new HashMap<>();
233        eventParams.put(ObservationConstants.ARGS_CONTENT, content);
234        eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId());
235        
236        _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_VALIDATED, user, eventParams));
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 modifiableWebContent)
380                    {
381                        ((ModifiableWebContent) cContent).setSiteName(siteName);
382                        _copySiteComponent.updateSharedContent((DefaultWebContent) content, modifiableWebContent, false);
383                        
384                        modifiableWebContent.saveChanges();
385                        
386                        if (modifiableWebContent instanceof VersionableAmetysObject versionableWebContent)
387                        {
388                            versionableWebContent.checkpoint();
389                        }
390                    }
391                }
392                
393                // Update referenced zone items
394                Set<SitemapElement> refPages = new HashSet<>();
395                Collection<ZoneItem> refZoneItems = sharedContent.getReferencingZoneItems();
396                for (ZoneItem zoneItem : refZoneItems)
397                {
398                    SitemapElement sitemapElement = zoneItem.getZone().getSitemapElement();
399                    
400                    if (zoneItem instanceof ModifiableZoneItem)
401                    {
402                        // Update the zone item with the copied content
403                        ((ModifiableZoneItem) zoneItem).setContent(cContent);
404                        
405                        Map<String, Object> eventParams = new HashMap<>();
406                        eventParams.put(org.ametys.web.ObservationConstants.ARGS_SITEMAP_ELEMENT, sitemapElement);
407                        eventParams.put(org.ametys.web.ObservationConstants.ARGS_ZONE_ITEM, zoneItem);
408                        eventParams.put(org.ametys.web.ObservationConstants.ARGS_ZONE_ITEM_ID, zoneItem.getId());
409                        eventParams.put(org.ametys.web.ObservationConstants.ARGS_ZONE_TYPE, ZoneType.CONTENT);
410                        
411                        _observationManager.notify(new Event(org.ametys.web.ObservationConstants.EVENT_ZONEITEM_MODIFIED, _currentUserProvider.getUser(), eventParams));
412                    }
413                    
414                    refPages.add(sitemapElement);
415                }
416                
417                for (SitemapElement sitemapElement : refPages)
418                {
419                    Map<String, Object> eventParams = new HashMap<>();
420                    eventParams.put(ObservationConstants.ARGS_CONTENT, cContent);
421                    eventParams.put(ObservationConstants.ARGS_CONTENT_ID, cContent.getId());
422                    eventParams.put(org.ametys.web.ObservationConstants.ARGS_SITEMAP_ELEMENT, sitemapElement);
423                    
424                    String eventId = migratedContents.containsKey(siteName) ? ObservationConstants.EVENT_CONTENT_MODIFIED : ObservationConstants.EVENT_CONTENT_ADDED;
425                    _observationManager.notify(new Event(eventId, _currentUserProvider.getUser(), eventParams));
426                }
427                
428                migratedContents.put(siteName, cContent);
429            }
430            
431            // Remove the shared content
432            sharedContent.remove();
433            parent.saveChanges();
434            
435            _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_DELETED, _currentUserProvider.getUser(), sharedEventParams));
436        }
437    }
438    
439    /**
440     * Create a shared content in the given contents root.
441     * @param desiredContentName the desired content name.
442     * @param contentsNode the contents root.
443     * @return the created content.
444     */
445    protected DefaultSharedContent createContent(String desiredContentName, ModifiableTraversableAmetysObject contentsNode)
446    {
447        DefaultSharedContent content = null;
448        
449        String contentName = NameHelper.filterName(desiredContentName);
450        int errorCount = 0;
451        do
452        {
453            if (errorCount != 0)
454            {
455                contentName = NameHelper.filterName(desiredContentName + " " + (errorCount + 1));
456            }
457            try
458            {
459                content = contentsNode.createChild(contentName, DefaultSharedContentFactory.SHARED_CONTENT_NODETYPE);
460            }
461            catch (RepositoryIntegrityViolationException e)
462            {
463                // Content name is already used
464                errorCount++;
465            }
466        }
467        while (content == null);
468        
469        return content;
470    }
471    
472    /**
473     * Validate a shared content.
474     * @param content the content to validate.
475     */
476    protected void internalValidateContent(DefaultSharedContent content)
477    {
478        ZonedDateTime validationDate = ZonedDateTime.now();
479        
480        boolean isValid = Arrays.asList(content.getAllLabels()).contains(CmsConstants.LIVE_LABEL);
481        if (!isValid)
482        {
483            content.setLastMajorValidationDate(validationDate);
484        }
485        
486        content.setLastValidationDate(validationDate);
487        if (content.getFirstValidationDate() == null)
488        {
489            content.setFirstValidationDate(validationDate);
490        }
491        
492        content.saveChanges();
493        
494        content.checkpoint();
495        content.addLabel(CmsConstants.LIVE_LABEL, true);
496    }
497    
498    /**
499     * Unpublish a shared content.
500     * @param content the content to unpublish.
501     */
502    protected void internalUnpublishContent(DefaultSharedContent content)
503    {
504        if (ArrayUtils.contains(content.getAllLabels(), CmsConstants.LIVE_LABEL))
505        {
506            content.removeLabel(CmsConstants.LIVE_LABEL);
507        }
508        
509        content.saveChanges();
510    }
511    
512    /**
513     * Remove all data of a model aware data holder.
514     * @param dataHolder the model aware data holder to empty.
515     */
516    protected void removeAllModelAwareData(ModifiableModelAwareDataHolder dataHolder)
517    {
518        for (String dataName : dataHolder.getDataNames())
519        {
520            ModelItem modelItem = dataHolder.getDefinition(dataName);
521            if (!(modelItem instanceof ElementDefinition) || ((ElementDefinition) modelItem).isEditable())
522            {
523                dataHolder.removeValue(dataName);
524            }
525        }
526    }
527    
528    /**
529     * Remove all data of a model less data holder.
530     * @param dataHolder the model less data holder to empty.
531     */
532    protected void removeAllModelLessData(ModifiableModelLessDataHolder dataHolder)
533    {
534        dataHolder.getDataNames()
535                  .stream()
536                  .forEach(dataHolder::removeValue);
537    }
538    
539}