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