001/*
002 *  Copyright 2020 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.cms.content.version;
017
018import java.io.IOException;
019import java.util.List;
020import java.util.Objects;
021import java.util.Optional;
022import java.util.function.Predicate;
023import java.util.stream.Stream;
024
025import org.apache.avalon.framework.component.Component;
026import org.apache.avalon.framework.service.ServiceException;
027import org.apache.avalon.framework.service.ServiceManager;
028import org.apache.avalon.framework.service.Serviceable;
029import org.apache.commons.lang3.ArrayUtils;
030
031import org.ametys.cms.content.compare.ContentComparator;
032import org.ametys.cms.content.compare.ContentComparatorChange;
033import org.ametys.cms.content.compare.ContentComparatorResult;
034import org.ametys.cms.data.type.ModelItemTypeConstants;
035import org.ametys.cms.data.type.ResourceElementTypeHelper;
036import org.ametys.cms.repository.Content;
037import org.ametys.core.util.LambdaUtils;
038import org.ametys.plugins.repository.AmetysObjectResolver;
039import org.ametys.plugins.repository.AmetysRepositoryException;
040import org.ametys.plugins.repository.version.VersionAwareAmetysObject;
041import org.ametys.runtime.model.ModelItem;
042
043import com.google.common.base.Predicates;
044
045/**
046 * Helper for retrieving some useful version informations about {@link VersionAwareAmetysObject}s, and compare some versions.
047 */
048public class CompareVersionHelper implements Component, Serviceable
049{
050    /** Avalon Role */
051    public static final String ROLE = CompareVersionHelper.class.getName();
052    
053    /** The ametys object resolver */
054    protected AmetysObjectResolver _ametysObjectResolver;
055    /** The content comparator */
056    protected ContentComparator _contentComparator;
057    
058    @Override
059    public void service(ServiceManager manager) throws ServiceException
060    {
061        _ametysObjectResolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
062        _contentComparator = (ContentComparator) manager.lookup(ContentComparator.ROLE);
063    }
064    
065    /**
066     * Gets the current version of the given {@link VersionAwareAmetysObject}
067     * @param versionable The {@link VersionAwareAmetysObject}
068     * @return The current version
069     */
070    public String getCurrentVersion(VersionAwareAmetysObject versionable)
071    {
072        String[] allRevisions = versionable.getAllRevisions();
073        if (allRevisions.length < 1)
074        {
075            throw new IllegalStateException(String.format("An unexpected error occured, the versionable content '%s' has no revision.", versionable));
076        }
077        String revision = allRevisions[allRevisions.length - 1];
078        return revision;
079    }
080    
081    /**
082     * Gets the previous version of the given {@link VersionAwareAmetysObject}
083     * @param versionable The {@link VersionAwareAmetysObject}
084     * @return The previous version
085     */
086    public Optional<String> getPreviousVersion(VersionAwareAmetysObject versionable)
087    {
088        return Optional.of(versionable.getAllRevisions())
089                .filter(allRevisions -> allRevisions.length >= 2)
090                .map(allRevisions -> allRevisions[allRevisions.length - 2]);
091    }
092    
093    /**
094     * Gets the last version of the given {@link VersionAwareAmetysObject} with the given label
095     * @param versionable The {@link VersionAwareAmetysObject}
096     * @param label The label
097     * @return the last version
098     */
099    public Optional<String> getLastVersionWithLabel(VersionAwareAmetysObject versionable, String label)
100    {
101        String[] allRevisions = versionable.getAllRevisions();
102        for (int i = allRevisions.length - 1; i >= 0; i--)
103        {
104            String revision = allRevisions[i];
105            String[] revisionLabels = versionable.getLabels(revision);
106            if (ArrayUtils.contains(revisionLabels, label))
107            {
108                return Optional.of(revision);
109            }
110        }
111        
112        return Optional.empty();
113    }
114    
115    /**
116     * Compare two versions of the same content
117     * @param <C> The type of the {@link VersionAwareAmetysObject} {@link Content}
118     * @param versionableContentId The {@link Content} id to compare, which is {@link VersionAwareAmetysObject}
119     * @param baseVersion The base version
120     * @param version The version to be compared
121     * @return The {@link ContentComparatorResult}
122     * @throws AmetysRepositoryException repository exception
123     * @throws IOException IO exception
124     */
125    public <C extends Content & VersionAwareAmetysObject> ContentComparatorResult compareVersions(String versionableContentId, String baseVersion, String version) throws AmetysRepositoryException, IOException
126    {
127        C baseContent = getContentVersion(versionableContentId, baseVersion);
128        C targetContent = getContentVersion(versionableContentId, version);
129        ContentComparatorResult comparatorResult = _contentComparator.compare(targetContent/* the 1st content is the target */, baseContent/* the 2nd is the base */);
130        return comparatorResult;
131    }
132    
133    /**
134     * Gets the given version of the given {@link Content} od
135     * @param <C> The type of the {@link VersionAwareAmetysObject} {@link Content}
136     * @param contentId The {@link Content} id
137     * @param version The version. Can be null for current version
138     * @return the given version of the given {@link Content} od
139     */
140    public <C extends Content & VersionAwareAmetysObject> C getContentVersion(String contentId, String version)
141    {
142        C versionableContent = _ametysObjectResolver.resolveById(contentId);
143        versionableContent.switchToRevision(version);
144        return versionableContent;
145    }
146    
147    /**
148     * Gets the attribute changes (as {@link ModelItem}s) from the given {@link ContentComparatorChange}s (as a {@link ContentComparatorChange} path can be more detailed than a {@link ModelItem#getPath model item path})
149     * <br>The false positive changes are also {@link #filterChanges filtered}
150     * @param changes The {@link ContentComparatorChange}s
151     * @return The changed {@link ModelItem}s
152     */
153    public Stream<ModelItem> getChangedModelItems(List<ContentComparatorChange> changes)
154    {
155        return _filterChanges(changes)
156                .distinct() // some changes are different but are about the same ModelItem => remove those duplicates
157                .map(attributeChange -> attributeChange._contentComparatorChange)
158                .map(ContentComparatorChange::getModelItem);
159    }
160    
161    /**
162     * Gets the {@link ContentComparatorChange}s, with some of them filtered (such as {@link ResourceElementTypeHelper#LAST_MODIFICATION_DATE_IDENTIFIER someRichText/lastModified}),
163     * because they can be considered as "false positive", from the given {@link ContentComparatorChange}s
164     * @param changes The {@link ContentComparatorChange}s
165     * @return The filtered changed {@link ContentComparatorChange}s
166     */
167    public Stream<ContentComparatorChange> filterChanges(List<ContentComparatorChange> changes)
168    {
169        return _filterChanges(changes)
170                .map(attributeChange -> attributeChange._contentComparatorChange);
171    }
172    
173    private Stream<AttributeModelChange> _filterChanges(List<ContentComparatorChange> changes)
174    {
175        return changes
176                .stream()
177                .map(LambdaUtils.wrap(contentComparatorChange -> _getAttributeModelChange(contentComparatorChange)))
178                .filter(_attributeModelChangeFilters()); // filter out some changes, such as "someRichTextAttribute/lastModified"
179    }
180
181    private AttributeModelChange _getAttributeModelChange(ContentComparatorChange contentComparatorChange)
182    {
183        return new AttributeModelChange(contentComparatorChange);
184    }
185    
186    private static Predicate<AttributeModelChange> _attributeModelChangeFilters()
187    {
188        return Predicates.compose(CompareVersionHelper::_filterContentComparatorChange, attributeModelChange -> attributeModelChange._contentComparatorChange);
189    }
190    
191    private static boolean _filterContentComparatorChange(ContentComparatorChange contentComparatorChange)
192    {
193        return !_isLastModifiedOnRichText(contentComparatorChange); // hardcoded filter on "someRichTextAttribute/lastModified"
194        // here we can add some other static filters...
195    }
196    
197    private static boolean _isLastModifiedOnRichText(ContentComparatorChange contentComparatorChange)
198    {
199        ModelItem modelItem = contentComparatorChange.getModelItem();
200        return ModelItemTypeConstants.RICH_TEXT_ELEMENT_TYPE_ID.equals(modelItem.getType().getId())
201                && contentComparatorChange.getDetailDataPath().equals(ResourceElementTypeHelper.LAST_MODIFICATION_DATE_IDENTIFIER);
202    }
203    
204    private static class AttributeModelChange
205    {
206        /** The change (is more detailed than on a specific attribute) given by {@link ContentComparator} */
207        private final ContentComparatorChange _contentComparatorChange;
208        /** The path of the involved model item */
209        private final String _modelItemPath;
210
211        AttributeModelChange(ContentComparatorChange change)
212        {
213            _contentComparatorChange = change;
214            _modelItemPath = _contentComparatorChange.getModelItem().getPath();
215        }
216        
217        @Override
218        public int hashCode()
219        {
220            return Objects.hash(_modelItemPath);
221        }
222
223        // consider equals if it concerns the same modelItem (i.e. for a change about a/b/c and a/b/d, if the modelItem is 'a/b', it is the same as we are just interested in attribute change)
224        @Override
225        public boolean equals(Object obj)
226        {
227            if (this == obj)
228            {
229                return true;
230            }
231            if (obj == null)
232            {
233                return false;
234            }
235            if (getClass() != obj.getClass())
236            {
237                return false;
238            }
239            AttributeModelChange other = (AttributeModelChange) obj;
240            return Objects.equals(_modelItemPath, other._modelItemPath);
241        }
242    }
243}