001/*
002 *  Copyright 2022 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.impl;
017
018import java.util.ArrayList;
019import java.util.Collections;
020import java.util.HashMap;
021import java.util.LinkedHashMap;
022import java.util.List;
023import java.util.Map;
024
025import org.apache.avalon.framework.activity.Disposable;
026import org.apache.avalon.framework.component.Component;
027import org.apache.avalon.framework.service.ServiceException;
028import org.apache.avalon.framework.service.ServiceManager;
029import org.apache.avalon.framework.service.Serviceable;
030import org.apache.cocoon.xml.AttributesImpl;
031import org.apache.cocoon.xml.XMLUtils;
032import org.apache.solr.common.SolrInputDocument;
033import org.xml.sax.ContentHandler;
034import org.xml.sax.SAXException;
035
036import org.ametys.cms.data.ametysobject.ModelAwareDataAwareAmetysObject;
037import org.ametys.cms.data.holder.DataHolderDisableConditionsEvaluator;
038import org.ametys.cms.data.holder.IndexableDataHolder;
039import org.ametys.cms.data.holder.group.IndexableComposite;
040import org.ametys.cms.data.holder.group.IndexableRepeater;
041import org.ametys.cms.data.type.indexing.IndexableDataContext;
042import org.ametys.cms.data.type.indexing.IndexableElementType;
043import org.ametys.cms.model.properties.Property;
044import org.ametys.cms.search.model.SystemPropertyExtensionPoint;
045import org.ametys.core.util.JSONUtils;
046import org.ametys.plugins.repository.data.external.ExternalizableDataProvider.ExternalizableDataStatus;
047import org.ametys.plugins.repository.data.holder.DataHolder;
048import org.ametys.plugins.repository.data.holder.ModelAwareDataHolder;
049import org.ametys.plugins.repository.data.holder.group.ModelAwareComposite;
050import org.ametys.plugins.repository.data.holder.group.ModelAwareRepeater;
051import org.ametys.plugins.repository.data.holder.group.ModelAwareRepeaterEntry;
052import org.ametys.plugins.repository.data.holder.values.SynchronizableValue;
053import org.ametys.plugins.repository.data.type.RepositoryElementType;
054import org.ametys.plugins.repository.model.CompositeDefinition;
055import org.ametys.plugins.repository.model.RepeaterDefinition;
056import org.ametys.plugins.repository.model.RepositoryDataContext;
057import org.ametys.plugins.repository.model.ViewHelper;
058import org.ametys.runtime.model.ElementDefinition;
059import org.ametys.runtime.model.ModelItem;
060import org.ametys.runtime.model.ModelViewItem;
061import org.ametys.runtime.model.ViewItem;
062import org.ametys.runtime.model.ViewItemAccessor;
063import org.ametys.runtime.model.disableconditions.DisableConditionsEvaluator;
064import org.ametys.runtime.model.exception.BadItemTypeException;
065import org.ametys.runtime.model.exception.UndefinedItemPathException;
066import org.ametys.runtime.model.type.DataContext;
067import org.ametys.runtime.model.type.ElementType;
068import org.ametys.runtime.model.type.ModelItemType;
069
070/**
071 * Helper for implementations of indexable data holder
072 */
073public final class IndexableDataHolderHelper implements Component, Serviceable, Disposable
074{
075    private static JSONUtils _jsonUtils;
076    private static SystemPropertyExtensionPoint _contentSystemPropertyExtentionPoint;
077    private static DisableConditionsEvaluator<ModelAwareDataHolder> _disableConditionsEvaluator;
078    
079    @SuppressWarnings("unchecked")
080    @Override
081    public void service(ServiceManager manager) throws ServiceException
082    {
083        _jsonUtils = (JSONUtils) manager.lookup(JSONUtils.ROLE);
084        _contentSystemPropertyExtentionPoint = (SystemPropertyExtensionPoint) manager.lookup(SystemPropertyExtensionPoint.ROLE);
085        _disableConditionsEvaluator = (DisableConditionsEvaluator<ModelAwareDataHolder>) manager.lookup(DataHolderDisableConditionsEvaluator.ROLE);
086    }
087    
088    public void dispose()
089    {
090        _jsonUtils = null;
091        _contentSystemPropertyExtentionPoint = null;
092    }
093    
094    /**
095     * Indexes data of the given {@link IndexableDataHolder}
096     * @param dataHolder the {@link IndexableDataHolder} to index
097     * @param viewItemAccessor the view item accessor to explore
098     * @param document the solr document representing this {@link IndexableDataHolder}
099     * @param rootDocument the solr document of the root object.
100     * @param solrFieldPrefix the prefix of the solr field
101     * @param context The context of the data to index
102     * @return additional solr documents that may have been created (ex: repeater entries)
103     * @throws BadItemTypeException if the saxed value's type does not matches the stored data
104     */
105    public static List<SolrInputDocument> indexData(IndexableDataHolder dataHolder, ViewItemAccessor viewItemAccessor, SolrInputDocument document, SolrInputDocument rootDocument, String solrFieldPrefix, IndexableDataContext context) throws BadItemTypeException
106    {
107        ViewItemAccessor mergedViewItemAccessor = org.ametys.runtime.model.ViewHelper.mergeDuplicatedItems(viewItemAccessor);
108
109        List<SolrInputDocument> additionalDocuments = new ArrayList<>();
110        
111        for (ViewItem viewItem : mergedViewItemAccessor.getViewItems())
112        {
113            additionalDocuments.addAll(_indexDataFromViewItem(dataHolder, viewItem, document, rootDocument, solrFieldPrefix, context));
114        }
115        
116        return additionalDocuments;
117    }
118
119    @SuppressWarnings("unchecked")
120    private static List<SolrInputDocument> _indexDataFromViewItem(IndexableDataHolder dataHolder, ViewItem viewItem, SolrInputDocument document, SolrInputDocument rootDocument, String solrFieldPrefix, IndexableDataContext context)
121    {
122        if (viewItem instanceof ModelViewItem modelViewItem)
123        {
124            ModelItem modelItem = modelViewItem.getDefinition();
125            String dataName = modelItem.getName();
126            IndexableDataContext newContext = context.cloneContext()
127                    .addSegmentToDataPath(dataName)
128                    .withModelItem(modelItem)
129                    .withViewItem(viewItem);
130
131            if (modelItem instanceof Property property)
132            {
133                property.indexValue(document, getAmetysObjectFromContext(newContext), newContext);
134                return Collections.EMPTY_LIST;
135            }
136            else if (dataHolder.hasValue(dataName))
137            {
138                if (modelItem instanceof ElementDefinition definition)
139                {
140                    ElementType type = definition.getType();
141                    
142                    if (type instanceof IndexableElementType indexingType)
143                    {
144                        Object value = dataHolder.getValue(dataName);
145                        
146                        String solrFieldName = solrFieldPrefix + dataName;
147                        indexingType.indexValue(document, rootDocument, solrFieldName, value, newContext);
148                    }
149                    
150                    return Collections.EMPTY_LIST;
151                }
152                else if (modelItem instanceof CompositeDefinition definition)
153                {
154                    IndexableComposite composite = dataHolder.getValue(dataName);
155                    String newSolrFieldPrefix = solrFieldPrefix + dataName + ModelItem.ITEM_PATH_SEPARATOR;
156
157                    return indexData(composite, (ViewItemAccessor) viewItem, document, rootDocument, newSolrFieldPrefix, newContext);
158                }
159                else if (modelItem instanceof RepeaterDefinition definition)
160                {
161                    IndexableRepeater repeater = dataHolder.getValue(dataName);
162                    return repeater.indexData(document, rootDocument, solrFieldPrefix, newContext);
163                }
164            }
165        }
166        else if (viewItem instanceof ViewItemAccessor accessor)
167        {
168            return indexData(dataHolder, accessor, document, rootDocument, solrFieldPrefix, context);
169        }
170        return Collections.EMPTY_LIST;
171    }
172    
173    /**
174     * Generates SAX events for the data in the given view in the given {@link DataHolder}
175     * @param dataHolder the {@link ModelAwareDataHolder} to SAX
176     * @param contentHandler the {@link ContentHandler} that will receive the SAX events
177     * @param viewItemAccessor the {@link ViewItemAccessor} referencing the items for which generate SAX events
178     * @param context The context of the data to SAX
179     * @param isEdition <code>true</code> if SAX events are generated in edition mode, <code>false</code> otherwise
180     * @throws SAXException if an error occurs during the SAX events generation
181     * @throws BadItemTypeException if the saxed value's type does not matches the stored data
182     * @throws UndefinedItemPathException if an item in the view is not part of the model
183     */
184    public static void dataToSAX(ModelAwareDataHolder dataHolder, ContentHandler contentHandler, ViewItemAccessor viewItemAccessor, DataContext context, boolean isEdition) throws SAXException, BadItemTypeException, UndefinedItemPathException
185    {
186        ViewItemAccessor mergedViewItemAccessor = org.ametys.runtime.model.ViewHelper.mergeDuplicatedItems(viewItemAccessor);
187        
188        for (ViewItem viewItem : mergedViewItemAccessor.getViewItems())
189        {
190            if (viewItem instanceof ModelViewItem modelViewItem)
191            {
192                ModelItem modelItem = modelViewItem.getDefinition();
193                String dataName = modelItem.getName();
194                DataContext newContext = context.cloneContext()
195                                                .addSegmentToDataPath(dataName)
196                                                .withViewItem(viewItem)
197                                                .withModelItem(modelItem);
198                
199                if (renderValue(dataHolder, dataName, newContext, isEdition))
200                {
201                    if (modelItem instanceof Property property)
202                    {
203                        if (hasValue(dataHolder, dataName, newContext))
204                        {
205                            _propertyToSAX(property, contentHandler, newContext, isEdition);
206                        }
207                    }
208                    else
209                    {
210                        ModelItemType type = modelItem.getType();
211        
212                        if (isEdition)
213                        {
214                            if (newContext instanceof RepositoryDataContext repositoryDataContext && repositoryDataContext.isDataExternalizable())
215                            {
216                                _saxExternalizableValuesAsJson(dataHolder, contentHandler, dataName, newContext);
217                            }
218                            else if (hasValue(dataHolder, dataName, newContext))
219                            {
220                                Object value = dataHolder.getValue(dataName);
221                                type.valueToSAXForEdition(contentHandler, modelItem.getName(), value, newContext);
222                            }
223                        }
224                        else if (hasValue(dataHolder, dataName, newContext))
225                        {
226                            Object value = dataHolder.getValue(dataName);
227                            type.valueToSAX(contentHandler, modelItem.getName(), value, newContext);
228                        }
229                    }
230                }
231            }
232            else if (viewItem instanceof ViewItemAccessor accessor)
233            {
234                dataToSAX(dataHolder, contentHandler, accessor, context, isEdition);
235            }
236        }
237    }
238    
239    @SuppressWarnings("unchecked")
240    private static void _propertyToSAX(Property property, ContentHandler contentHandler, DataContext context, boolean isEdition) throws SAXException
241    {
242        ModelAwareDataAwareAmetysObject ametysObject = getAmetysObjectFromContext(context);
243        if (isEdition)
244        {
245            throw new SAXException("Unable to generate SAX events for property '" + context.getDataPath() + "', for object '" + ametysObject + "' in edition mode: properties are not modifiables.");
246        }
247        else
248        {
249            property.valueToSAX(contentHandler, ametysObject, context);
250        }
251    }
252    
253    private static void _saxExternalizableValuesAsJson (ModelAwareDataHolder dataHolder, ContentHandler contentHandler, String dataName, DataContext context) throws SAXException
254    {
255        Map<String, Object> values = externalizableValuesAsJson(dataHolder, dataName, context);
256        
257        if (!values.isEmpty())
258        {
259            String jsonString = _jsonUtils.convertObjectToJson(values);
260    
261            AttributesImpl attrs = new AttributesImpl();
262            attrs.addCDATAAttribute("json", "true");
263            XMLUtils.createElement(contentHandler, dataName, attrs, jsonString);
264        }
265    }
266    
267    /**
268     * Convert the data in the given view of the given {@link DataHolder}
269     * @param dataHolder the {@link ModelAwareDataHolder} containing the data to convert
270     * @param viewItemAccessor the {@link ViewItemAccessor} referencing the items to convert
271     * @param context The context of the data to convert
272     * @param isEdition <code>true</code> to convert in edition mode, <code>false</code> otherwise
273     * @return The data of the given view as JSON
274     * @throws BadItemTypeException if the value's type does not matches the stored data
275     * @throws UndefinedItemPathException if an item in the view is not part of the model
276     */
277    public static Map<String, Object> dataToJSON(ModelAwareDataHolder dataHolder, ViewItemAccessor viewItemAccessor, DataContext context, boolean isEdition) throws BadItemTypeException, UndefinedItemPathException
278    {
279        ViewItemAccessor mergedViewItemAccessor = org.ametys.runtime.model.ViewHelper.mergeDuplicatedItems(viewItemAccessor);
280        
281        Map<String, Object> result = new HashMap<>();
282        
283        for (ViewItem viewItem : mergedViewItemAccessor.getViewItems())
284        {
285            if (viewItem instanceof ModelViewItem modelViewItem)
286            {
287                ModelItem modelItem = modelViewItem.getDefinition();
288                String dataName = modelItem.getName();
289                DataContext newContext = context.cloneContext()
290                                                .addSegmentToDataPath(dataName)
291                                                .withViewItem(viewItem)
292                                                .withModelItem(modelItem);
293                
294                if (renderValue(dataHolder, dataName, newContext, isEdition))
295                {
296                    if (modelItem instanceof Property property)
297                    {
298                        if (hasValue(dataHolder, dataName, newContext))
299                        {
300                            Object json = _propertyToJSON(property, newContext, isEdition);
301                            result.put(dataName, json);
302                        }
303                    }
304                    else
305                    {
306                        ModelItemType type = modelItem.getType();
307        
308                        if (isEdition)
309                        {
310                            if (newContext instanceof RepositoryDataContext repositoryDataContext && repositoryDataContext.isDataExternalizable())
311                            {
312                                Map<String, Object> json = externalizableValuesAsJson(dataHolder, dataName, newContext);
313                                if (!json.isEmpty())
314                                {
315                                    result.put(dataName, json);
316                                }
317                            }
318                            else if (hasValue(dataHolder, dataName, newContext))
319                            {
320                                Object value = dataHolder.getValue(dataName);
321                                Object json = type.valueToJSONForEdition(value, newContext);
322                                result.put(dataName, json);
323                            }
324                        }
325                        else if (hasValue(dataHolder, dataName, newContext))
326                        {
327                            Object value = dataHolder.getValue(dataName);
328                            Object json = type.valueToJSONForClient(value, newContext);
329                            result.put(dataName, json);
330                        }
331                    }
332                }
333            }
334            else if (viewItem instanceof ViewItemAccessor accessor)
335            {
336                result.putAll(dataToJSON(dataHolder, accessor, context, isEdition));
337            }
338        }
339        
340        return result;
341    }
342    
343    @SuppressWarnings("unchecked")
344    private static Object _propertyToJSON(Property property, DataContext context, boolean isEdition) throws UndefinedItemPathException
345    {
346        ModelAwareDataAwareAmetysObject ametysObject = getAmetysObjectFromContext(context);
347        if (isEdition)
348        {
349            throw new UndefinedItemPathException("Unable to convert property '" + context.getDataPath() + "', for object '" + ametysObject + "' in edition mode: properties are not modifiables.");
350        }
351        else
352        {
353            return property.valueToJSON(ametysObject, context);
354        }
355    }
356    
357    /**
358     * Convert the externalizable data with the given name
359     * @param dataHolder the {@link ModelAwareDataHolder} containing the data to convert
360     * @param dataName the name of the data to convert
361     * @param context The context of the data to convert
362     * @return The data with the given name as JSON
363     */
364    public static Map<String, Object> externalizableValuesAsJson(ModelAwareDataHolder dataHolder, String dataName, DataContext context)
365    {
366        Map<String, Object> result = new LinkedHashMap<>();
367
368        RepositoryElementType type = dataHolder.getType(dataName);
369        if (dataHolder.hasLocalValue(dataName)
370            || context.renderEmptyValues() && dataHolder.hasLocalValueOrEmpty(dataName))
371        {
372            Object localValue = dataHolder.getLocalValue(dataName);
373            Object localValueAsJSON = type.externalizableValueToJSON(localValue, context);
374            result.put("local", localValueAsJSON);
375        }
376
377        if (dataHolder.hasExternalValue(dataName)
378            || context.renderEmptyValues() && dataHolder.hasExternalValueOrEmpty(dataName))
379        {
380            Object externalValue = dataHolder.getExternalValue(dataName);
381            Object externalValueAsJSON = type.externalizableValueToJSON(externalValue, context);
382            result.put("external", externalValueAsJSON);
383        }
384        
385        if (!result.isEmpty())
386        {
387            ExternalizableDataStatus status = dataHolder.getStatus(dataName);
388            result.put("status", status.name().toLowerCase());
389        }
390        
391        return result;
392    }
393    
394    /**
395     * Returns all data of the given DataHolder as a typed-values Map.
396     * @param dataHolder the DataHolder to export
397     * @param viewItemAccessor the {@link ViewItemAccessor} referencing the items to include in the resulting Map
398     * @param context The context of the data
399     * @return a Map containing all data.
400     */
401    @SuppressWarnings("unchecked")
402    public static Map<String, Object> dataToMap(ModelAwareDataHolder dataHolder, ViewItemAccessor viewItemAccessor, DataContext context)
403    {
404        ViewItemAccessor mergedViewItemAccessor = org.ametys.runtime.model.ViewHelper.mergeDuplicatedItems(viewItemAccessor);
405        
406        Map<String, Object> result = new HashMap<>();
407        
408        ViewHelper.visitView(mergedViewItemAccessor,
409            (element, definition) -> {
410                // simple element
411                String name = definition.getName();
412                DataContext newContext = context.cloneContext().addSegmentToDataPath(name);
413                
414                if (renderValue(dataHolder, name, newContext, false))
415                {
416                    if (definition instanceof Property property)
417                    {
418                        if (hasValue(dataHolder, name, newContext))
419                        {
420                            ModelAwareDataAwareAmetysObject ametysObject = getAmetysObjectFromContext(newContext);
421                            Object value = property.getValue(ametysObject);
422                            result.put(name, value);
423                        }
424                    }
425                    else
426                    {
427                        if (newContext instanceof RepositoryDataContext repositoryDataContext && repositoryDataContext.isDataExternalizable())
428                        {
429                            if (_hasExternalizableValue(dataHolder, name, newContext))
430                            {
431                                SynchronizableValue value = new SynchronizableValue(dataHolder.getLocalValue(name));
432                                value.setExternalValue(dataHolder.getExternalValue(name));
433                                value.setExternalizableStatus(dataHolder.getStatus(name));
434                                result.put(name, value);
435                            }
436                        }
437                        else if (hasValue(dataHolder, name, newContext))
438                        {
439                            Object value = dataHolder.getValue(name);
440                            result.put(name, value);
441                        }
442                    }
443                }
444            },
445            (group, definition) -> {
446                // composite
447                String name = definition.getName();
448                DataContext newContext = context.cloneContext().addSegmentToDataPath(name);
449                if (renderValue(dataHolder, name, newContext, false) && hasValue(dataHolder, name, newContext))
450                {
451                    ModelAwareComposite value = dataHolder.getValue(name);
452                    result.put(name, value == null ? null : value.dataToMap(group, newContext));
453                }
454            },
455            (group, definition) -> {
456                // repeater
457                String name = definition.getName();
458                DataContext repeaterContext = context.cloneContext().addSegmentToDataPath(name);
459                
460                if (renderValue(dataHolder, name, repeaterContext, false) && hasValue(dataHolder, name, context))
461                {
462                    ModelAwareRepeater repeater = dataHolder.getValue(name);
463                    List<Map<String, Object>> entries = null;
464                    if (repeater != null)
465                    {
466                        entries = new ArrayList<>();
467                        for (ModelAwareRepeaterEntry entry : repeater.getEntries())
468                        {
469                            DataContext entryContext = repeaterContext.cloneContext().addSuffixToLastSegment("[" + entry.getPosition() + "]");
470                            entries.add(entry.dataToMap(group, entryContext));
471                        }
472                    }
473                    result.put(name, entries);
474                }
475            },
476            group -> result.putAll(dataToMap(dataHolder, group, context)));
477        
478        return result;
479    }
480    
481    /**
482     * Retrieves the {@link ModelAwareDataAwareAmetysObject} from the given {@link DataContext}
483     * @param context the context containing the ametys object
484     * @return the ametys object, or <code>null</code> if there is no object id in the context
485     */
486    public static ModelAwareDataAwareAmetysObject getAmetysObjectFromContext(DataContext context)
487    {
488        RepositoryDataContext repoContext = context instanceof RepositoryDataContext rc ? rc : RepositoryDataContext.newInstance(context);
489        
490        return repoContext.getObject()
491                          .filter(ModelAwareDataAwareAmetysObject.class::isInstance)
492                          .map(ModelAwareDataAwareAmetysObject.class::cast)
493                          .orElse(null);
494    }
495    
496    /**
497     * Check if the value at the given path should be rendered
498     * @param dataHolder the data holder containing the data to check
499     * @param dataPath the path of the data to check
500     * @param context the data context
501     * @param isEdition <code>true</code> if values are rendered in edition mode, <code>false</code> otherwise
502     * @return <code>true</code> if the value has to be rendered, <code>false</code> otherwise
503     */
504    public static boolean renderValue(ModelAwareDataHolder dataHolder, String dataPath, DataContext context, boolean isEdition)
505    {
506        if (!isEdition && !context.renderDisabledValues())
507        {
508            ModelItem modelItem = dataHolder.getDefinition(dataPath);
509            String absoluteDataPath = context.getDataPath();
510            return !_disableConditionsEvaluator.evaluateDisableConditions(modelItem, absoluteDataPath, dataHolder.getRootDataHolder());
511        }
512        else
513        {
514            return true;
515        }
516    }
517    
518    /**
519     * Check if there is a value to render at the given path
520     * @param dataHolder the data holder containing the data to check
521     * @param dataPath the path of the data to check
522     * @param context the data context
523     * @return <code>true</code> if there is a value to render, <code>false</code> otherwise
524     */
525    public static boolean hasValue(ModelAwareDataHolder dataHolder, String dataPath, DataContext context)
526    {
527        return dataHolder.hasValue(dataPath)
528               || context.renderEmptyValues() && dataHolder.hasValueOrEmpty(dataPath);
529    }
530    
531    private static boolean _hasExternalizableValue(ModelAwareDataHolder dataHolder, String dataName, DataContext context)
532    {
533        return dataHolder.hasLocalValue(dataName)
534                || dataHolder.hasExternalValue(dataName)
535                || context.renderEmptyValues()
536                    && (dataHolder.hasLocalValueOrEmpty(dataName) || dataHolder.hasExternalValueOrEmpty(dataName));
537    }
538    
539    /**
540     * Retrieves the {@link SystemPropertyExtensionPoint} for content system properties
541     * @return the {@link SystemPropertyExtensionPoint} for content system properties
542     */
543    public static SystemPropertyExtensionPoint getContentSystemPropertyExtensionPoint()
544    {
545        return _contentSystemPropertyExtentionPoint;
546    }
547}