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