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}