001/* 002 * Copyright 2010 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.content.consistency; 017 018import java.time.ZonedDateTime; 019import java.util.ArrayList; 020import java.util.Collection; 021import java.util.HashMap; 022import java.util.Iterator; 023import java.util.List; 024import java.util.Map; 025import java.util.Optional; 026import java.util.concurrent.Callable; 027import java.util.concurrent.CancellationException; 028import java.util.concurrent.ExecutionException; 029import java.util.concurrent.Executor; 030import java.util.concurrent.ExecutorService; 031import java.util.concurrent.Executors; 032import java.util.concurrent.Future; 033import java.util.concurrent.ThreadFactory; 034import java.util.concurrent.atomic.AtomicLong; 035 036import javax.jcr.RepositoryException; 037 038import org.apache.avalon.framework.activity.Initializable; 039import org.apache.avalon.framework.component.Component; 040import org.apache.avalon.framework.context.ContextException; 041import org.apache.avalon.framework.context.Contextualizable; 042import org.apache.avalon.framework.logger.Logger; 043import org.apache.avalon.framework.service.ServiceException; 044import org.apache.avalon.framework.service.ServiceManager; 045import org.apache.avalon.framework.service.Serviceable; 046import org.apache.cocoon.Constants; 047import org.apache.cocoon.environment.Context; 048import org.apache.cocoon.util.log.SLF4JLoggerAdapter; 049 050import org.ametys.cms.content.references.OutgoingReferences; 051import org.ametys.cms.contenttype.ContentType; 052import org.ametys.cms.contenttype.ContentTypeExtensionPoint; 053import org.ametys.cms.repository.Content; 054import org.ametys.cms.repository.ContentQueryHelper; 055import org.ametys.cms.repository.DefaultContent; 056import org.ametys.cms.repository.WorkflowAwareContent; 057import org.ametys.cms.transformation.ConsistencyChecker; 058import org.ametys.cms.transformation.ConsistencyChecker.CHECK; 059import org.ametys.core.engine.BackgroundEngineHelper; 060import org.ametys.core.util.DateUtils; 061import org.ametys.plugins.repository.AmetysObjectIterable; 062import org.ametys.plugins.repository.AmetysObjectResolver; 063import org.ametys.plugins.repository.AmetysRepositoryException; 064import org.ametys.plugins.repository.ModifiableAmetysObject; 065import org.ametys.plugins.repository.ModifiableTraversableAmetysObject; 066import org.ametys.plugins.repository.RepositoryConstants; 067import org.ametys.plugins.repository.UnknownAmetysObjectException; 068import org.ametys.plugins.repository.collection.AmetysObjectCollectionFactory; 069import org.ametys.plugins.repository.jcr.NameHelper; 070import org.ametys.plugins.repository.jcr.NameHelper.NameComputationMode; 071import org.ametys.plugins.repository.query.QueryHelper; 072import org.ametys.plugins.repository.query.expression.AndExpression; 073import org.ametys.plugins.repository.query.expression.Expression; 074import org.ametys.plugins.repository.query.expression.Expression.Operator; 075import org.ametys.plugins.repository.query.expression.StringExpression; 076import org.ametys.plugins.repositoryapp.RepositoryProvider; 077import org.ametys.plugins.workflow.support.WorkflowHelper; 078import org.ametys.runtime.model.Model; 079import org.ametys.runtime.model.ModelHelper; 080import org.ametys.runtime.model.View; 081import org.ametys.runtime.model.ViewHelper; 082import org.ametys.runtime.plugin.component.AbstractLogEnabled; 083 084import com.opensymphony.workflow.loader.StepDescriptor; 085 086/** 087 * Manage all operation related to checking the consistency of a content 088 */ 089public class ContentConsistencyManager extends AbstractLogEnabled implements Initializable, Contextualizable, Serviceable, Component 090{ 091 092 /** The avalon role */ 093 public static final String ROLE = ContentConsistencyManager.class.getName(); 094 095 private static final String __CONSISTENCY_RESULTS_ROOT_NODE_NAME = "consistencyResults"; 096 097 private static ExecutorService __PARALLEL_THREAD_EXECUTOR; 098 private static final int __THREAD_POOL_SIZE_MULTIPLIER = 4; 099 100 /** The ametys object resolver. */ 101 protected AmetysObjectResolver _resolver; 102 103 /** The consistency checker */ 104 protected ConsistencyChecker _consistencyChecker; 105 106 private Context _cocoonContext; 107 private RepositoryProvider _repositoryProvider; 108 private ServiceManager _manager; 109 110 private ContentTypeExtensionPoint _cTypeEP; 111 112 private WorkflowHelper _workflowHelper; 113 114 public void contextualize(org.apache.avalon.framework.context.Context context) throws ContextException 115 { 116 _cocoonContext = (Context) context.get(Constants.CONTEXT_ENVIRONMENT_CONTEXT); 117 } 118 119 public void service(ServiceManager manager) throws ServiceException 120 { 121 _manager = manager; 122 _consistencyChecker = (ConsistencyChecker) manager.lookup(ConsistencyChecker.ROLE); 123 _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE); 124 _repositoryProvider = (RepositoryProvider) manager.lookup(RepositoryProvider.ROLE); 125 _workflowHelper = (WorkflowHelper) manager.lookup(WorkflowHelper.ROLE); 126 _cTypeEP = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE); 127 } 128 129 public void initialize() throws Exception 130 { 131 AsyncConsistencyCheckerThreadFactory threadFactory = new AsyncConsistencyCheckerThreadFactory(); 132 // The thread are doing a lot of heavy IO operations, it's worth going over the number of available processors 133 __PARALLEL_THREAD_EXECUTOR = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors() * __THREAD_POOL_SIZE_MULTIPLIER, threadFactory); 134 } 135 136 /** 137 * Thread factory for async checker. 138 * Set the thread name format and marks the thread as daemon. 139 */ 140 static class AsyncConsistencyCheckerThreadFactory implements ThreadFactory 141 { 142 private static ThreadFactory _defaultThreadFactory; 143 private static String _nameFormat; 144 private static AtomicLong _count; 145 146 public AsyncConsistencyCheckerThreadFactory() 147 { 148 _defaultThreadFactory = Executors.defaultThreadFactory(); 149 _nameFormat = "ametys-async-consistency-checker-%d"; 150 _count = new AtomicLong(0); 151 } 152 153 public Thread newThread(Runnable r) 154 { 155 Thread thread = _defaultThreadFactory.newThread(r); 156 thread.setName(String.format(_nameFormat, _count.getAndIncrement())); 157 // make the threads low priority daemon to avoid slowing user thread 158 thread.setDaemon(true); 159 thread.setPriority(3); 160 161 return thread; 162 } 163 } 164 165 /** 166 * Getter to provide synthetic access to the manager 167 */ 168 private ServiceManager getManager() 169 { 170 return _manager; 171 } 172 173 /** 174 * Runnable to be used for asynchronous calls 175 */ 176 class AsyncConsistencyChecker implements Callable<String> 177 { 178 /** event to observe */ 179 protected final Logger _logger; 180 private final Content _content; 181 182 public AsyncConsistencyChecker(Content content, org.slf4j.Logger logger) 183 { 184 this._logger = new SLF4JLoggerAdapter(logger); 185 this._content = content; 186 } 187 188 @Override 189 public String call() 190 { 191 Map<String, Object> environmentInformation = null; 192 try 193 { 194 // Create the environment. 195 environmentInformation = BackgroundEngineHelper.createAndEnterEngineEnvironment(getManager(), _cocoonContext, _logger); 196 197 return _checkConsistency(_content); 198 } 199 catch (Exception e) 200 { 201 throw new RuntimeException("Content consistency check for content " + _content.getId() + " failed", e); 202 } 203 finally 204 { 205 BackgroundEngineHelper.leaveEngineEnvironment(environmentInformation); 206 } 207 } 208 } 209 210 /** 211 * Check all contents to see if there references are consistent. 212 * All result will also be stored in the consistency result database table for later access. 213 * @return record describing the results of the checks 214 */ 215 public ConsistenciesReport checkAllContents() 216 { 217 // Get the start date to remove outdated results later 218 ZonedDateTime startDate = ZonedDateTime.now(); 219 try (AmetysObjectIterable<Content> contents = _getContents(null)) 220 { 221 ConsistenciesReport checkReport = _checkContents(contents); 222 // the checkContents only remove possible outdated results for the set of content provided as arguments. 223 // We need to remove all the other result : deleted content, deleted link, etc… 224 removeOutdatedResult(startDate, null); 225 return checkReport; 226 } 227 } 228 229 /** 230 * Remove results older than a given date 231 * @param date the threshold 232 * @param filterExpression an expression to filter the content to consider, null to consider all contents 233 */ 234 protected void removeOutdatedResult(ZonedDateTime date, Expression filterExpression) 235 { 236 String xPathQuery = QueryHelper.getXPathQuery(null, ContentConsistencyResult.CONTENT_CONSISTENCY_RESULT_NODETYPE, filterExpression); 237 xPathQuery += "[@ametys:" + ContentConsistencyResult.DATE + " < xs:dateTime('" + DateUtils.zonedDateTimeToString(date) + "')]"; 238 try (AmetysObjectIterable<ContentConsistencyResult> outdatedResults = _resolver.query(xPathQuery)) 239 { 240 for (ContentConsistencyResult outdatedResult : outdatedResults) 241 { 242 try 243 { 244 outdatedResult.remove(); 245 outdatedResult.saveChanges(); 246 } 247 catch (AmetysRepositoryException e) 248 { 249 getLogger().warn("Failed to remove outdated result '{}' due to repository error", outdatedResult.getId(), e); 250 } 251 } 252 } 253 } 254 255 /** 256 * Record holding the results of a consistency checks on multiple contents 257 * @param results a list of {@link ContentConsistencyResult} for every contents that were checked 258 * @param unchecked a list of content id for every content check that were interrupted, cancelled or failed during execution 259 */ 260 public record ConsistenciesReport(List<String> results, List<String> unchecked) { 261 /** 262 * Returns true if this report contains no elements 263 * @return true if this report contains no elements 264 */ 265 public boolean isEmpty() 266 { 267 return (results == null || results.isEmpty()) 268 && (unchecked == null || unchecked.isEmpty()); 269 } 270 } 271 272 /** 273 * Check all the content provided by the iterable for broken links. 274 * This method will actually provide {@link Callable} to an {@link Executor} that parallelize the checks. 275 * @param contents an iterable of contents to check 276 * @return record describing the results of the checks 277 */ 278 protected ConsistenciesReport _checkContents(AmetysObjectIterable<Content> contents) 279 { 280 try 281 { 282 List<AsyncConsistencyChecker> checkers = contents.stream() 283 .map(content -> new AsyncConsistencyChecker(content, getLogger())) 284 .toList(); 285 286 // execute all checker and wait for their completion (either success or failure) 287 List<Future<String>> futures = __PARALLEL_THREAD_EXECUTOR.invokeAll(checkers); 288 289 // Refresh the session to retrieve the repository modification from the threads 290 _repositoryProvider.getSession("default").refresh(true); 291 292 Iterator<Future<String>> fIterarot = futures.iterator(); 293 Iterator<Content> cIterator = contents.iterator(); 294 295 List<String> done = new ArrayList<>(); 296 List<String> failed = new ArrayList<>(); 297 298 // both iterator should have the same size as the future iterator was mapped from the content iterator 299 while (fIterarot.hasNext() && cIterator.hasNext()) 300 { 301 Future<String> future = fIterarot.next(); 302 Content content = cIterator.next(); 303 304 try 305 { 306 String result = future.get(); 307 if (result != null) 308 { 309 done.add(result); 310 } 311 } 312 catch (CancellationException | InterruptedException | ExecutionException e) 313 { 314 String contentId = content.getId(); 315 getLogger().error("Failed to retrieve result from content consistency checker thread for content {}", contentId, e); 316 failed.add(contentId); 317 } 318 } 319 return new ConsistenciesReport(done, failed); 320 } 321 catch (InterruptedException e) 322 { 323 getLogger().error("Content consistency check was interrupted", e); 324 return null; 325 } 326 catch (RepositoryException e1) 327 { 328 getLogger().error("Failed to refresh the session"); 329 return null; 330 } 331 } 332 333 private String _checkConsistency(Content content) 334 { 335 int successCount = 0; 336 int unknownCount = 0; 337 int unauthorizedCount = 0; 338 int notFoundCount = 0; 339 int serverErrorCount = 0; 340 341 Map<String, OutgoingReferences> referencesByPath = content.getOutgoingReferences(); 342 343 for (String dataPath : referencesByPath.keySet()) 344 { 345 OutgoingReferences references = referencesByPath.get(dataPath); 346 for (String referenceType : references.keySet()) 347 { 348 for (String referenceValue : references.get(referenceType)) 349 { 350 CHECK check; 351 try 352 { 353 check = _consistencyChecker.checkConsistency(referenceType, referenceValue, content.getId(), dataPath, false).status(); 354 } 355 catch (Exception e) 356 { 357 check = CHECK.SERVER_ERROR; 358 getLogger().debug("An exception occurred while checking reference value {} at dataPath {} for content {}", referenceType + "#" + referenceValue, dataPath, content.getId(), e); 359 } 360 361 switch (check) 362 { 363 case SUCCESS: 364 successCount++; 365 break; 366 case UNKNOWN: 367 unknownCount++; 368 break; 369 case UNAUTHORIZED: 370 unauthorizedCount++; 371 break; 372 case NOT_FOUND: 373 notFoundCount++; 374 break; 375 case SERVER_ERROR: 376 default: 377 serverErrorCount++; 378 break; 379 } 380 } 381 } 382 } 383 384 // Store the result 385 ContentConsistencyResult result = storeResult((WorkflowAwareContent) content, successCount, unknownCount, unauthorizedCount, notFoundCount, serverErrorCount); 386 387 return result != null ? result.getId() : null; 388 } 389 390 /** 391 * Store the result of the consistency check done on the content 392 * @param content the content checked 393 * @param successCount the number of success 394 * @param unknownCount the number of unknown 395 * @param unauthorizedCount the number of unauthorized 396 * @param notFoundCount the number of not found 397 * @param serverErrorCount the number of server error 398 * @return the result 399 */ 400 protected ContentConsistencyResult storeResult(WorkflowAwareContent content, int successCount, int unknownCount, int unauthorizedCount, int notFoundCount, int serverErrorCount) 401 { 402 // Retrieve a pre existing result for the content or create an new one 403 Optional<ContentConsistencyResult> previousResult = _getExistingResultForContent(content.getId()); 404 if (unknownCount > 0 || unauthorizedCount > 0 || serverErrorCount > 0 || notFoundCount > 0) 405 { 406 ContentConsistencyResult result = previousResult.orElseGet(() -> 407 { 408 ModifiableTraversableAmetysObject root = _getOrCreateRootCollection(); 409 String nodeName = NameHelper.getUniqueAmetysObjectName(root, "consistency-result", NameComputationMode.GENERATED_KEY, false); 410 return root.createChild(nodeName, ContentConsistencyResult.CONTENT_CONSISTENCY_RESULT_NODETYPE); 411 } 412 ); 413 414 Map<String, Object> values = new HashMap<>(); 415 values.put(ContentConsistencyResult.CONTENT_ID, content.getId()); 416 values.put(ContentConsistencyResult.TITLE, content.getTitle()); 417 values.put(ContentConsistencyResult.CONTENT_TYPES, content.getTypes()); 418 values.put(ContentConsistencyResult.CREATION_DATE, content.getCreationDate()); 419 values.put(ContentConsistencyResult.CREATOR, content.getCreator()); 420 Optional.ofNullable(content.getLastMajorValidationDate()).ifPresent(date -> values.put(ContentConsistencyResult.LAST_MAJOR_VALIDATION_DATE, date)); 421 content.getLastMajorValidator().ifPresent(user -> values.put(ContentConsistencyResult.LAST_MAJOR_VALIDATOR, user)); 422 Optional.ofNullable(content.getLastValidationDate()).ifPresent(date -> values.put(ContentConsistencyResult.LAST_VALIDATION_DATE, date)); 423 content.getLastValidator().ifPresent(user -> values.put(ContentConsistencyResult.LAST_VALIDATOR, user)); 424 values.put(ContentConsistencyResult.LAST_CONTRIBUTOR, content.getLastContributor()); 425 values.put(ContentConsistencyResult.LAST_MODIFICATION_DATE, content.getLastModified()); 426 values.put(ContentConsistencyResult.WORKFLOW_STEP, content.getCurrentStepId()); 427 values.put(ContentConsistencyResult.DATE, ZonedDateTime.now()); 428 values.put(ContentConsistencyResult.NOT_FOUND, notFoundCount); 429 values.put(ContentConsistencyResult.SERVER_ERROR, serverErrorCount); 430 values.put(ContentConsistencyResult.SUCCESS, successCount); 431 values.put(ContentConsistencyResult.UNAUTHORIZED, unauthorizedCount); 432 values.put(ContentConsistencyResult.UNKNOWN, unknownCount); 433 434 result.synchronizeValues(values); 435 436 result.saveChanges(); 437 return result; 438 } 439 // Remove old result if there is no error any more 440 else if (previousResult.isPresent()) 441 { 442 ContentConsistencyResult result = previousResult.get(); 443 ModifiableAmetysObject parent = result.getParent(); 444 result.remove(); 445 parent.saveChanges(); 446 } 447 return null; 448 } 449 450 private Optional<ContentConsistencyResult> _getExistingResultForContent(String id) 451 { 452 String xPathQuery = QueryHelper.getXPathQuery(null, ContentConsistencyResult.CONTENT_CONSISTENCY_RESULT_NODETYPE, new StringExpression(ContentConsistencyResult.CONTENT_ID, Operator.EQ, id)); 453 try (AmetysObjectIterable<ContentConsistencyResult> query = _resolver.query(xPathQuery)) 454 { 455 return query.stream().findFirst(); 456 } 457 } 458 459 // Synchronized to avoid creation of multiple root 460 private synchronized ModifiableTraversableAmetysObject _getOrCreateRootCollection() 461 { 462 ModifiableTraversableAmetysObject pluginsRoot = _resolver.resolveByPath("/ametys:plugins"); 463 464 ModifiableTraversableAmetysObject cmsNode = null; 465 if (pluginsRoot.hasChild("cms")) 466 { 467 cmsNode = (ModifiableTraversableAmetysObject) pluginsRoot.getChild("cms"); 468 } 469 else 470 { 471 cmsNode = (ModifiableTraversableAmetysObject) pluginsRoot.createChild("cms", "ametys:unstructured"); 472 } 473 474 ModifiableTraversableAmetysObject resultsCollection = null; 475 if (cmsNode.hasChild(__CONSISTENCY_RESULTS_ROOT_NODE_NAME)) 476 { 477 resultsCollection = (ModifiableTraversableAmetysObject) cmsNode.getChild(__CONSISTENCY_RESULTS_ROOT_NODE_NAME); 478 } 479 else 480 { 481 resultsCollection = (ModifiableTraversableAmetysObject) cmsNode.createChild(__CONSISTENCY_RESULTS_ROOT_NODE_NAME, AmetysObjectCollectionFactory.COLLECTION_NODETYPE); 482 // Save the new node instantly to allow other thread to retrieve the newly created node. 483 cmsNode.saveChanges(); 484 } 485 486 return resultsCollection; 487 } 488 489 /** 490 * Get the contents with inconsistency information. 491 * @param filterExpression an expression to filter content to check, or null to check all contents 492 * @return an iterator on contents. 493 */ 494 protected AmetysObjectIterable<Content> _getContents(Expression filterExpression) 495 { 496 String query = ContentQueryHelper.getContentXPathQuery(filterExpression != null ? new AndExpression(new ConsistencyExpression(), filterExpression) : new ConsistencyExpression()); 497 return _resolver.query(query); 498 } 499 500 /** 501 * Expression which tests if contents have consistency informations. 502 */ 503 public static class ConsistencyExpression implements Expression 504 { 505 public String build() 506 { 507 return new StringBuilder() 508 .append(RepositoryConstants.NAMESPACE_PREFIX_INTERNAL).append(':').append(DefaultContent.METADATA_ROOT_OUTGOING_REFERENCES) 509 .append('/').append(RepositoryConstants.NAMESPACE_PREFIX_INTERNAL).append(':').append(DefaultContent.METADATA_OUTGOING_REFERENCES) 510 .append("/*") 511 .append("/@").append(RepositoryConstants.NAMESPACE_PREFIX).append(':').append(DefaultContent.METADATA_OUTGOING_REFERENCE_PROPERTY) 512 .toString(); 513 } 514 } 515 516 /** 517 * Serialize a content consistency result as JSON 518 * @param result the results to serialize 519 * @param columns the list of columns to use as "view" 520 * @return a JSON representation of the result 521 */ 522 public Map<String, Object> resultToJSON(ContentConsistencyResult result, List<Map<String, Object>> columns) 523 { 524 // Column can point either to the result object or the linked content. 525 // We need to separate them before serializing 526 527 Collection< ? extends Model> resultModel = result.getModel(); 528 Model[] resultModelArray = resultModel.toArray(size -> new Model[size]); 529 // initialize the view with the content id. Its convenient and we should 530 // always need this value 531 View resultView = View.of(resultModel, ContentConsistencyResult.CONTENT_ID); 532 533 // Do not fail if the content is unavailable, simply ignore the content column 534 Content content = null; 535 Collection<? extends Model> contentModel = null; 536 Model[] contentModelArray = null; 537 View contentView = null; 538 boolean contentViewEmpty = true; 539 try 540 { 541 content = _resolver.resolveById(result.getContentId()); 542 contentModel = content.getModel(); 543 contentModelArray = contentModel.toArray(size -> new Model[size]); 544 contentView = ViewHelper.createEmptyViewItemAccessor(contentModel); 545 } 546 catch (UnknownAmetysObjectException e) 547 { 548 getLogger().warn("Content with id '{}' doesn't exist. Consistency result will be included without content data", result.getContentId(), e); 549 } 550 551 // Separate column in the result view and content view 552 for (Map<String, Object> column : columns) 553 { 554 // if a column is available in the result, use the result value. 555 // else try to use the content 556 String columnItemPath = (String) column.get("id"); 557 if (ModelHelper.hasModelItem(columnItemPath, resultModel)) 558 { 559 ViewHelper.addViewItem(columnItemPath, resultView, resultModelArray); 560 } 561 else if (content != null) 562 { 563 if (ModelHelper.hasModelItem(columnItemPath, contentModel)) 564 { 565 ViewHelper.addViewItem(columnItemPath, contentView, contentModelArray); 566 contentViewEmpty = false; 567 } 568 else 569 { 570 getLogger().info("item path {} is not defined for content {}. It will be ignored in the result.", columnItemPath, content.getId()); 571 } 572 } 573 } 574 575 Map<String, Object> searchResult = result.dataToJSON(resultView); 576 577 // improve serialization for some particular metadata 578 if (resultView.hasModelViewItem(ContentConsistencyResult.WORKFLOW_STEP)) 579 { 580 searchResult.put(ContentConsistencyResult.WORKFLOW_STEP, _workflowStepToJSON(result.getWorkflowStep(), content)); 581 } 582 583 if (resultView.hasModelViewItem(ContentConsistencyResult.CONTENT_TYPES)) 584 { 585 searchResult.put(ContentConsistencyResult.CONTENT_TYPES, _contentTypeToJSON(result.getContentTypes())); 586 } 587 588 if (content != null && !contentViewEmpty) 589 { 590 searchResult.putAll(content.dataToJSON(contentView)); 591 } 592 593 return searchResult; 594 } 595 596 597 private Object _contentTypeToJSON(String[] typeIds) 598 { 599 List<Map<String, Object>> jsonValues = new ArrayList<>(typeIds.length); 600 for (String id : typeIds) 601 { 602 ContentType contentType = _cTypeEP.getExtension(id); 603 Map<String, Object> jsonValue = new HashMap<>(); 604 if (contentType != null) 605 { 606 jsonValue.put("label", contentType.getLabel()); 607 jsonValue.put("smallIcon", contentType.getSmallIcon()); 608 609 String iconGlyph = contentType.getIconGlyph(); 610 if (iconGlyph != null) 611 { 612 jsonValue.put("iconGlyph", iconGlyph); 613 String iconDecorator = contentType.getIconDecorator(); 614 if (iconDecorator != null) 615 { 616 jsonValue.put("iconDecorator", iconDecorator); 617 } 618 } 619 } 620 jsonValues.add(jsonValue); 621 } 622 return jsonValues; 623 } 624 625 private Object _workflowStepToJSON(long value, Content content) 626 { 627 if (content instanceof WorkflowAwareContent) 628 { 629 WorkflowAwareContent waContent = (WorkflowAwareContent) content; 630 int currentStepId = Math.toIntExact(value); 631 632 StepDescriptor stepDescriptor = _workflowHelper.getStepDescriptor(waContent, currentStepId); 633 634 if (stepDescriptor != null) 635 { 636 return _workflowHelper.workflowStep2JSON(stepDescriptor); 637 } 638 } 639 640 return Map.of(); 641 } 642}