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