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}