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