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.Collection;
021import java.util.List;
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.StringUtils;
028
029import org.ametys.cms.contenttype.ContentTypesHelper;
030import org.ametys.cms.repository.Content;
031import org.ametys.plugins.repository.AmetysRepositoryException;
032import org.ametys.plugins.repository.data.holder.group.impl.ModelAwareRepeater;
033import org.ametys.plugins.repository.data.type.ModelItemTypeConstants;
034import org.ametys.runtime.i18n.I18nizableText;
035import org.ametys.runtime.model.ElementDefinition;
036import org.ametys.runtime.model.Model;
037import org.ametys.runtime.model.ModelItem;
038import org.ametys.runtime.model.ModelViewItem;
039import org.ametys.runtime.model.ModelViewItemGroup;
040import org.ametys.runtime.model.SimpleViewItemGroup;
041import org.ametys.runtime.model.View;
042import org.ametys.runtime.model.ViewItem;
043import org.ametys.runtime.model.ViewItemContainer;
044import org.ametys.runtime.model.compare.DataChangeType;
045import org.ametys.runtime.plugin.component.AbstractLogEnabled;
046
047/**
048 * Object used to compare two contents
049 *
050 */
051public class ContentComparator extends AbstractLogEnabled implements Component, Serviceable
052{
053    /** The Avalon role */
054    public static final String ROLE = ContentComparator.class.getName();
055    
056    private ContentTypesHelper _contentTypesHelper;
057    
058    public void service(ServiceManager manager) throws ServiceException
059    {
060        this._contentTypesHelper = (ContentTypesHelper) manager.lookup(ContentTypesHelper.ROLE);
061    }
062    
063    /**
064     * Compare 2 contents, for all their common attributes (contents with different contentTypes are rejected)
065     * @param content1 1st content
066     * @param content2 new content
067     * @return {@link ContentComparatorResult}
068     * @throws AmetysRepositoryException repository exception
069     * @throws IOException IO exception
070     */
071    public ContentComparatorResult compare(Content content1, Content content2) throws AmetysRepositoryException, IOException
072    {
073        return compare(content1, content2, null);
074    }
075    
076    /**
077     * Compare 2 contents, for all their common attributes
078     * @param content1 1st content
079     * @param content2 new content
080     * @param strictCompare true to reject contents with different content types
081     * @return {@link ContentComparatorResult}
082     * @throws AmetysRepositoryException repository exception
083     * @throws IOException IO exception
084     */
085    public ContentComparatorResult compare(Content content1, Content content2, boolean strictCompare) throws AmetysRepositoryException, IOException
086    {
087        return compare(content1, content2, null, strictCompare);
088    }
089    
090    /**
091     * Compare 2 contents, filtering with a view (contents with different contentTypes are rejected)
092     * @param content1 1st content
093     * @param content2 new content
094     * @param viewName name of the view
095     * @return {@link ContentComparatorResult}
096     * @throws AmetysRepositoryException repository exception
097     * @throws IOException IO exception
098     */
099    public ContentComparatorResult compare(Content content1, Content content2, String viewName) throws AmetysRepositoryException, IOException
100    {
101        return compare(content1, content2, viewName, true);
102    }
103    
104    /**
105     * Compare 2 contents, filtering with a view
106     * @param content1 1st content
107     * @param content2 new content
108     * @param viewName name of the view
109     * @param strictCompare true to reject contents with different content types
110     * @return {@link ContentComparatorResult}
111     * @throws AmetysRepositoryException repository exception
112     * @throws IOException IO exception
113     */
114    public ContentComparatorResult compare(Content content1, Content content2, String viewName, boolean strictCompare) throws AmetysRepositoryException, IOException
115    {
116        return _compare(content1, content2, viewName, strictCompare);
117    }
118    
119    private ContentComparatorResult _compare(Content content1, Content content2, String viewName, boolean strictCompare) throws AmetysRepositoryException, IOException
120    {
121        //if strictCompare, we check if the types are strictly equals
122        List<String> types1 = Arrays.asList(content1.getTypes());
123        List<String> types2 = Arrays.asList(content2.getTypes());
124        if (strictCompare && (types1.size() != types2.size() || !types1.containsAll(types2) || !types2.containsAll(types1)))
125        {
126            ContentComparatorResult result = new ContentComparatorResult(content1, content2);
127            result.setNotEquals();
128            return result;
129        }
130        else
131        {
132            View view = _generateView(viewName, content1);
133            ContentComparatorResult result = new ContentComparatorResult(content1, content2, viewName, view);
134            if (view != null)
135            {
136                for (ViewItem viewItem : view.getViewItems())
137                {
138                    _compareAttributes(result, content1, content2, viewItem, StringUtils.EMPTY);
139                }
140            }
141            else
142            {
143                result.setNotEquals();
144            }
145            
146            return result;
147        }
148    }
149    
150    /**
151     * Generate a view using a name (can be null) and a content
152     * @param viewName name of the view. If null, a __generated__ view will be created using all content's attributes
153     * @param content content where the view should be retrieved
154     * @return {@link View} from the name, or generated
155     */
156    private View _generateView(String viewName, Content content)
157    {
158        if (viewName != null)
159        {
160            return _contentTypesHelper.getView(viewName, content.getTypes(), content.getMixinTypes());
161        }
162        else
163        {
164            @SuppressWarnings("unchecked")
165            View  view = View.of((Collection<Model>) content.getModel());
166            view.setName("__generated__");
167            view.setLabel(new I18nizableText("Live edition metadataset"));
168            view.setDescription(new I18nizableText("Live edition metadataset"));
169            view.setSmallIcon(null);
170            view.setMediumIcon(null);
171            view.setLargeIcon(null);
172            view.setIsInternal(true);
173            
174            return view;
175        }
176    }
177
178    /**
179     * Compare 2 attributes and add the differences in result
180     * @param result global result where differences will be stored
181     * @param content1 1st content
182     * @param content2 2nd content
183     * @param viewItem view item that will be compared
184     * @param parentDataPath absolute data path of the container of the current view item 
185     * @throws AmetysRepositoryException repository exception
186     * @throws IOException IO Exception (comparison of InputStream mainly)
187     */
188    private void _compareAttributes(ContentComparatorResult result, Content content1, Content content2, ViewItem viewItem, String parentDataPath) throws AmetysRepositoryException, IOException
189    {
190        // If the view item is a reference to a model item, compare the model items and their values
191        if (viewItem instanceof ModelViewItem)
192        {
193            ModelItem modelItemFromView = ((ModelViewItem) viewItem).getDefinition();
194            String modelItemPath = modelItemFromView.getPath();
195            
196            ModelItem modelItem1 = null;
197            if (content1.hasDefinition(modelItemPath))
198            {
199                modelItem1 = content1.getDefinition(modelItemPath);
200            }
201            
202            ModelItem modelItem2 = null;
203            if (content2.hasDefinition(modelItemPath))
204            {
205                modelItem2 = content2.getDefinition(modelItemPath);
206            }
207            
208            if (modelItem1 != null && modelItem2 != null && modelItem1.getModel().equals(modelItem2.getModel()))
209            {
210                String modelItemName = ((ModelViewItem) viewItem).getDefinition().getName();
211                String currentDataPath = _concatAttributeDataPathSegments(parentDataPath, modelItemName);
212                if (_checkAttributePresence(result, content1, content2, currentDataPath, true))
213                {
214                    if (ModelItemTypeConstants.COMPOSITE_TYPE_ID.equals(modelItemFromView.getType().getId())) 
215                    {
216                        _compareComposite(result, (ModelViewItemGroup) viewItem, content1, content2, modelItemFromView, currentDataPath);
217                    }
218                    else if (ModelItemTypeConstants.REPEATER_TYPE_ID.equals(modelItemFromView.getType().getId()))
219                    {
220                        _compareRepeater(result, (ModelViewItemGroup) viewItem, content1, content2, modelItemFromView, currentDataPath);
221                    }
222                    else
223                    {
224                        // If the model item is not a group, it has to be an ElementDefinition
225                        _compareAttribute(result, content1, content2, (ElementDefinition) modelItemFromView, currentDataPath);
226                    }
227                }
228            }
229            else
230            {
231                // TODO CMS-9978 Ignore or add the difference? What happen if an item is defined by a content but not by the other one?
232                // TODO CMS-9978 Shouldn't we compare the definitions? (cardinality, ...)?
233            }
234        }
235
236        // If the view item contains other view items, compare the children
237        if (viewItem instanceof SimpleViewItemGroup)
238        {
239            for (ViewItem child : ((ViewItemContainer) viewItem).getViewItems())
240            {
241                _compareAttributes(result, content1, content2, child, parentDataPath);
242            }
243        }
244    }
245    
246    /**
247     * Check if an attribute is present in both contents and add a change if requested
248     * @param result The result
249     * @param content1 1st content
250     * @param content2 2nd content
251     * @param attributeDataPath absolute path of the data to check (also used to log)
252     * @param logChange true to add a change in the list (if one has it and the other doesn't)
253     * @return true if both contents have the node
254     */
255    private boolean _checkAttributePresence(ContentComparatorResult result, Content content1, Content content2, String attributeDataPath, boolean logChange)
256    {
257        if (content1.hasValue(attributeDataPath) && content2.hasValue(attributeDataPath))
258        {
259            return true;
260        }
261        else if (logChange && content1.hasValue(attributeDataPath) && !content2.hasValue(attributeDataPath))
262        {
263            ModelItem definition = content1.getDefinition(attributeDataPath);
264            result.addChange(new ContentComparatorChange(attributeDataPath, StringUtils.EMPTY, definition, DataChangeType.REMOVED));
265        }
266        else if (logChange && !content1.hasValue(attributeDataPath) && content2.hasValue(attributeDataPath))
267        {
268            ModelItem definition = content2.getDefinition(attributeDataPath);
269            result.addChange(new ContentComparatorChange(attributeDataPath, StringUtils.EMPTY, definition, DataChangeType.ADDED));
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            _compareAttributes(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 (_checkAttributePresence(result, content1, content2, repeaterEntryDataPath, true))
330            {
331                // Compare repeater's children for each entry
332                for (ViewItem viewItem : viewItemGroup.getViewItems())
333                {
334                    _compareAttributes(result, content1, content2, viewItem, repeaterEntryDataPath);
335                }
336            }
337        }
338    }
339    
340    /**
341     * Compare 2 attributes
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 attributeDataPath absolute data path of the current attribute
347     * @throws AmetysRepositoryException repository exception
348     * @throws IOException IO Exception
349     */
350    private void _compareAttribute(ContentComparatorResult result, Content content1, Content content2, ElementDefinition<?> definition, String attributeDataPath) throws AmetysRepositoryException, IOException
351    {
352        definition.getType()
353                  .compareValues(content1.getValue(attributeDataPath), content2.getValue(attributeDataPath))
354                  .map(change ->
355                  {
356                      String detailPath = change.getRight();
357                      return new ContentComparatorChange(attributeDataPath, detailPath, definition, change.getLeft(), change.getMiddle());
358                  })
359                  .forEach(result::addChange);
360    }
361    
362    private String _concatAttributeDataPathSegments(String... segments)
363    {
364        StringBuilder result = new StringBuilder();
365        
366        for (String segment : segments)
367        {
368            if (StringUtils.isNotEmpty(result) && StringUtils.isNotEmpty(segment))
369            {
370                result.append(ModelItem.ITEM_PATH_SEPARATOR);
371            }
372            result.append(segment);
373        }
374        
375        return result.toString();
376    }
377}