001/*
002 *  Copyright 2018 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.data.holder.group.impl;
017
018import java.io.IOException;
019import java.util.ArrayList;
020import java.util.Collections;
021import java.util.Comparator;
022import java.util.HashMap;
023import java.util.List;
024import java.util.Map;
025import java.util.Optional;
026import java.util.SortedSet;
027import java.util.TreeSet;
028
029import org.apache.cocoon.xml.AttributesImpl;
030import org.apache.cocoon.xml.XMLUtils;
031import org.apache.commons.lang3.StringUtils;
032import org.apache.solr.common.SolrInputDocument;
033import org.slf4j.Logger;
034import org.slf4j.LoggerFactory;
035import org.xml.sax.Attributes;
036import org.xml.sax.ContentHandler;
037import org.xml.sax.SAXException;
038
039import org.ametys.cms.content.indexing.solr.SolrFieldNames;
040import org.ametys.cms.data.holder.IndexableDataHolder;
041import org.ametys.cms.data.holder.group.IndexableRepeater;
042import org.ametys.cms.data.holder.group.IndexableRepeaterEntry;
043import org.ametys.cms.data.holder.impl.IndexableDataHolderHelper;
044import org.ametys.cms.data.type.indexing.IndexableDataContext;
045import org.ametys.plugins.repository.data.holder.DataHolder;
046import org.ametys.plugins.repository.data.holder.ModelAwareDataHolder;
047import org.ametys.plugins.repository.data.holder.group.ModelAwareRepeaterEntry;
048import org.ametys.plugins.repository.data.holder.group.ModifiableRepeater;
049import org.ametys.plugins.repository.data.holder.group.ModifiableRepeaterEntry;
050import org.ametys.plugins.repository.data.holder.group.Repeater;
051import org.ametys.plugins.repository.data.holder.group.RepeaterEntry;
052import org.ametys.plugins.repository.data.holder.values.SynchronizableRepeater;
053import org.ametys.plugins.repository.data.holder.values.SynchronizationContext;
054import org.ametys.plugins.repository.data.repositorydata.RepositoryData;
055import org.ametys.plugins.repository.model.RepeaterDefinition;
056import org.ametys.runtime.model.ModelViewItem;
057import org.ametys.runtime.model.ModelViewItemGroup;
058import org.ametys.runtime.model.ViewHelper;
059import org.ametys.runtime.model.ViewItemAccessor;
060import org.ametys.runtime.model.ViewItemContainer;
061import org.ametys.runtime.model.exception.BadItemTypeException;
062import org.ametys.runtime.model.exception.NotUniqueTypeException;
063import org.ametys.runtime.model.exception.UndefinedItemPathException;
064import org.ametys.runtime.model.exception.UnknownTypeException;
065import org.ametys.runtime.model.type.DataContext;
066
067/**
068 * Class for model aware repeaters
069 */
070public class DefaultModelAwareRepeater implements IndexableRepeater
071{
072    private static final Logger __LOGGER = LoggerFactory.getLogger(ModelAwareDataHolder.class);
073    
074    /** Definition of this repeater */
075    protected RepeaterDefinition _definition;
076    
077    /** Parent of the current {@link Repeater} */
078    protected IndexableDataHolder _parent;
079    
080    /** Root {@link DataHolder} */
081    protected IndexableDataHolder _root;
082    
083    /** Repository data to use to store entries in the repository */
084    protected RepositoryData _repositoryData;
085    
086    /**
087     * Creates a model aware repeater
088     * @param repositoryData the repository data of the repeater
089     * @param parent the parent of the created {@link Repeater}
090     * @param root the root {@link DataHolder}
091     * @param definition the definition of the repeater
092     */
093    public DefaultModelAwareRepeater(RepositoryData repositoryData, IndexableDataHolder parent, IndexableDataHolder root, RepeaterDefinition definition)
094    {
095        _repositoryData = repositoryData;
096        _definition = definition;
097        _parent = parent;
098        _root = root;
099    }
100    
101    @Override
102    public List<? extends IndexableRepeaterEntry> getEntries()
103    {
104        SortedSet<IndexableRepeaterEntry> entries = new TreeSet<>(new Comparator<ModelAwareRepeaterEntry>()
105        {
106            public int compare(ModelAwareRepeaterEntry entry1, ModelAwareRepeaterEntry entry2)
107            {
108                return Integer.compare(entry1.getPosition(), entry2.getPosition());
109            }
110        });
111        
112        for (String entryName : _repositoryData.getDataNames())
113        {
114            IndexableRepeaterEntry entry = getEntry(Integer.parseInt(entryName));
115            entries.add(entry);
116        }
117        
118        return Collections.unmodifiableList(new ArrayList<>(entries));
119    }
120    
121    public IndexableRepeaterEntry getEntry(int position)
122    {
123        if (1 <= position && position <= getSize())
124        {
125            RepositoryData entryRepositoryData = _repositoryData.getRepositoryData(String.valueOf(position));
126            return new DefaultModelAwareRepeaterEntry(entryRepositoryData, this, _definition);
127        }
128        else if (-getSize() < position && position <= 0)
129        {
130            // Find the positive equivalent position and call the getEntry method with this position
131            return getEntry(getSize() + position);
132        }
133        else
134        {
135            return null;
136        }
137    }
138
139    public int getSize()
140    {
141        return _repositoryData.getDataNames().size();
142    }
143    
144    public boolean hasEntry(int position)
145    {
146        if (1 <= position)
147        {
148            return _repositoryData.hasValue(String.valueOf(position));
149        }
150        else
151        {
152            return _repositoryData.hasValue(String.valueOf(getSize() + position));
153        }
154    }
155    
156    /**
157     * Retrieves the repeater's model
158     * @return the repeater's model
159     */
160    public RepeaterDefinition getModel()
161    {
162        return _definition;
163    }
164    
165    public void dataToSAX(ContentHandler contentHandler, String dataPath, DataContext context) throws SAXException
166    {
167        for (ModelAwareRepeaterEntry entry : getEntries())
168        {
169            XMLUtils.startElement(contentHandler, "entry", _getEntryAttributes(entry));
170            entry.dataToSAX(contentHandler, dataPath, context);
171            XMLUtils.endElement(contentHandler, "entry");
172        }
173    }
174    
175    public void dataToSAX(ContentHandler contentHandler, DataContext context) throws SAXException, BadItemTypeException
176    {
177        ModelViewItemGroup viewItemGroup = ModelViewItemGroup.of(_definition);
178        dataToSAX(contentHandler, viewItemGroup, context);
179    }
180    
181    public void dataToSAX(ContentHandler contentHandler, ViewItemAccessor viewItemAccessor, DataContext context) throws SAXException, BadItemTypeException
182    {
183        for (ModelAwareRepeaterEntry entry : getEntries())
184        {
185            XMLUtils.startElement(contentHandler, "entry", _getEntryAttributes(entry));
186            entry.dataToSAX(contentHandler, viewItemAccessor, context);
187            XMLUtils.endElement(contentHandler, "entry");
188        }
189    }
190    
191    private Attributes _getEntryAttributes(ModelAwareRepeaterEntry entry)
192    {
193        AttributesImpl entryAttrs = new AttributesImpl();
194        String entryName = Integer.toString(entry.getPosition());
195        entryAttrs.addCDATAAttribute("name", entryName);
196        return entryAttrs;
197    }
198    
199    public Map<String, Object> dataToJSON(String dataPath, DataContext context) throws IOException
200    {
201        return _dataToJSON(Optional.of(dataPath), Optional.empty(), context);
202    }
203    
204    public Map<String, Object> dataToJSON(DataContext context) throws BadItemTypeException
205    {
206        ModelViewItemGroup viewItemGroup = ModelViewItemGroup.of(_definition);
207        return dataToJSON(viewItemGroup, context);
208    }
209    
210    public Map<String, Object> dataToJSON(ViewItemAccessor viewItemAccessor, DataContext context) throws BadItemTypeException
211    {
212        return _dataToJSON(Optional.empty(), Optional.of(viewItemAccessor), context);
213    }
214    
215    @SuppressWarnings("unchecked")
216    private Map<String, Object> _dataToJSON(Optional<String> dataPath, Optional<ViewItemAccessor> viewItemAccessor, DataContext context) throws BadItemTypeException
217    {
218        List<Map<String, Object>> entriesValues = new ArrayList<>();
219        for (ModelAwareRepeaterEntry entry : getEntries())
220        {
221            DataContext entryContext = context.cloneContext();
222            if (StringUtils.isNotEmpty(context.getDataPath()))
223            {
224                entryContext.addSuffixToLastSegment("[" + entry.getPosition() + "]");
225            }
226            
227            Map<String, Object> entryValues = null;
228            if (dataPath.isPresent())
229            {
230                entryValues = (Map<String, Object>) entry.dataToJSON(dataPath.get(), entryContext);
231            }
232            else if (viewItemAccessor.isPresent())
233            {
234                entryValues = entry.dataToJSON(viewItemAccessor.get(), entryContext);
235            }
236            
237            entriesValues.add(entryValues);
238        }
239        
240        Map<String, Object> result = new HashMap<>();
241        result.put("entryCount", getSize());
242        result.put("entries", entriesValues);
243        return result;
244    }
245    
246    /**
247     * Generates SAX events for the comments of the data in the given view in the current {@link DataHolder}
248     * @param contentHandler the {@link ContentHandler} that will receive the SAX events
249     * @param viewItemAccessor the {@link ViewItemAccessor} referencing the items for which generate SAX events
250     * @throws SAXException if an error occurs during the SAX events generation
251     */
252    public void commentsToSAX(ContentHandler contentHandler, ViewItemAccessor viewItemAccessor) throws SAXException
253    {
254        for (ModelAwareRepeaterEntry entry : getEntries())
255        {
256            entry.commentsToSAX(contentHandler, viewItemAccessor);
257        }
258    }
259    
260    public void copyTo(ModifiableRepeater repeater) throws UndefinedItemPathException, BadItemTypeException, UnknownTypeException, NotUniqueTypeException
261    {
262        for (RepeaterEntry entry : getEntries())
263        {
264            ModifiableRepeaterEntry entryDestination = repeater.addEntry(entry.getPosition());
265            entry.copyTo(entryDestination);
266        }
267    }
268    
269    public List<SolrInputDocument> indexData(SolrInputDocument document, SolrInputDocument rootDocument, String solrFieldPrefix, IndexableDataContext context) throws BadItemTypeException
270    {
271        List<SolrInputDocument> additionalDocuments = new ArrayList<>();
272        String solrFieldName = solrFieldPrefix + context.getDataPathLastSegment();
273        
274        for (IndexableRepeaterEntry entry : getEntries())
275        {
276            // Update the context with entry position
277            IndexableDataContext newContext = context.cloneContext()
278                                            .addSuffixToLastSegment("[" + entry.getPosition() + "]");
279            
280            SolrInputDocument repeaterEntryDoc = new SolrInputDocument();
281            
282            if (!context.indexForFullTextField())
283            {
284                // Creates a new Solr document for each entry
285                String repeaterEntryDocId = document.getField("id").getFirstValue().toString() + "/" + solrFieldName + "/" + entry.getPosition();
286                repeaterEntryDoc.addField("id", repeaterEntryDocId);
287                repeaterEntryDoc.addField(SolrFieldNames.DOCUMENT_TYPE, SolrFieldNames.TYPE_REPEATER);
288                repeaterEntryDoc.addField(SolrFieldNames.REPEATER_ENTRY_POSITION, entry.getPosition());
289                
290                document.addField(solrFieldName + "_s_dv", repeaterEntryDocId);
291            }
292            
293            // Add the created document to additional documents
294            additionalDocuments.add(repeaterEntryDoc);
295            
296            ViewItemAccessor viewItemAccessor = context.getViewItem()
297                .map(ViewItemAccessor.class::cast)
298                .orElse(ViewHelper.createViewItemAccessor(entry.getModel()));
299            additionalDocuments.addAll(IndexableDataHolderHelper.indexData(entry, viewItemAccessor, repeaterEntryDoc, rootDocument, StringUtils.EMPTY, newContext));
300        }
301        
302        return additionalDocuments;
303    }
304    
305    public boolean hasDifferences(ViewItemContainer viewItemContainer, SynchronizableRepeater repeaterValues, SynchronizationContext context) throws UndefinedItemPathException, BadItemTypeException
306    {
307        return SynchronizableRepeater.Mode.APPEND.equals(repeaterValues.getMode())
308                ? _hasDifferencesInAppendMode(viewItemContainer, repeaterValues, context)
309                : SynchronizableRepeater.Mode.REPLACE.equals(repeaterValues.getMode())
310                        ? _hasDifferencesInReplaceMode(viewItemContainer, repeaterValues, context)
311                        : _hasDifferencesInReplaceAllMode(viewItemContainer, repeaterValues, context);
312    }
313    
314    /**
315     * Check if there are differences between the given values and the repeater's entries if {@link SynchronizableRepeater#getMode()} is APPEND
316     * @param viewItemContainer The {@link ViewItemContainer} containing all items to check
317     * @param repeaterValues the values of the repeater to check
318     * @param context the context of the synchronization
319     * @return <code>true</code> if there are differences, <code>false</code> otherwise
320     * @throws UndefinedItemPathException if an entry's key refers to a data that is not defined by the model
321     * @throws BadItemTypeException if the type defined by the model of one of the entry's key doesn't match the corresponding value
322     */
323    protected boolean _hasDifferencesInAppendMode(ViewItemContainer viewItemContainer, SynchronizableRepeater repeaterValues, SynchronizationContext context) throws UndefinedItemPathException, BadItemTypeException
324    {
325        boolean hasEntriesToAppend = !repeaterValues.getRemovedEntries().isEmpty() || !repeaterValues.getEntries().isEmpty();
326        
327        if (__LOGGER.isDebugEnabled())
328        {
329            String viewItemPath = viewItemContainer instanceof ModelViewItem modelViewItem 
330                                    ? ViewHelper.getModelViewItemPath(modelViewItem)
331                                    : StringUtils.EMPTY;
332            if (hasEntriesToAppend)
333            {
334                __LOGGER.debug("#hasDifferences[{}] differences detected: some entries will be appended", viewItemPath);
335            }
336            else
337            {
338                __LOGGER.debug("#hasDifferences[{}] no difference detected.", viewItemPath);
339            }
340        }
341        
342        return hasEntriesToAppend;
343    }
344    
345    /**
346     * Check if there are differences between the given values and the repeater's entries if {@link SynchronizableRepeater#getMode()} is REPLACE
347     * @param viewItemContainer The {@link ViewItemContainer} containing all items to check
348     * @param repeaterValues the values of the repeater to check
349     * @param context the context of the synchronization
350     * @return <code>true</code> if there are differences, <code>false</code> otherwise
351     * @throws UndefinedItemPathException if an entry's key refers to a data that is not defined by the model
352     * @throws BadItemTypeException if the type defined by the model of one of the entry's key doesn't match the corresponding value
353     */
354    protected boolean _hasDifferencesInReplaceMode(ViewItemContainer viewItemContainer, SynchronizableRepeater repeaterValues, SynchronizationContext context) throws UndefinedItemPathException, BadItemTypeException
355    {
356        List<Integer> positions = repeaterValues.getReplacePositions();
357        List<Map<String, Object>> entriesValues = repeaterValues.getEntries();
358        
359        for (int i = 0; i < positions.size(); i++)
360        {
361            int position = positions.get(i);
362            ModelAwareRepeaterEntry repeaterEntry = getEntry(position);
363            if (repeaterEntry.hasDifferences(viewItemContainer, entriesValues.get(i), context))
364            {
365                return true;
366            }
367        }
368        
369        // No differences has been found in entries
370        if (__LOGGER.isDebugEnabled())
371        {
372            String viewItemPath = viewItemContainer instanceof ModelViewItem modelViewItem 
373                                        ? ViewHelper.getModelViewItemPath(modelViewItem)
374                                        : StringUtils.EMPTY;
375            __LOGGER.debug("#hasDifferences[{}] no difference detected.", viewItemPath);
376        }
377        return false;
378    }
379    
380    /**
381     * Check if there are differences between the given values and the repeater's entries if {@link SynchronizableRepeater#getMode()} is REPLACE_ALL
382     * @param viewItemContainer The {@link ViewItemContainer} containing all items to check
383     * @param repeaterValues the values of the repeater to check
384     * @param context the context of the synchronization
385     * @return <code>true</code> if there are differences, <code>false</code> otherwise
386     * @throws UndefinedItemPathException if an entry's key refers to a data that is not defined by the model
387     * @throws BadItemTypeException if the type defined by the model of one of the entry's key doesn't match the corresponding value
388     */
389    protected boolean _hasDifferencesInReplaceAllMode(ViewItemContainer viewItemContainer, SynchronizableRepeater repeaterValues, SynchronizationContext context) throws UndefinedItemPathException, BadItemTypeException
390    {
391        List<Map<String, Object>> entriesValues = repeaterValues.getEntries();
392        if (hasToMoveEntries(repeaterValues.getPositionsMapping(), entriesValues.size()))
393        {
394            if (__LOGGER.isDebugEnabled())
395            {
396                String viewItemPath = viewItemContainer instanceof ModelViewItem modelViewItem 
397                                            ? ViewHelper.getModelViewItemPath(modelViewItem)
398                                            : StringUtils.EMPTY;
399                __LOGGER.debug("#hasDifferences[{}] differences detected: some entries will be moved", viewItemPath);
400            }
401            return true;
402        }
403        
404        for (ModelAwareRepeaterEntry repeaterEntry : getEntries())
405        {
406            int entryIndex = repeaterEntry.getPosition() - 1;
407            Map<String, Object> entryValues = entriesValues.get(entryIndex);
408            if (repeaterEntry.hasDifferences(viewItemContainer, entryValues, context))
409            {
410                return true;
411            }
412        }
413        
414        // No differences has been found in entries
415        if (__LOGGER.isDebugEnabled())
416        {
417            String viewItemPath = viewItemContainer instanceof ModelViewItem modelViewItem 
418                                        ? ViewHelper.getModelViewItemPath(modelViewItem)
419                                        : StringUtils.EMPTY;
420            __LOGGER.debug("#hasDifferences[{}] no difference detected.", viewItemPath);
421        }
422        return false;
423    }
424    
425    public boolean hasToMoveEntries(Map<Integer, Integer> positionsMapping, int targetSize)
426    {
427        int initialSize = getSize();
428
429        if (targetSize != initialSize)
430        {
431            return true;
432        }
433        
434        for (Map.Entry<Integer, Integer> mapping : positionsMapping.entrySet())
435        {
436            if (!mapping.getKey().equals(mapping.getValue()))
437            {
438                return true;
439            }
440        }
441        
442        return false;
443    }
444    
445    public RepositoryData getRepositoryData()
446    {
447        return _repositoryData;
448    }
449    
450    public IndexableDataHolder getParentDataHolder()
451    {
452        return _parent;
453    }
454    
455    public IndexableDataHolder getRootDataHolder()
456    {
457        return _root;
458    }
459}