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