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}