001/*
002 * This library is part of OpenCms -
003 * the Open Source Content Management System
004 *
005 * Copyright (c) Alkacon Software GmbH & Co. KG (http://www.alkacon.com)
006 *
007 * This library is free software; you can redistribute it and/or
008 * modify it under the terms of the GNU Lesser General Public
009 * License as published by the Free Software Foundation; either
010 * version 2.1 of the License, or (at your option) any later version.
011 *
012 * This library is distributed in the hope that it will be useful,
013 * but WITHOUT ANY WARRANTY; without even the implied warranty of
014 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
015 * Lesser General Public License for more details.
016 *
017 * For further information about Alkacon Software, please see the
018 * company website: http://www.alkacon.com
019 *
020 * For further information about OpenCms, please see the
021 * project website: http://www.opencms.org
022 *
023 * You should have received a copy of the GNU Lesser General Public
024 * License along with this library; if not, write to the Free Software
025 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
026 */
027
028package org.opencms.ade.publish;
029
030import org.opencms.file.CmsObject;
031import org.opencms.file.CmsResource;
032import org.opencms.file.CmsResourceFilter;
033import org.opencms.file.types.CmsResourceTypeBinary;
034import org.opencms.file.types.CmsResourceTypeImage;
035import org.opencms.file.types.CmsResourceTypePlain;
036import org.opencms.file.types.CmsResourceTypePointer;
037import org.opencms.file.types.CmsResourceTypeXmlContent;
038import org.opencms.main.CmsException;
039import org.opencms.main.CmsLog;
040import org.opencms.main.OpenCms;
041import org.opencms.relations.CmsRelation;
042import org.opencms.relations.CmsRelationFilter;
043import org.opencms.relations.CmsRelationType;
044import org.opencms.util.CmsUUID;
045
046import java.util.Collection;
047import java.util.Collections;
048import java.util.HashMap;
049import java.util.List;
050import java.util.Map;
051import java.util.Set;
052
053import org.apache.commons.logging.Log;
054
055import com.google.common.base.Predicate;
056import com.google.common.collect.Lists;
057import com.google.common.collect.Maps;
058import com.google.common.collect.Sets;
059
060/**
061 * Helper class for finding all related resources for a set of resources to be published, for use with the new ADE publish dialog.<p>
062 */
063public class CmsPublishRelationFinder {
064
065    /**
066     * A map from resources to sets of resources, which automtically instantiates an empty set when accessing a key that
067     * doesn't exist via get().<p>
068     */
069    public static class ResourceMap extends HashMap<CmsResource, Set<CmsResource>> {
070
071        /** Serial version id. */
072        private static final long serialVersionUID = 1L;
073
074        /**
075         * Constructor.<p>
076         */
077        public ResourceMap() {
078
079            super();
080        }
081
082        /**
083         * Creates a new resource map based on this instance while filtering some elements out.<p>
084         *
085         * The given predicate is used to check whether any single resource should be kept. If it returns
086         * false for a top-level resource (map key), the parent will be removed and all its children added as
087         * keys. If it returns false for a map value, the value will be removed for its key.
088         *
089         * @param pred predicate to check whether resources should be kept
090         * @return the new filtered resource map
091         */
092        public ResourceMap filter(Predicate<CmsResource> pred) {
093
094            ResourceMap result = new ResourceMap();
095            for (CmsResource key : keySet()) {
096                if (pred.apply(key)) {
097                    result.get(key);
098                    for (CmsResource value : get(key)) {
099                        if (pred.apply(value)) {
100                            result.get(key).add(value);
101                        }
102                    }
103                } else {
104                    for (CmsResource value : get(key)) {
105                        if (pred.apply(value)) {
106                            result.get(value);
107                        }
108                    }
109
110                }
111            }
112            return result;
113        }
114
115        /**
116         * @see java.util.HashMap#get(java.lang.Object)
117         */
118        @Override
119        public Set<CmsResource> get(Object res) {
120
121            Set<CmsResource> result = super.get(res);
122            if (result == null) {
123                result = Sets.newHashSet();
124                put((CmsResource)res, result);
125            }
126            return result;
127        }
128
129        /**
130         * Returns the sum of all sizes of set values.<p>
131         *
132         * @return the total size
133         */
134        public int totalSize() {
135
136            int result = 0;
137            for (Map.Entry<CmsResource, Set<CmsResource>> entry : entrySet()) {
138                result += entry.getValue().size();
139            }
140            return result;
141        }
142
143    }
144
145    /** The log instance for this class. */
146    private static final Log LOG = CmsLog.getLog(CmsPublishRelationFinder.class);
147
148    /** The resource types whose resources will be also added as related resources, even if the relation pointing to them is a weak relation.<p> */
149    private static final String[] VALID_WEAK_RELATION_TARGET_TYPES = {
150        CmsResourceTypePlain.getStaticTypeName(),
151        CmsResourceTypeImage.getStaticTypeName(),
152        CmsResourceTypePointer.getStaticTypeName(),
153        CmsResourceTypeBinary.getStaticTypeName()};
154
155    /** The CMS context used by this object. */
156    private CmsObject m_cms;
157
158    /** Flag which controls whether unchanged resources in the original resource list should be kept or removed. */
159    private boolean m_keepOriginalUnchangedResources;
160
161    /** The original set of resources passed in the constructor. */
162    private Set<CmsResource> m_originalResources;
163
164    /** The provider for additional related resources. */
165    private I_CmsPublishRelatedResourceProvider m_relatedResourceProvider;
166
167    /** Cache for resources. */
168    private Map<CmsUUID, CmsResource> m_resources = Maps.newHashMap();
169
170    /**
171     * Creates a new instance.<p>
172     *
173     * @param cms the CMS context to use
174     * @param resources the resources for which the related resources should be found
175     * @param keepOriginalUnchangedResources true if unchanged resources from the original resource list should be kept
176     * @param relProvider provider for additional related resources
177     */
178    public CmsPublishRelationFinder(
179        CmsObject cms,
180        Collection<CmsResource> resources,
181        boolean keepOriginalUnchangedResources,
182        I_CmsPublishRelatedResourceProvider relProvider) {
183
184        m_cms = cms;
185        // put resources in a map with the structure id as a key
186        m_originalResources = Sets.newHashSet(resources);
187        for (CmsResource res : resources) {
188            m_resources.put(res.getStructureId(), res);
189        }
190        m_keepOriginalUnchangedResources = keepOriginalUnchangedResources;
191        m_relatedResourceProvider = relProvider;
192    }
193
194    /**
195     * Gets the related resources in the form of a ResourceMap.<p>
196     *
197     * @return a ResourceMap which has resources from the original set of resources as keys, and sets of related resources as values
198     *
199     */
200    public ResourceMap getPublishRelatedResources() {
201
202        ResourceMap related = computeRelatedResources();
203        ResourceMap reachable = computeReachability(related);
204        ResourceMap publishRelatedResources = getChangedResourcesReachableFromOriginalResources(reachable);
205        removeNestedItemsFromTopLevel(publishRelatedResources);
206        //addParentFolders(publishRelatedResources);
207        removeUnchangedTopLevelResources(publishRelatedResources, reachable);
208        return publishRelatedResources;
209    }
210
211    /**
212     * Removes unchanged resources from the top level, and if they have children which do not occur anywhere else,
213     * moves these children to the top level.<p>
214     *
215     * @param publishRelatedResources the resource map to modify
216     * @param reachability the reachability map
217     */
218    public void removeUnchangedTopLevelResources(ResourceMap publishRelatedResources, ResourceMap reachability) {
219
220        Set<CmsResource> unchangedParents = Sets.newHashSet();
221        Set<CmsResource> childrenOfUnchangedParents = Sets.newHashSet();
222        Set<CmsResource> other = Sets.newHashSet();
223        for (CmsResource parent : publishRelatedResources.keySet()) {
224            if (isUnchangedAndShouldBeRemoved(parent)) {
225                unchangedParents.add(parent);
226                childrenOfUnchangedParents.addAll(publishRelatedResources.get(parent));
227            } else {
228                other.add(parent);
229                other.addAll(publishRelatedResources.get(parent));
230            }
231        }
232
233        // we want the resources which *only* occur as children of unchanged parents
234        childrenOfUnchangedParents.removeAll(other);
235
236        for (CmsResource parent : unchangedParents) {
237            publishRelatedResources.remove(parent);
238        }
239
240        // Try to find hierarchical relationships in childrenOfUnchangedParents
241        while (findAndMoveParentWithChildren(childrenOfUnchangedParents, reachability, publishRelatedResources)) {
242            // do nothing
243        }
244        // only the resources with no 'children' are left, transfer them to the target map
245        for (CmsResource remainingResource : childrenOfUnchangedParents) {
246            publishRelatedResources.get(remainingResource);
247        }
248    }
249
250    /**
251     * Computes the "reachability map", given the map of direct relations between resources.<p>
252     *
253     * @param relatedResources a map containing the direct relations between resources
254     * @return a map from resources to the sets of resources which are reachable via relations
255     */
256    private ResourceMap computeReachability(ResourceMap relatedResources) {
257
258        ResourceMap result = new ResourceMap();
259        for (CmsResource resource : relatedResources.keySet()) {
260            result.get(resource).add(resource);
261            result.get(resource).addAll(relatedResources.get(resource));
262        }
263        int oldSize, newSize;
264        do {
265            ResourceMap newReachableResources = new ResourceMap();
266            oldSize = result.totalSize();
267            for (CmsResource source : result.keySet()) {
268                for (CmsResource target : result.get(source)) {
269                    // need to check if the key is present, otherwise we may get a ConcurrentModificationException
270                    if (result.containsKey(target)) {
271                        newReachableResources.get(source).addAll(result.get(target));
272                    }
273                }
274            }
275            newSize = newReachableResources.totalSize();
276            result = newReachableResources;
277        } while (oldSize < newSize);
278        return result;
279    }
280
281    /**
282     * Gets a ResourceMap which contains, for each resource reachable from the original set of resources, the directly related resources.<p>
283     *
284     * @return a map from resources to their directly related resources
285     */
286    private ResourceMap computeRelatedResources() {
287
288        ResourceMap relatedResources = new ResourceMap();
289        Set<CmsResource> resourcesToProcess = Sets.newHashSet(m_originalResources);
290        Set<CmsResource> processedResources = Sets.newHashSet();
291        while (!resourcesToProcess.isEmpty()) {
292            CmsResource currentResource = resourcesToProcess.iterator().next();
293            resourcesToProcess.remove(currentResource);
294            processedResources.add(currentResource);
295            if (!currentResource.getState().isDeleted()) {
296                Set<CmsResource> directlyRelatedResources = getDirectlyRelatedResources(currentResource);
297                for (CmsResource target : directlyRelatedResources) {
298                    if (m_cms.existsResource(target.getStructureId(), CmsResourceFilter.ALL.addRequireVisible())) {
299                        if (!processedResources.contains(target)) {
300                            resourcesToProcess.add(target);
301                        }
302                        relatedResources.get(currentResource).add(target);
303                    }
304                }
305            } else {
306                if (CmsResourceTypeXmlContent.isXmlContent(currentResource)
307                    && CmsResourceTypeXmlContent.isPossiblyDetailContent(currentResource)) {
308                    // special case for detail-only containers: If the corresponding detail content has been deleted and is going to be published,
309                    // we will usually want to publish the detail-only containers as well.
310                    List<CmsRelation> relations = getRelationsFromResource(currentResource);
311                    for (CmsRelation relation : relations) {
312                        if (relation.getType() == CmsRelationType.DETAIL_ONLY) {
313                            CmsResource target = getResource(relation.getTargetId());
314                            if (target != null) {
315                                // only add them to the related resources, no further recursive processing
316                                relatedResources.get(currentResource).add(target);
317                            }
318                        }
319                    }
320                }
321            }
322        }
323        return relatedResources;
324    }
325
326    /**
327     * Tries to find a parent with related children in a set, and moves them to a result ResourceMap.<p>
328     *
329     * @param originalSet the original set
330     * @param reachability the reachability ResourceMap
331     * @param result the target ResourceMap to move the parent/children to
332     *
333     * @return true if a parent with children could be found (and moved)
334     */
335    private boolean findAndMoveParentWithChildren(
336        Set<CmsResource> originalSet,
337        ResourceMap reachability,
338        ResourceMap result) {
339
340        for (CmsResource parent : originalSet) {
341            Set<CmsResource> reachableResources = reachability.get(parent);
342            Set<CmsResource> children = Sets.newHashSet();
343            if (reachableResources.size() > 1) {
344                for (CmsResource potentialChild : reachableResources) {
345                    if ((potentialChild != parent) && originalSet.contains(potentialChild)) {
346                        children.add(potentialChild);
347                    }
348                }
349                if (children.size() > 0) {
350                    result.get(parent).addAll(children);
351                    originalSet.removeAll(children);
352                    originalSet.remove(parent);
353                    return true;
354                }
355            }
356        }
357        return false;
358
359    }
360
361    /**
362     * Gets the resources which are reachable from the original set of resources and are not unchanged.<p>
363     *
364     * @param reachable the resource map of reachable resources
365     * @return the resources which are unchanged and reachable from the original set of resources
366     */
367    private ResourceMap getChangedResourcesReachableFromOriginalResources(ResourceMap reachable) {
368
369        ResourceMap publishRelatedResources = new ResourceMap();
370        for (CmsResource res : m_originalResources) {
371            Collection<CmsResource> reachableItems = reachable.get(res);
372            List<CmsResource> changedItems = Lists.newArrayList();
373            for (CmsResource item : reachableItems) {
374                if (!isUnchangedAndShouldBeRemoved(item) && !item.getStructureId().equals(res.getStructureId())) {
375                    changedItems.add(item);
376                }
377            }
378            publishRelatedResources.get(res).addAll(changedItems);
379        }
380        return publishRelatedResources;
381    }
382
383    /**
384     * Fetches the directly related resources for a given resource.<p>
385     *
386     * @param currentResource the resource for which to get the related resources
387     * @return the directly related resources
388     */
389    private Set<CmsResource> getDirectlyRelatedResources(CmsResource currentResource) {
390
391        Set<CmsResource> directlyRelatedResources = Sets.newHashSet();
392        List<CmsRelation> relations = getRelationsFromResource(currentResource);
393        for (CmsRelation relation : relations) {
394            LOG.info("Trying to read resource for relation " + relation.getTargetPath());
395            CmsResource target = getResource(relation.getTargetId());
396            if (target != null) {
397                if (relation.getType().isStrong() || shouldAddWeakRelationTarget(target)) {
398                    directlyRelatedResources.add(target);
399                }
400            }
401        }
402        try {
403            CmsResource parentFolder = m_cms.readParentFolder(currentResource.getStructureId());
404            if (parentFolder != null) { // parent folder of root folder is null
405                if (parentFolder.getState().isNew() || currentResource.isFile()) {
406                    directlyRelatedResources.add(parentFolder);
407                }
408            }
409        } catch (CmsException e) {
410            LOG.error(
411                "Error processing parent folder for " + currentResource.getRootPath() + ": " + e.getLocalizedMessage(),
412                e);
413        }
414
415        try {
416            directlyRelatedResources.addAll(
417                m_relatedResourceProvider.getAdditionalRelatedResources(m_cms, currentResource));
418        } catch (Exception e) {
419            LOG.error(
420                "Error processing additional related resource for "
421                    + currentResource.getRootPath()
422                    + ": "
423                    + e.getLocalizedMessage(),
424                e);
425        }
426        return directlyRelatedResources;
427    }
428
429    /**
430     * Reads the relations from a given resource, and returns an empty list if an error occurs while reading them.<p>
431     *
432     * @param currentResource the resource for which to get the relation
433     * @return the outgoing relations
434     */
435    private List<CmsRelation> getRelationsFromResource(CmsResource currentResource) {
436
437        try {
438            return m_cms.readRelations(CmsRelationFilter.relationsFromStructureId(currentResource.getStructureId()));
439        } catch (CmsException e) {
440            return Collections.emptyList();
441        }
442    }
443
444    /**
445     * Reads a resource with a given id, but will get a resource from a cache if it has already been read before.<p>
446     * If an error occurs, null will be returned.
447     *
448     * @param structureId the structure id
449     * @return the resource with the given structure id
450     */
451    private CmsResource getResource(CmsUUID structureId) {
452
453        CmsResource resource = m_resources.get(structureId);
454        if (resource == null) {
455            try {
456                resource = m_cms.readResource(structureId, CmsResourceFilter.ALL);
457                m_resources.put(structureId, resource);
458            } catch (CmsException e) {
459                LOG.info(e.getLocalizedMessage(), e);
460            }
461        }
462        return resource;
463    }
464
465    /**
466     * Checks if the resource is unchanged *and* should be removed.<p>
467     *
468     * @param item the resource to check
469     * @return true if the resource is unchanged and should be removed
470     */
471    private boolean isUnchangedAndShouldBeRemoved(CmsResource item) {
472
473        if (item.getState().isUnchanged()) {
474            return !m_keepOriginalUnchangedResources || !m_originalResources.contains(item);
475        }
476        return false;
477    }
478
479    /**
480     * Removes those resources as keys from the resource map which also occur as related resources under a different key.<p>
481     *
482     * @param publishRelatedResources the resource map from which to remove the duplicate items
483     */
484    private void removeNestedItemsFromTopLevel(ResourceMap publishRelatedResources) {
485
486        Set<CmsResource> toDelete = Sets.newHashSet();
487        for (CmsResource parent : publishRelatedResources.keySet()) {
488            if (toDelete.contains(parent)) {
489                continue;
490            }
491            for (CmsResource child : publishRelatedResources.get(parent)) {
492                if (publishRelatedResources.containsKey(child)) {
493                    toDelete.add(child);
494                }
495            }
496        }
497        for (CmsResource delResource : toDelete) {
498            publishRelatedResources.remove(delResource);
499        }
500    }
501
502    /**
503     * Checks if the resource should be added to the related resources even if the relation pointing to it is a weak relation.<p>
504     *
505     * @param weakRelationTarget the relation target resource
506     * @return true if the resource should be added as a related resource
507     */
508    private boolean shouldAddWeakRelationTarget(CmsResource weakRelationTarget) {
509
510        for (String typeName : VALID_WEAK_RELATION_TARGET_TYPES) {
511            if (OpenCms.getResourceManager().matchResourceType(typeName, weakRelationTarget.getTypeId())) {
512                return true;
513            }
514        }
515        return false;
516    }
517}