001/* 002 * Copyright 2016 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.search.content; 017 018import java.util.ArrayList; 019import java.util.Collection; 020import java.util.HashMap; 021import java.util.List; 022import java.util.Locale; 023import java.util.Map; 024import java.util.Optional; 025import java.util.Set; 026 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.commons.lang3.StringUtils; 032 033import org.ametys.cms.data.ContentValue; 034import org.ametys.cms.data.ametysobject.ModelAwareDataAwareAmetysObject; 035import org.ametys.cms.data.holder.impl.IndexableDataHolderHelper; 036import org.ametys.cms.data.type.ModelItemTypeConstants; 037import org.ametys.cms.model.CMSDataContext; 038import org.ametys.cms.model.properties.Property; 039import org.ametys.cms.repository.Content; 040import org.ametys.cms.search.model.SearchModel; 041import org.ametys.cms.search.model.SystemProperty; 042import org.ametys.cms.search.ui.model.SearchUIColumn; 043import org.ametys.cms.search.ui.model.SearchUIColumnHelper; 044import org.ametys.cms.search.ui.model.impl.RepeaterSearchUIColumn; 045import org.ametys.plugins.repository.data.external.ExternalizableDataProviderExtensionPoint; 046import org.ametys.plugins.repository.data.holder.ModelAwareDataHolder; 047import org.ametys.plugins.repository.data.holder.group.ModelAwareComposite; 048import org.ametys.plugins.repository.data.holder.group.ModelAwareRepeater; 049import org.ametys.plugins.repository.data.holder.group.ModelAwareRepeaterEntry; 050import org.ametys.plugins.repository.model.CompositeDefinition; 051import org.ametys.plugins.repository.model.RepeaterDefinition; 052import org.ametys.plugins.repository.model.ViewHelper; 053import org.ametys.runtime.model.ElementDefinition; 054import org.ametys.runtime.model.Model; 055import org.ametys.runtime.model.ModelHelper; 056import org.ametys.runtime.model.ModelItem; 057import org.ametys.runtime.model.ModelItemGroup; 058import org.ametys.runtime.model.ModelViewItem; 059import org.ametys.runtime.model.ModelViewItemGroup; 060import org.ametys.runtime.model.View; 061import org.ametys.runtime.model.ViewElement; 062import org.ametys.runtime.model.ViewElementAccessor; 063import org.ametys.runtime.model.ViewItemAccessor; 064import org.ametys.runtime.model.ViewItemContainer; 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.ModelItemType; 069import org.ametys.runtime.plugin.component.AbstractLogEnabled; 070 071/** 072 * Component creating content values extractors from {@link SearchModel}s or {@link View}s. 073 */ 074public class ContentValuesExtractorFactory extends AbstractLogEnabled implements Component, Serviceable 075{ 076 /** The component role. */ 077 public static final String ROLE = ContentValuesExtractorFactory.class.getName(); 078 079 /** The content search helper. */ 080 protected ContentSearchHelper _searchHelper; 081 082 /** To determine the externalizable status */ 083 protected ExternalizableDataProviderExtensionPoint _externalizableDataProviderEP; 084 085 @Override 086 public void service(ServiceManager manager) throws ServiceException 087 { 088 _searchHelper = (ContentSearchHelper) manager.lookup(ContentSearchHelper.ROLE); 089 _externalizableDataProviderEP = (ExternalizableDataProviderExtensionPoint) manager.lookup(ExternalizableDataProviderExtensionPoint.ROLE); 090 } 091 092 /** 093 * Create a ContentValuesExtractor from a search model. 094 * @param searchModel The reference search model. 095 * @return a ContentValuesExtractor backed by the given search model. 096 */ 097 public SearchModelContentValuesExtractor create(SearchModel searchModel) 098 { 099 return new SearchModelContentValuesExtractor(searchModel); 100 } 101 102 /** 103 * Create a simple {@link ContentValuesExtractor} from a view 104 * @param view The view. 105 * @return The created {@link ContentValuesExtractor} 106 */ 107 public SimpleContentValuesExtractor create(View view) 108 { 109 return new SimpleContentValuesExtractor(view); 110 } 111 112 /** 113 * A ContentValuesExtractor 114 */ 115 public interface ContentValuesExtractor 116 { 117 /** 118 * Get the values from the given content. 119 * @param content The content. 120 * @param defaultLocale The default locale for localized values if the content's language is null. Can be null. 121 * @param contextualParameters The search contextual parameters. 122 * @return the extracted values. 123 */ 124 public Map<String, Object> getValues(Content content, Locale defaultLocale, Map<String, Object> contextualParameters); 125 } 126 127 /** 128 * An abstract implementation of ContentValuesExtractor 129 */ 130 public abstract class AbstractContentValuesExtractor implements ContentValuesExtractor 131 { 132 public Map<String, Object> getValues(Content content, Locale defaultLocale, Map<String, Object> contextualParameters) 133 { 134 CMSDataContext context = CMSDataContext.newInstance() 135 .withRichTextMaxLength(100) 136 .withObject(content) 137 .withLocale(defaultLocale) 138 .withEmptyValues(false); 139 140 boolean handleExternalizable = (boolean) contextualParameters.getOrDefault("externalizable", true); 141 if (handleExternalizable) 142 { 143 Set<String> externalizableData = _externalizableDataProviderEP.getExternalizableDataPaths(content); 144 context.withExternalizableData(externalizableData); 145 } 146 147 ViewItemContainer viewItemContainer = _getResultItems(content.getModel(), contextualParameters); 148 return _dataToJSON(content, viewItemContainer, context, StringUtils.EMPTY, new HashMap<>()); 149 } 150 151 /** 152 * Retrieves the view to use to extract values 153 * @param model the model 154 * @param contextualParameters The search contextual parameters. 155 * @return the {@link View} to use to extract values 156 */ 157 protected abstract ViewItemContainer _getResultItems(Collection<? extends Model> model, Map<String, Object> contextualParameters); 158 } 159 160 /** 161 * A ContentValuesExtractor backed by a {@link SearchModel}. 162 */ 163 public class SearchModelContentValuesExtractor extends AbstractContentValuesExtractor 164 { 165 private SearchModel _searchModel; 166 167 /** 168 * Build a ContentValuesExtractor referencing a {@link SearchModel}. 169 * @param searchModel the {@link SearchModel}. 170 */ 171 public SearchModelContentValuesExtractor(SearchModel searchModel) 172 { 173 _searchModel = searchModel; 174 } 175 176 @Override 177 protected ViewItemContainer _getResultItems(Collection< ? extends Model> model, Map<String, Object> contextualParameters) 178 { 179 return _searchModel.getResultItems(contextualParameters); 180 } 181 } 182 183 /** 184 * A simple ContentValuesExtractor on a list of content types. 185 */ 186 public class SimpleContentValuesExtractor extends AbstractContentValuesExtractor 187 { 188 private ViewItemContainer _resultItems; 189 190 /** 191 * Build a simple {@link ContentValuesExtractor} from the given result items 192 * @param resultItems The result items 193 * @throws IllegalArgumentException if the given result items contain a composite as leaf, or a {@link SystemProperty} that is not displayable 194 */ 195 public SimpleContentValuesExtractor(ViewItemContainer resultItems) throws IllegalArgumentException 196 { 197 _resultItems = resultItems; 198 } 199 200 @Override 201 protected ViewItemContainer _getResultItems(Collection< ? extends Model> model, Map<String, Object> contextualParameters) 202 { 203 return _resultItems; 204 } 205 } 206 207 @SuppressWarnings("unchecked") 208 private Map<String, Object> _dataToJSON(ModelAwareDataHolder dataHolder, ViewItemAccessor viewItemAccessor, DataContext context, String prefix, Map<String, Content> resolvedContents) throws BadItemTypeException, UndefinedItemPathException 209 { 210 Map<String, Object> result = new HashMap<>(); 211 212 ViewHelper.visitView(viewItemAccessor, 213 (element, definition) -> { 214 // simple element 215 String name = definition.getName(); 216 String newDataPath = prefix + name; 217 218 DataContext newContext = CMSDataContext.newInstance(context) 219 .addSegmentToDataPath(name) 220 .withViewItem(element) 221 .withModelItem(definition); 222 223 result.putAll(_elementToJSON(dataHolder, element, definition, newDataPath, newContext, resolvedContents)); 224 }, 225 (group, definition) -> { 226 // composite 227 String name = definition.getName(); 228 String newDataPath = prefix + name; 229 230 DataContext newContext = CMSDataContext.newInstance(context) 231 .addSegmentToDataPath(name) 232 .withViewItem(group) 233 .withModelItem(definition); 234 235 result.putAll(_compositeToJSON(dataHolder, group, definition, newDataPath, newContext, resolvedContents)); 236 }, 237 (group, definition) -> { 238 // repeater 239 String name = definition.getName(); 240 String newDataPath = prefix + name; 241 242 DataContext repeaterContext = CMSDataContext.newInstance(context) 243 .addSegmentToDataPath(name) 244 .withViewItem(group) 245 .withModelItem(definition); 246 247 result.putAll(_repeaterToJSON(dataHolder, group, definition, newDataPath, repeaterContext, resolvedContents)); 248 }, 249 group -> result.putAll(_dataToJSON(dataHolder, group, context, prefix, resolvedContents))); 250 251 return result; 252 } 253 254 private Map<String, Object> _elementToJSON(ModelAwareDataHolder dataHolder, ViewElement viewElement, ElementDefinition definition, String dataPath, DataContext context, Map<String, Content> resolvedContents) 255 { 256 Map<String, Object> result = new HashMap<>(); 257 String name = definition.getName(); 258 259 if (viewElement instanceof ViewElementAccessor viewElementAccessor 260 && !viewElementAccessor.getViewItems().isEmpty()) 261 { 262 // The view item is an accessor -> convert all its children to JSON 263 if (dataHolder.hasValue(name) && ModelItemTypeConstants.CONTENT_ELEMENT_TYPE_ID.equals(definition.getType().getId())) 264 { 265 Map<String, Object> contentJSON = definition.isMultiple() 266 ? _multipleContentToJSON(dataHolder, viewElementAccessor, name, dataPath, context, resolvedContents) 267 : _singleContentToJSON(dataHolder, viewElementAccessor, name, dataPath, context, resolvedContents); 268 269 result.putAll(contentJSON); 270 } 271 } 272 else if (definition instanceof Property property) 273 { 274 ModelAwareDataAwareAmetysObject ametysObject = IndexableDataHolderHelper.getAmetysObjectFromContext(context); 275 @SuppressWarnings("unchecked") 276 Object json = property.valueToJSON(ametysObject, context); 277 result.put(dataPath, json); 278 } 279 else 280 { 281 if (((CMSDataContext) context).isDataExternalizable()) 282 { 283 Map<String, Object> json = IndexableDataHolderHelper.externalizableValuesAsJson(dataHolder, name, context); 284 if (!json.isEmpty()) 285 { 286 result.put(dataPath, json); 287 } 288 } 289 else if (dataHolder.hasValue(name)) 290 { 291 Object value = dataHolder.getValue(name); 292 if (ModelItemTypeConstants.CONTENT_ELEMENT_TYPE_ID.equals(definition.getType().getId())) 293 { 294 // Remove view item from context because children must be processed by the current algorithm, not by the type's one 295 context.withViewItem(null); 296 297 // Resolve contents here to use resolved contents cache 298 value = _resolveContentValues(definition, value, resolvedContents); 299 } 300 301 ModelItemType type = definition.getType(); 302 Object json = type.valueToJSONForClient(value, context); 303 result.put(dataPath, json); 304 } 305 } 306 307 return result; 308 } 309 310 @SuppressWarnings("unchecked") 311 private Map<String, Object> _multipleContentToJSON(ModelAwareDataHolder dataHolder, ViewElementAccessor viewElementAccessor, String modelItemName, String dataPath, DataContext context, Map<String, Content> resolvedContents) 312 { 313 Map<String, List<Object>> allDataJSON = new HashMap<>(); // JSON for all data except repeaters 314 Map<String, Object> repeatersJSON = new HashMap<>(); // JSON for repeaters that are leaves 315 316 ContentValue[] conntentValues = dataHolder.getValue(modelItemName); 317 for (ContentValue contentValue : conntentValues) 318 { 319 Content linkedContent = _getResolvedContent(contentValue, resolvedContents); 320 if (linkedContent != null) 321 { 322 DataContext contentContext = CMSDataContext.newInstance(context) 323 .withObject(linkedContent) 324 .withDataPath(StringUtils.EMPTY); 325 326 Map<String, Object> contentJSON = _dataToJSON(linkedContent, viewElementAccessor, contentContext, dataPath + ModelItem.ITEM_PATH_SEPARATOR, resolvedContents); 327 for (String childDataPath : contentJSON.keySet()) 328 { 329 String definitionPath = ModelHelper.getDefinitionPathFromDataPath(childDataPath); 330 Object childJSON = contentJSON.get(childDataPath); 331 if (_isRepeaterJSON(childJSON)) 332 { 333 // The child is a repeater -> merge the entries 334 _mergeRepeaterEntriesJSON(repeatersJSON, (Map<String, Object>) childJSON, definitionPath); 335 } 336 else 337 { 338 // Merge data of all contents 339 List<Object> dataJSON = allDataJSON.computeIfAbsent(definitionPath, __ -> new ArrayList<>()); 340 if (childJSON instanceof List jsonList) 341 { 342 dataJSON.addAll(jsonList); 343 } 344 else 345 { 346 dataJSON.add(childJSON); 347 } 348 } 349 } 350 } 351 } 352 353 Map<String, Object> result = new HashMap<>(); 354 result.putAll(allDataJSON); 355 result.putAll(repeatersJSON); 356 357 return result; 358 } 359 360 private Map<String, Object> _singleContentToJSON(ModelAwareDataHolder dataHolder, ViewElementAccessor viewElementAccessor, String modelItemName, String dataPath, DataContext context, Map<String, Content> resolvedContents) 361 { 362 Map<String, Object> result = new HashMap<>(); 363 364 ContentValue contentValue = dataHolder.getValue(modelItemName); 365 Content linkedContent = _getResolvedContent(contentValue, resolvedContents); 366 367 if (linkedContent != null) 368 { 369 DataContext contentContext = CMSDataContext.newInstance(context) 370 .withObject(linkedContent) 371 .withDataPath(StringUtils.EMPTY); 372 373 Map<String, Object> contentJSON = _dataToJSON(linkedContent, viewElementAccessor, contentContext, dataPath + ModelItem.ITEM_PATH_SEPARATOR, resolvedContents); 374 result.putAll(contentJSON); 375 } 376 377 return result; 378 } 379 380 private Object _resolveContentValues(ElementDefinition definition, Object value, Map<String, Content> resolvedContents) 381 { 382 Object result = value; 383 384 if (definition.isMultiple()) 385 { 386 ContentValue[] contentValues = (ContentValue[]) value; 387 List<Content> contents = new ArrayList<>(); 388 for (ContentValue contentValue : contentValues) 389 { 390 Content content = _getResolvedContent(contentValue, resolvedContents); 391 if (content != null) 392 { 393 contents.add(content); 394 } 395 } 396 397 result = contents.toArray(new Content[contents.size()]); 398 } 399 else 400 { 401 ContentValue contentValue = (ContentValue) value; 402 Content content = _getResolvedContent(contentValue, resolvedContents); 403 result = content != null ? content : result; 404 } 405 406 return result; 407 } 408 409 private Content _getResolvedContent(ContentValue contentValue, Map<String, Content> resolvedContents) 410 { 411 return resolvedContents.computeIfAbsent(contentValue.getContentId(), 412 id -> contentValue.getContentIfExists().orElse(null)); 413 } 414 415 private Map<String, Object> _compositeToJSON(ModelAwareDataHolder dataHolder, ModelViewItemGroup<CompositeDefinition> compositeViewItem, CompositeDefinition compositeDefinition, String dataPath, DataContext context, Map<String, Content> resolvedContents) 416 { 417 Map<String, Object> result = new HashMap<>(); 418 String name = compositeDefinition.getName(); 419 420 if (compositeViewItem.getViewItems().isEmpty()) 421 { 422 throw new IllegalArgumentException("Attribute at path '" + dataPath + "' is a composite: can not invoke #getAttributeValue"); 423 } 424 425 if (dataHolder.hasValue(name)) 426 { 427 ModelAwareComposite value = dataHolder.getValue(name); 428 Map<String, Object> json = _dataToJSON(value, compositeViewItem, context, dataPath + ModelItem.ITEM_PATH_SEPARATOR, resolvedContents); 429 result.putAll(json); 430 } 431 432 return result; 433 } 434 435 private Map<String, Object> _repeaterToJSON(ModelAwareDataHolder dataHolder, ModelViewItemGroup<RepeaterDefinition> repeaterViewItem, RepeaterDefinition repeaterDefinition, String repeaterPath, DataContext context, Map<String, Content> resolvedContents) 436 { 437 Map<String, Object> result = new HashMap<>(); 438 String name = repeaterDefinition.getName(); 439 440 if (dataHolder.hasValue(name)) 441 { 442 ModelAwareRepeater repeater = dataHolder.getValue(name); 443 444 if (repeaterViewItem instanceof SearchUIColumn || repeaterViewItem.getViewItems().isEmpty()) 445 { 446 ModelViewItemGroup<RepeaterDefinition> repeaterLeaf = _getRepeaterLeafViewItem(repeaterViewItem, repeaterDefinition); 447 Map<String, Object> repeaterLeafJSON = _repeaterLeafToJSON(repeater, repeaterLeaf, context, resolvedContents); 448 result.put(repeaterPath, repeaterLeafJSON); 449 } 450 else 451 { 452 Map<String, Object> repeaterJSON = _repeaterToJSON(repeater, repeaterViewItem, repeaterPath, context, resolvedContents); 453 result.putAll(repeaterJSON); 454 } 455 } 456 457 return result; 458 } 459 460 private Map<String, Object> _repeaterLeafToJSON(ModelAwareRepeater repeater, ModelViewItemGroup<RepeaterDefinition> repeaterViewItem, DataContext context, Map<String, Content> resolvedContents) 461 { 462 List<Map<String, Object>> entriesJSON = new ArrayList<>(); 463 for (ModelAwareRepeaterEntry entry : repeater.getEntries()) 464 { 465 DataContext entryContext = CMSDataContext.newInstance(context) 466 .addSuffixToLastSegment("[" + entry.getPosition() + "]"); 467 468 Map<String, Object> entryValuesJson = _dataToJSON(entry, repeaterViewItem, entryContext, StringUtils.EMPTY, resolvedContents); 469 470 Map<String, Object> entryJSON = new HashMap<>(); 471 entryJSON.put("values", entryValuesJson); 472 entryJSON.put("position", entry.getPosition()); 473 474 entriesJSON.add(entryJSON); 475 } 476 477 Map<String, Object> json = new HashMap<>(); 478 json.put("type", org.ametys.plugins.repository.data.type.ModelItemTypeConstants.REPEATER_TYPE_ID); 479 json.put("entries", entriesJSON); 480 json.put("label", repeaterViewItem.getDefinition().getLabel()); 481 482 Optional.ofNullable(repeaterViewItem.getDefinition().getHeaderLabel()) 483 .ifPresent(headerLabel -> json.put("header-label", headerLabel)); 484 485 return json; 486 } 487 488 private ModelViewItemGroup<RepeaterDefinition> _getRepeaterLeafViewItem(ModelViewItemGroup<RepeaterDefinition> group, RepeaterDefinition definition) 489 { 490 boolean useColumns = group instanceof SearchUIColumn; 491 ModelViewItemGroup<RepeaterDefinition> viewItemGroup = useColumns ? new RepeaterSearchUIColumn() : new ModelViewItemGroup<>(); 492 viewItemGroup.setDefinition(definition); 493 494 for (ModelItem child : definition.getChildren()) 495 { 496 _addViewItemForRepeaterLeaf(child, viewItemGroup, useColumns); 497 } 498 499 return viewItemGroup; 500 } 501 502 @SuppressWarnings("unchecked") 503 private void _addViewItemForRepeaterLeaf(ModelItem modelItem, ModelViewItemGroup group, boolean isColumns) 504 { 505 ModelViewItem viewItem; 506 if (modelItem instanceof CompositeDefinition compositeDefinition) 507 { 508 viewItem = new ModelViewItemGroup<>(); 509 510 // Add children only for composites, children of type repeater do not need to have children 511 for (ModelItem child : compositeDefinition.getChildren()) 512 { 513 _addViewItemForRepeaterLeaf(child, (ModelViewItemGroup) viewItem, isColumns); 514 } 515 } 516 else 517 { 518 viewItem = isColumns 519 ? SearchUIColumnHelper.createModelItemColumn(modelItem) 520 : modelItem instanceof ModelItemGroup 521 ? new ModelViewItemGroup<>() 522 : new ViewElement(); 523 } 524 525 viewItem.setDefinition(modelItem); 526 group.addViewItem(viewItem); 527 } 528 529 @SuppressWarnings("unchecked") 530 private Map<String, Object> _repeaterToJSON(ModelAwareRepeater repeater, ModelViewItemGroup<RepeaterDefinition> group, String repeaterPath, DataContext repeaterContext, Map<String, Content> resolvedContents) 531 { 532 Map<String, List<Object>> singleDataJSON = new HashMap<>(); 533 Map<String, Object> repeatersJSON = new HashMap<>(); 534 535 for (ModelAwareRepeaterEntry entry : repeater.getEntries()) 536 { 537 DataContext entryContext = CMSDataContext.newInstance(repeaterContext) 538 .addSuffixToLastSegment("[" + entry.getPosition() + "]"); 539 540 Map<String, Object> entryJson = _dataToJSON(entry, group, entryContext, repeaterPath + "[" + entry.getPosition() + "]" + ModelItem.ITEM_PATH_SEPARATOR, resolvedContents); 541 542 for (String entryPath : entryJson.keySet()) 543 { 544 String definitionPath = ModelHelper.getDefinitionPathFromDataPath(entryPath); 545 Object dataJSON = entryJson.get(entryPath); 546 if (_isRepeaterJSON(dataJSON)) 547 { 548 // The child is a repeater -> merge the entries 549 _mergeRepeaterEntriesJSON(repeatersJSON, (Map<String, Object>) dataJSON, definitionPath); 550 } 551 else 552 { 553 // Merge data of all data 554 List<Object> dataJsonList = singleDataJSON.computeIfAbsent(definitionPath, __ -> new ArrayList<>()); 555 if (dataJSON instanceof List jsonList) 556 { 557 dataJsonList.addAll(jsonList); 558 } 559 else 560 { 561 dataJsonList.add(dataJSON); 562 } 563 } 564 } 565 } 566 567 Map<String, Object> result = new HashMap<>(); 568 result.putAll(singleDataJSON); 569 result.putAll(repeatersJSON); 570 571 return result; 572 } 573 574 private boolean _isRepeaterJSON(Object json) 575 { 576 return json instanceof Map map && org.ametys.plugins.repository.data.type.ModelItemTypeConstants.REPEATER_TYPE_ID.equals(map.get("type")); 577 } 578 579 @SuppressWarnings("unchecked") 580 private void _mergeRepeaterEntriesJSON(Map<String, Object> repeatersJSON, Map<String, Object> json, String definitionPath) 581 { 582 if (repeatersJSON.containsKey(definitionPath)) 583 { 584 Map<String, Object> repeaterJSON = (Map<String, Object>) repeatersJSON.get(definitionPath); 585 List<Object> allEntries = (List<Object>) repeaterJSON.get("entries"); 586 List<Object> entries = (List<Object>) json.get("entries"); 587 allEntries.addAll(entries); 588 } 589 else 590 { 591 repeatersJSON.put(definitionPath, json); 592 } 593 } 594}