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