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                        _propertyToSAX(property, contentHandler, newContext, isEdition);
204                    }
205                    else
206                    {
207                        ModelItemType type = modelItem.getType();
208        
209                        if (isEdition)
210                        {
211                            if (newContext instanceof RepositoryDataContext repositoryDataContext && repositoryDataContext.isDataExternalizable())
212                            {
213                                _saxExternalizableValuesAsJson(dataHolder, contentHandler, dataName, newContext);
214                            }
215                            else if (hasValue(dataHolder, dataName, newContext))
216                            {
217                                Object value = dataHolder.getValue(dataName);
218                                type.valueToSAXForEdition(contentHandler, modelItem.getName(), value, newContext);
219                            }
220                        }
221                        else if (hasValue(dataHolder, dataName, newContext))
222                        {
223                            Object value = dataHolder.getValue(dataName);
224                            type.valueToSAX(contentHandler, modelItem.getName(), value, newContext);
225                        }
226                    }
227                }
228            }
229            else if (viewItem instanceof ViewItemAccessor accessor)
230            {
231                dataToSAX(dataHolder, contentHandler, accessor, context, isEdition);
232            }
233        }
234    }
235    
236    @SuppressWarnings("unchecked")
237    private static void _propertyToSAX(Property property, ContentHandler contentHandler, DataContext context, boolean isEdition) throws SAXException
238    {
239        ModelAwareDataAwareAmetysObject ametysObject = getAmetysObjectFromContext(context);
240        if (isEdition)
241        {
242            throw new SAXException("Unable to generate SAX events for property '" + context.getDataPath() + "', for object '" + ametysObject + "' in edition mode: properties are not modifiables.");
243        }
244        else
245        {
246            property.valueToSAX(contentHandler, ametysObject, context);
247        }
248    }
249    
250    private static void _saxExternalizableValuesAsJson (ModelAwareDataHolder dataHolder, ContentHandler contentHandler, String dataName, DataContext context) throws SAXException
251    {
252        Map<String, Object> values = externalizableValuesAsJson(dataHolder, dataName, context);
253        
254        if (!values.isEmpty())
255        {
256            String jsonString = _jsonUtils.convertObjectToJson(values);
257    
258            AttributesImpl attrs = new AttributesImpl();
259            attrs.addCDATAAttribute("json", "true");
260            XMLUtils.createElement(contentHandler, dataName, attrs, jsonString);
261        }
262    }
263    
264    /**
265     * Convert the data in the given view of the given {@link DataHolder}
266     * @param dataHolder the {@link ModelAwareDataHolder} containing the data to convert
267     * @param viewItemAccessor the {@link ViewItemAccessor} referencing the items to convert
268     * @param context The context of the data to convert
269     * @param isEdition <code>true</code> to convert in edition mode, <code>false</code> otherwise
270     * @return The data of the given view as JSON
271     * @throws BadItemTypeException if the value's type does not matches the stored data
272     * @throws UndefinedItemPathException if an item in the view is not part of the model
273     */
274    public static Map<String, Object> dataToJSON(ModelAwareDataHolder dataHolder, ViewItemAccessor viewItemAccessor, DataContext context, boolean isEdition) throws BadItemTypeException, UndefinedItemPathException
275    {
276        ViewItemAccessor mergedViewItemAccessor = org.ametys.runtime.model.ViewHelper.mergeDuplicatedItems(viewItemAccessor);
277        
278        Map<String, Object> result = new HashMap<>();
279        
280        for (ViewItem viewItem : mergedViewItemAccessor.getViewItems())
281        {
282            if (viewItem instanceof ModelViewItem modelViewItem)
283            {
284                ModelItem modelItem = modelViewItem.getDefinition();
285                String dataName = modelItem.getName();
286                DataContext newContext = context.cloneContext()
287                                                .addSegmentToDataPath(dataName)
288                                                .withViewItem(viewItem)
289                                                .withModelItem(modelItem);
290                
291                if (renderValue(dataHolder, dataName, newContext, isEdition))
292                {
293                    if (modelItem instanceof Property property)
294                    {
295                        Object json = _propertyToJSON(property, newContext, isEdition);
296                        result.put(dataName, json);
297                    }
298                    else
299                    {
300                        ModelItemType type = modelItem.getType();
301        
302                        if (isEdition)
303                        {
304                            if (newContext instanceof RepositoryDataContext repositoryDataContext && repositoryDataContext.isDataExternalizable())
305                            {
306                                Map<String, Object> json = externalizableValuesAsJson(dataHolder, dataName, newContext);
307                                if (!json.isEmpty())
308                                {
309                                    result.put(dataName, json);
310                                }
311                            }
312                            else if (hasValue(dataHolder, dataName, newContext))
313                            {
314                                Object value = dataHolder.getValue(dataName);
315                                Object json = type.valueToJSONForEdition(value, newContext);
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.valueToJSONForClient(value, newContext);
323                            result.put(dataName, json);
324                        }
325                    }
326                }
327            }
328            else if (viewItem instanceof ViewItemAccessor accessor)
329            {
330                result.putAll(dataToJSON(dataHolder, accessor, context, isEdition));
331            }
332        }
333        
334        return result;
335    }
336    
337    @SuppressWarnings("unchecked")
338    private static Object _propertyToJSON(Property property, DataContext context, boolean isEdition) throws UndefinedItemPathException
339    {
340        ModelAwareDataAwareAmetysObject ametysObject = getAmetysObjectFromContext(context);
341        if (isEdition)
342        {
343            throw new UndefinedItemPathException("Unable to convert property '" + context.getDataPath() + "', for object '" + ametysObject + "' in edition mode: properties are not modifiables.");
344        }
345        else
346        {
347            return property.valueToJSON(ametysObject, context);
348        }
349    }
350    
351    /**
352     * Convert the externalizable data with the given name
353     * @param dataHolder the {@link ModelAwareDataHolder} containing the data to convert
354     * @param dataName the name of the data to convert
355     * @param context The context of the data to convert
356     * @return The data with the given name as JSON
357     */
358    public static Map<String, Object> externalizableValuesAsJson(ModelAwareDataHolder dataHolder, String dataName, DataContext context)
359    {
360        Map<String, Object> result = new LinkedHashMap<>();
361
362        RepositoryElementType type = dataHolder.getType(dataName);
363        if (dataHolder.hasLocalValue(dataName)
364            || context.renderEmptyValues() && dataHolder.hasLocalValueOrEmpty(dataName))
365        {
366            Object localValue = dataHolder.getLocalValue(dataName);
367            Object localValueAsJSON = type.externalizableValueToJSON(localValue, context);
368            result.put("local", localValueAsJSON);
369        }
370
371        if (dataHolder.hasExternalValue(dataName)
372            || context.renderEmptyValues() && dataHolder.hasExternalValueOrEmpty(dataName))
373        {
374            Object externalValue = dataHolder.getExternalValue(dataName);
375            Object externalValueAsJSON = type.externalizableValueToJSON(externalValue, context);
376            result.put("external", externalValueAsJSON);
377        }
378        
379        if (!result.isEmpty())
380        {
381            ExternalizableDataStatus status = dataHolder.getStatus(dataName);
382            result.put("status", status.name().toLowerCase());
383        }
384        
385        return result;
386    }
387    
388    /**
389     * Returns all data of the given DataHolder as a typed-values Map.
390     * @param dataHolder the DataHolder to export
391     * @param viewItemAccessor the {@link ViewItemAccessor} referencing the items to include in the resulting Map
392     * @param context The context of the data
393     * @return a Map containing all data.
394     */
395    @SuppressWarnings("unchecked")
396    public static Map<String, Object> dataToMap(ModelAwareDataHolder dataHolder, ViewItemAccessor viewItemAccessor, DataContext context)
397    {
398        ViewItemAccessor mergedViewItemAccessor = org.ametys.runtime.model.ViewHelper.mergeDuplicatedItems(viewItemAccessor);
399        
400        Map<String, Object> result = new HashMap<>();
401        
402        ViewHelper.visitView(mergedViewItemAccessor, 
403            (element, definition) -> {
404                // simple element
405                String name = definition.getName();
406                DataContext newContext = context.cloneContext().addSegmentToDataPath(name);
407                
408                if (renderValue(dataHolder, name, newContext, false))
409                {
410                    if (definition instanceof Property property)
411                    {
412                        ModelAwareDataAwareAmetysObject ametysObject = getAmetysObjectFromContext(newContext);
413                        Object value = property.getValue(ametysObject);
414                        if (value != null) // there is no empty values for properties, so a null value means an inexistent data
415                        {
416                            result.put(name, value);
417                        }
418                    }
419                    else
420                    {
421                        if (newContext instanceof RepositoryDataContext repositoryDataContext && repositoryDataContext.isDataExternalizable())
422                        {
423                            if (_hasExternalizableValue(dataHolder, name, newContext))
424                            {
425                                SynchronizableValue value = new SynchronizableValue(dataHolder.getLocalValue(name));
426                                value.setExternalValue(dataHolder.getExternalValue(name));
427                                value.setExternalizableStatus(dataHolder.getStatus(name));
428                                result.put(name, value);
429                            }
430                        }
431                        else if (hasValue(dataHolder, name, newContext))
432                        {
433                            Object value = dataHolder.getValue(name);
434                            result.put(name, value);
435                        }
436                    }
437                }
438            }, 
439            (group, definition) -> {
440                // composite
441                String name = definition.getName();
442                DataContext newContext = context.cloneContext().addSegmentToDataPath(name);
443                if (renderValue(dataHolder, name, newContext, false) && hasValue(dataHolder, name, newContext))
444                {
445                    ModelAwareComposite value = dataHolder.getValue(name);
446                    result.put(name, value == null ? null : value.dataToMap(group, newContext));
447                }
448            }, 
449            (group, definition) -> {
450                // repeater
451                String name = definition.getName();
452                DataContext repeaterContext = context.cloneContext().addSegmentToDataPath(name);
453                
454                if (renderValue(dataHolder, name, repeaterContext, false) && hasValue(dataHolder, name, context))
455                {
456                    ModelAwareRepeater repeater = dataHolder.getValue(name);
457                    List<Map<String, Object>> entries = null;
458                    if (repeater != null)
459                    {
460                        entries = new ArrayList<>();
461                        for (ModelAwareRepeaterEntry entry : repeater.getEntries())
462                        {
463                            DataContext entryContext = repeaterContext.cloneContext().addSuffixToLastSegment("[" + entry.getPosition() + "]");
464                            entries.add(entry.dataToMap(group, entryContext));
465                        }
466                    }
467                    result.put(name, entries);
468                }
469            }, 
470            group -> result.putAll(dataToMap(dataHolder, group, context)));
471        
472        return result;
473    }
474    
475    /**
476     * Retrieves the {@link ModelAwareDataAwareAmetysObject} from the given {@link DataContext}
477     * @param context the context containing the ametys object
478     * @return the ametys object, or <code>null</code> if there is no object id in the context
479     */
480    public static ModelAwareDataAwareAmetysObject getAmetysObjectFromContext(DataContext context)
481    {
482        RepositoryDataContext repoContext = context instanceof RepositoryDataContext rc ? rc : RepositoryDataContext.newInstance(context);
483        
484        return repoContext.getObject()
485                          .filter(ModelAwareDataAwareAmetysObject.class::isInstance)
486                          .map(ModelAwareDataAwareAmetysObject.class::cast)
487                          .orElse(null);
488    }
489    
490    /**
491     * Check if the value at the given path should be rendered
492     * @param dataHolder the data holder containing the data to check
493     * @param dataPath the path of the data to check
494     * @param context the data context
495     * @param isEdition <code>true</code> if values are rendered in edition mode, <code>false</code> otherwise
496     * @return <code>true</code> if the value has to be rendered, <code>false</code> otherwise
497     */
498    public static boolean renderValue(ModelAwareDataHolder dataHolder, String dataPath, DataContext context, boolean isEdition)
499    {
500        if (!isEdition && !context.renderDisabledValues())
501        {
502            ModelItem modelItem = dataHolder.getDefinition(dataPath);
503            String absoluteDataPath = context.getDataPath();
504            return !_disableConditionsEvaluator.evaluateDisableConditions(modelItem, absoluteDataPath, getAmetysObjectFromContext(context));
505        }
506        else
507        {
508            return true;
509        }
510    }
511    
512    /**
513     * Check if there is a value to render at the given path
514     * @param dataHolder the data holder containing the data to check
515     * @param dataPath the path of the data to check
516     * @param context the data context
517     * @return <code>true</code> if there is a value to render, <code>false</code> otherwise
518     */
519    public static boolean hasValue(ModelAwareDataHolder dataHolder, String dataPath, DataContext context)
520    {
521        return dataHolder.hasValue(dataPath)
522               || context.renderEmptyValues() && dataHolder.hasValueOrEmpty(dataPath);
523    }
524    
525    private static boolean _hasExternalizableValue(ModelAwareDataHolder dataHolder, String dataName, DataContext context)
526    {
527        return dataHolder.hasLocalValue(dataName)
528                || dataHolder.hasExternalValue(dataName)
529                || context.renderEmptyValues()
530                    && (dataHolder.hasLocalValueOrEmpty(dataName) || dataHolder.hasExternalValueOrEmpty(dataName));
531    }
532    
533    /**
534     * Retrieves the {@link SystemPropertyExtensionPoint} for content system properties
535     * @return the {@link SystemPropertyExtensionPoint} for content system properties
536     */
537    public static SystemPropertyExtensionPoint getContentSystemPropertyExtensionPoint()
538    {
539        return _contentSystemPropertyExtentionPoint;
540    }
541}