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