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