001/*
002 *  Copyright 2017 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.compare;
017
018import java.io.IOException;
019import java.util.Arrays;
020import java.util.List;
021
022import org.apache.avalon.framework.component.Component;
023import org.apache.avalon.framework.service.ServiceException;
024import org.apache.avalon.framework.service.ServiceManager;
025import org.apache.avalon.framework.service.Serviceable;
026import org.apache.commons.lang3.StringUtils;
027
028import org.ametys.cms.contenttype.ContentTypesHelper;
029import org.ametys.cms.repository.Content;
030import org.ametys.plugins.repository.AmetysRepositoryException;
031import org.ametys.plugins.repository.data.holder.group.ModelAwareRepeater;
032import org.ametys.plugins.repository.data.type.ModelItemTypeConstants;
033import org.ametys.runtime.i18n.I18nizableText;
034import org.ametys.runtime.model.ElementDefinition;
035import org.ametys.runtime.model.ModelItem;
036import org.ametys.runtime.model.ModelViewItem;
037import org.ametys.runtime.model.ModelViewItemGroup;
038import org.ametys.runtime.model.SimpleViewItemGroup;
039import org.ametys.runtime.model.View;
040import org.ametys.runtime.model.ViewItem;
041import org.ametys.runtime.model.compare.DataChangeType;
042import org.ametys.runtime.plugin.component.AbstractLogEnabled;
043
044/**
045 * Object used to compare two contents
046 *
047 */
048public class ContentComparator extends AbstractLogEnabled implements Component, Serviceable
049{
050    /** The Avalon role */
051    public static final String ROLE = ContentComparator.class.getName();
052    
053    private ContentTypesHelper _contentTypesHelper;
054    
055    public void service(ServiceManager manager) throws ServiceException
056    {
057        this._contentTypesHelper = (ContentTypesHelper) manager.lookup(ContentTypesHelper.ROLE);
058    }
059    
060    /**
061     * Compare 2 contents, for all their common attributes (contents with different contentTypes are rejected)
062     * @param content1 1st content
063     * @param content2 new content
064     * @return {@link ContentComparatorResult}
065     * @throws AmetysRepositoryException repository exception
066     * @throws IOException IO exception
067     */
068    public ContentComparatorResult compare(Content content1, Content content2) throws AmetysRepositoryException, IOException
069    {
070        return compare(content1, content2, null);
071    }
072    
073    /**
074     * Compare 2 contents, for all their common attributes
075     * @param content1 1st content
076     * @param content2 new content
077     * @param strictCompare true to reject contents with different content types
078     * @return {@link ContentComparatorResult}
079     * @throws AmetysRepositoryException repository exception
080     * @throws IOException IO exception
081     */
082    public ContentComparatorResult compare(Content content1, Content content2, boolean strictCompare) throws AmetysRepositoryException, IOException
083    {
084        return compare(content1, content2, null, strictCompare);
085    }
086    
087    /**
088     * Compare 2 contents, filtering with a view (contents with different contentTypes are rejected)
089     * @param content1 1st content
090     * @param content2 new content
091     * @param viewName name of the view
092     * @return {@link ContentComparatorResult}
093     * @throws AmetysRepositoryException repository exception
094     * @throws IOException IO exception
095     */
096    public ContentComparatorResult compare(Content content1, Content content2, String viewName) throws AmetysRepositoryException, IOException
097    {
098        return compare(content1, content2, viewName, true);
099    }
100    
101    /**
102     * Compare 2 contents, filtering with a view
103     * @param content1 1st content
104     * @param content2 new content
105     * @param viewName name of the view
106     * @param strictCompare true to reject contents with different content types
107     * @return {@link ContentComparatorResult}
108     * @throws AmetysRepositoryException repository exception
109     * @throws IOException IO exception
110     */
111    public ContentComparatorResult compare(Content content1, Content content2, String viewName, boolean strictCompare) throws AmetysRepositoryException, IOException
112    {
113        return _compare(content1, content2, viewName, strictCompare);
114    }
115    
116    private ContentComparatorResult _compare(Content content1, Content content2, String viewName, boolean strictCompare) throws AmetysRepositoryException, IOException
117    {
118        //if strictCompare, we check if the types are strictly equals
119        List<String> types1 = Arrays.asList(content1.getTypes());
120        List<String> types2 = Arrays.asList(content2.getTypes());
121        if (strictCompare && (types1.size() != types2.size() || !types1.containsAll(types2) || !types2.containsAll(types1)))
122        {
123            ContentComparatorResult result = new ContentComparatorResult(content1, content2);
124            result.setNotEquals();
125            return result;
126        }
127        else
128        {
129            View view = _generateView(viewName, content1);
130            ContentComparatorResult result = new ContentComparatorResult(content1, content2, viewName, view);
131            if (view != null)
132            {
133                for (ViewItem viewItem : view.getViewItems())
134                {
135                    _compareModelItems(result, content1, content2, viewItem, StringUtils.EMPTY);
136                }
137            }
138            else
139            {
140                result.setNotEquals();
141            }
142            
143            return result;
144        }
145    }
146    
147    /**
148     * Generate a view using a name (can be null) and a content
149     * @param viewName name of the view. If null, a __generated__ view will be created using all content's attributes
150     * @param content content where the view should be retrieved
151     * @return {@link View} from the name, or generated
152     */
153    private View _generateView(String viewName, Content content)
154    {
155        if (viewName != null)
156        {
157            return _contentTypesHelper.getView(viewName, content.getTypes(), content.getMixinTypes());
158        }
159        else
160        {
161            View  view = View.of(content.getModel());
162            view.setName("__generated__");
163            view.setLabel(new I18nizableText("Live edition metadataset"));
164            view.setDescription(new I18nizableText("Live edition metadataset"));
165            view.setSmallIcon(null);
166            view.setMediumIcon(null);
167            view.setLargeIcon(null);
168            view.setInternal(true);
169            
170            return view;
171        }
172    }
173
174    /**
175     * Compare 2 model items and add the differences in result
176     * @param result global result where differences will be stored
177     * @param content1 1st content
178     * @param content2 2nd content
179     * @param viewItem view item that will be compared
180     * @param parentDataPath absolute data path of the container of the current view item 
181     * @throws AmetysRepositoryException repository exception
182     * @throws IOException IO Exception (comparison of InputStream mainly)
183     */
184    private void _compareModelItems(ContentComparatorResult result, Content content1, Content content2, ViewItem viewItem, String parentDataPath) throws AmetysRepositoryException, IOException
185    {
186        // If the view item is a reference to a model item, compare the model items and their values
187        if (viewItem instanceof ModelViewItem modelViewItem)
188        {
189            ModelItem modelItemFromView = modelViewItem.getDefinition();
190            String modelItemPath = modelItemFromView.getPath();
191            
192            ModelItem modelItem1 = null;
193            if (content1.hasDefinition(modelItemPath))
194            {
195                modelItem1 = content1.getDefinition(modelItemPath);
196            }
197            
198            ModelItem modelItem2 = null;
199            if (content2.hasDefinition(modelItemPath))
200            {
201                modelItem2 = content2.getDefinition(modelItemPath);
202            }
203            
204            if (modelItem1 != null && modelItem2 != null && modelItem1.getModel().equals(modelItem2.getModel()))
205            {
206                String modelItemName = modelViewItem.getDefinition().getName();
207                String currentDataPath = StringUtils.isNotEmpty(parentDataPath) ? parentDataPath + ModelItem.ITEM_PATH_SEPARATOR + modelItemName : modelItemName;
208                if (_checkModelItemPresence(result, content1, content2, currentDataPath, true))
209                {
210                    if (ModelItemTypeConstants.COMPOSITE_TYPE_ID.equals(modelItemFromView.getType().getId())) 
211                    {
212                        _compareComposite(result, (ModelViewItemGroup) modelViewItem, content1, content2, modelItemFromView, currentDataPath);
213                    }
214                    else if (ModelItemTypeConstants.REPEATER_TYPE_ID.equals(modelItemFromView.getType().getId()))
215                    {
216                        _compareRepeater(result, (ModelViewItemGroup) modelViewItem, content1, content2, modelItemFromView, currentDataPath);
217                    }
218                    else
219                    {
220                        // If the model item is not a group, it has to be an ElementDefinition
221                        _compareElement(result, content1, content2, (ElementDefinition) modelItemFromView, currentDataPath);
222                    }
223                }
224            }
225            else
226            {
227                // TODO CMS-9978 Ignore or add the difference? What happens if an item is defined by a content but not by the other one?
228                // TODO CMS-9978 Shouldn't we compare the definitions? (cardinality, ...)?
229            }
230        }
231
232        // If the view item contains other view items, compare the children
233        if (viewItem instanceof SimpleViewItemGroup simpleViewItemGroup)
234        {
235            for (ViewItem child : simpleViewItemGroup.getViewItems())
236            {
237                _compareModelItems(result, content1, content2, child, parentDataPath);
238            }
239        }
240    }
241    
242    /**
243     * Check if a model item is present in both contents and add a change if requested
244     * @param result The result
245     * @param content1 1st content
246     * @param content2 2nd content
247     * @param dataPath absolute path of the data to check (also used to log)
248     * @param logChange true to add a change in the list (if one has it and the other doesn't)
249     * @return true if both contents have the node
250     */
251    private boolean _checkModelItemPresence(ContentComparatorResult result, Content content1, Content content2, String dataPath, boolean logChange)
252    {
253        boolean hasContent1Value = content1.hasValue(dataPath);
254        boolean hasContent2Value = content2.hasValue(dataPath);
255        
256        if (hasContent1Value && hasContent2Value)
257        {
258            return true;
259        }
260        else if (logChange && hasContent1Value && !hasContent2Value)
261        {
262            ModelItem definition = content1.getDefinition(dataPath);
263            result.addChange(new ContentComparatorChange(dataPath, StringUtils.EMPTY, definition, DataChangeType.REMOVED));
264        }
265        else if (logChange && !hasContent1Value && hasContent2Value)
266        {
267            ModelItem definition = content2.getDefinition(dataPath);
268            result.addChange(new ContentComparatorChange(dataPath, StringUtils.EMPTY, definition, DataChangeType.ADDED));
269        }
270        
271        return false;
272    }
273    
274    /**
275     * Compare 2 composites
276     * @param result global result where differences will be stored
277     * @param viewItemGroup view item of the current composite
278     * @param content1 1st content
279     * @param content2 2nd content
280     * @param definition definition of the composite
281     * @param compositeDataPath absolute data path of the current composite
282     * @throws AmetysRepositoryException repository exception
283     * @throws IOException IO Exception
284     */
285    private void _compareComposite(ContentComparatorResult result, ModelViewItemGroup viewItemGroup, Content content1, Content content2, ModelItem definition, String compositeDataPath) throws AmetysRepositoryException, IOException
286    {
287        for (ViewItem viewItem : viewItemGroup.getViewItems())
288        {
289            _compareModelItems(result, content1, content2, viewItem, compositeDataPath);
290        }
291    }
292    
293    /**
294     * Compare 2 repeaters
295     * @param result global result where differences will be stored
296     * @param viewItemGroup view item of the current repeater
297     * @param content1 1st content
298     * @param content2 2nd content
299     * @param definition definition of the repeater
300     * @param repeaterDataPath absolute data path of the current repeater
301     * @throws AmetysRepositoryException repository exception
302     * @throws IOException IO Exception
303     */
304    private void _compareRepeater(ContentComparatorResult result, ModelViewItemGroup viewItemGroup, Content content1, Content content2, ModelItem definition, String repeaterDataPath) throws AmetysRepositoryException, IOException
305    {
306        ModelAwareRepeater repeater1 = content1.getRepeater(repeaterDataPath);
307        ModelAwareRepeater repeater2 = content2.getRepeater(repeaterDataPath);
308
309        if (repeater1.getSize() > repeater2.getSize())
310        {
311            for (int i = repeater2.getSize() + 1; i <= repeater1.getSize(); i++)
312            {
313                result.addChange(new ContentComparatorChange(repeaterDataPath + "[" + Integer.toString(i) + "]", StringUtils.EMPTY, definition, DataChangeType.REMOVED));
314            }
315        }
316        else if (repeater1.getSize() < repeater2.getSize())
317        {
318            for (int i = repeater1.getSize() + 1; i <= repeater2.getSize(); i++)
319            {
320                result.addChange(new ContentComparatorChange(repeaterDataPath + "[" + Integer.toString(i) + "]", StringUtils.EMPTY, definition, DataChangeType.ADDED));
321            }
322        }
323        
324        // Compare common entries
325        int numberOfCommonEntries = Math.min(repeater1.getSize(), repeater2.getSize());
326        for (int i = 1; i <= numberOfCommonEntries; i++)
327        {
328            String repeaterEntryDataPath = repeaterDataPath + "[" + Integer.toString(i) + "]";
329            if (_checkModelItemPresence(result, content1, content2, repeaterEntryDataPath, true))
330            {
331                // Compare repeater's children for each entry
332                for (ViewItem viewItem : viewItemGroup.getViewItems())
333                {
334                    _compareModelItems(result, content1, content2, viewItem, repeaterEntryDataPath);
335                }
336            }
337        }
338    }
339    
340    /**
341     * Compare 2 elements
342     * @param result global result where differences will be stored
343     * @param content1 1st content
344     * @param content2 2nd content
345     * @param definition definition of the attribute
346     * @param elementPath absolute data path of the current attribute
347     * @throws AmetysRepositoryException repository exception
348     * @throws IOException IO Exception
349     */
350    private void _compareElement(ContentComparatorResult result, Content content1, Content content2, ElementDefinition<?> definition, String elementPath) throws AmetysRepositoryException, IOException
351    {
352        definition.getType()
353                  .compareValues(content1.getValue(elementPath), content2.getValue(elementPath))
354                  .map(change ->
355                  {
356                      String detailPath = change.getRight();
357                      return new ContentComparatorChange(elementPath, detailPath, definition, change.getLeft(), change.getMiddle());
358                  })
359                  .forEach(result::addChange);
360    }
361}