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