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