001/*
002 *  Copyright 2017 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.odf.tree;
017
018import java.text.Normalizer;
019import java.util.ArrayList;
020import java.util.Arrays;
021import java.util.Collection;
022import java.util.Collections;
023import java.util.HashMap;
024import java.util.LinkedHashMap;
025import java.util.List;
026import java.util.Map;
027import java.util.Objects;
028import java.util.Optional;
029import java.util.Set;
030import java.util.TreeMap;
031import java.util.stream.Collectors;
032import java.util.stream.Stream;
033
034import org.apache.avalon.framework.activity.Initializable;
035import org.apache.avalon.framework.component.Component;
036import org.apache.avalon.framework.service.ServiceException;
037import org.apache.avalon.framework.service.ServiceManager;
038import org.apache.avalon.framework.service.Serviceable;
039import org.apache.commons.lang3.StringUtils;
040import org.xml.sax.SAXException;
041
042import org.ametys.cms.contenttype.ContentAttributeDefinition;
043import org.ametys.cms.contenttype.ContentType;
044import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
045import org.ametys.cms.contenttype.ContentTypesHelper;
046import org.ametys.cms.data.ContentDataHelper;
047import org.ametys.cms.data.type.ModelItemTypeConstants;
048import org.ametys.cms.model.ContentElementDefinition;
049import org.ametys.cms.repository.Content;
050import org.ametys.cms.repository.ContentQueryHelper;
051import org.ametys.cms.repository.ContentTypeExpression;
052import org.ametys.cms.repository.LanguageExpression;
053import org.ametys.core.cache.AbstractCacheManager;
054import org.ametys.core.cache.Cache;
055import org.ametys.core.util.I18nUtils;
056import org.ametys.core.util.LambdaUtils;
057import org.ametys.odf.ProgramItem;
058import org.ametys.odf.catalog.Catalog;
059import org.ametys.odf.catalog.CatalogsManager;
060import org.ametys.odf.enumeration.OdfReferenceTableEntry;
061import org.ametys.odf.enumeration.OdfReferenceTableHelper;
062import org.ametys.odf.orgunit.OrgUnit;
063import org.ametys.odf.orgunit.OrgUnitFactory;
064import org.ametys.odf.orgunit.RootOrgUnitProvider;
065import org.ametys.odf.program.Program;
066import org.ametys.odf.program.ProgramFactory;
067import org.ametys.plugins.core.impl.cache.AbstractCacheKey;
068import org.ametys.plugins.repository.AmetysObject;
069import org.ametys.plugins.repository.AmetysObjectIterable;
070import org.ametys.plugins.repository.AmetysObjectResolver;
071import org.ametys.plugins.repository.UnknownAmetysObjectException;
072import org.ametys.plugins.repository.model.RepeaterDefinition;
073import org.ametys.plugins.repository.provider.WorkspaceSelector;
074import org.ametys.plugins.repository.query.QueryHelper;
075import org.ametys.plugins.repository.query.SortCriteria;
076import org.ametys.plugins.repository.query.expression.AndExpression;
077import org.ametys.plugins.repository.query.expression.Expression;
078import org.ametys.plugins.repository.query.expression.Expression.Operator;
079import org.ametys.plugins.repository.query.expression.StringExpression;
080import org.ametys.runtime.i18n.I18nizableText;
081import org.ametys.runtime.model.ElementDefinition;
082import org.ametys.runtime.model.Enumerator;
083import org.ametys.runtime.model.ModelItem;
084import org.ametys.runtime.model.ModelItemContainer;
085import org.ametys.runtime.plugin.component.AbstractLogEnabled;
086
087import com.google.common.collect.Maps;
088
089/**
090 * Component providing methods to retrieve ODF virtual pages, such as the ODF root,
091 * level 1 and 2 metadata names, and so on.
092 */
093public class OdfClassificationHandler extends AbstractLogEnabled implements Component, Initializable, Serviceable
094{
095    /** The avalon role. */
096    public static final String ROLE = OdfClassificationHandler.class.getName();
097    
098    /** First level attribute name. */
099    public static final String LEVEL1_ATTRIBUTE_NAME = "firstLevel";
100    
101    /** Second level attribute name. */
102    public static final String LEVEL2_ATTRIBUTE_NAME = "secondLevel";
103    
104    /** Catalog attribute name. */
105    public static final String CATALOG_ATTRIBUTE_NAME = "odf-root-catalog";
106    
107    /** Constant for the {@link Cache} id for the {@link LevelValue}s objects in cache by {@link LevelValuesCacheKey}. */
108    public static final String LEVEL_VALUES_CACHE = OdfClassificationHandler.class.getName() + "$LevelValues";
109    
110    /** The default level 1 attribute. */
111    protected static final String DEFAULT_LEVEL1_ATTRIBUTE = "degree";
112    
113    /** The default level 2 attribute. */
114    protected static final String DEFAULT_LEVEL2_ATTRIBUTE = "domain";
115    
116    /** Content types that are not eligible for first and second level */
117    // See ODF-1115 Exclude the mentions enumerator from the list : 
118    protected static final List<String> NON_ELIGIBLE_CTYPES_FOR_LEVEL = Arrays.asList("org.ametys.plugins.odf.Content.programItem", "odf-enumeration.Mention");
119    
120    /** The ametys object resolver. */
121    protected AmetysObjectResolver _resolver;
122    
123    /** The i18n utils. */
124    protected I18nUtils _i18nUtils;
125    
126    /** The content type extension point. */
127    protected ContentTypeExtensionPoint _cTypeEP;
128    
129    /** The ODF Catalog enumeration */
130    protected CatalogsManager _catalogsManager;
131    
132    /** The workspace selector. */
133    protected WorkspaceSelector _workspaceSelector;
134    
135    /** Avalon service manager */
136    protected ServiceManager _manager;
137    
138    /** Content types helper */
139    protected ContentTypesHelper _contentTypesHelper;
140    
141    /** Odf reference table helper */
142    protected OdfReferenceTableHelper _odfReferenceTableHelper;
143    
144    /** Root orgunit provider */
145    protected RootOrgUnitProvider _orgUnitProvider;
146    
147    /** The cache manager */
148    protected AbstractCacheManager _cacheManager;
149    
150    @Override
151    public void service(ServiceManager serviceManager) throws ServiceException
152    {
153        _manager = serviceManager;
154        _resolver = (AmetysObjectResolver) serviceManager.lookup(AmetysObjectResolver.ROLE);
155        _i18nUtils = (I18nUtils) serviceManager.lookup(I18nUtils.ROLE);
156        _cTypeEP = (ContentTypeExtensionPoint) serviceManager.lookup(ContentTypeExtensionPoint.ROLE);
157        _workspaceSelector = (WorkspaceSelector) serviceManager.lookup(WorkspaceSelector.ROLE);
158        _catalogsManager = (CatalogsManager) serviceManager.lookup(CatalogsManager.ROLE);
159        _contentTypesHelper = (ContentTypesHelper) serviceManager.lookup(ContentTypesHelper.ROLE);
160        _odfReferenceTableHelper = (OdfReferenceTableHelper) serviceManager.lookup(OdfReferenceTableHelper.ROLE);
161        _orgUnitProvider = (RootOrgUnitProvider) serviceManager.lookup(RootOrgUnitProvider.ROLE);
162        _cacheManager = (AbstractCacheManager) serviceManager.lookup(AbstractCacheManager.ROLE);
163    }
164    
165    @Override
166    public void initialize() throws Exception
167    {
168        _cacheManager.createMemoryCache(LEVEL_VALUES_CACHE, 
169                new I18nizableText("plugin.odf", "PLUGINS_ODF_CACHE_LEVEL_VALUES_LABEL"),
170                new I18nizableText("plugin.odf", "PLUGINS_ODF_CACHE_LEVEL_VALUES_DESC"),
171                true,
172                null);
173    }
174    
175    /**
176     * Get the ODF catalogs
177     * @return the ODF catalogs
178     */
179    public Map<String, I18nizableText> getCatalogs ()
180    {
181        Map<String, I18nizableText> catalogs = new HashMap<>();
182        
183        for (Catalog catalog : _catalogsManager.getCatalogs())
184        {
185            catalogs.put(catalog.getName(), new I18nizableText(catalog.getTitle()));
186        }
187        
188        return catalogs;
189    }
190    
191    /**
192     * True if the program attribute is eligible
193     * @param attributePath the attribute path
194     * @param allowMultiple true is we allow multiple attribute
195     * @return true if the program attribute is eligible
196     */
197    public boolean isEligibleMetadataForLevel(String attributePath, boolean allowMultiple)
198    {
199        ContentType cType = _cTypeEP.getExtension(ProgramFactory.PROGRAM_CONTENT_TYPE);
200        
201        if (cType.hasModelItem(attributePath))
202        {
203            ModelItem modelItem = cType.getModelItem(attributePath);
204            return _isModelItemEligible(modelItem, allowMultiple);
205        }
206        else
207        {
208            return false;
209        }
210    }
211    
212    /**
213     * Get the eligible enumerated attributes for ODF page level
214     * @return the eligible attributes
215     */
216    public Map<String, ModelItem> getEligibleAttributesForLevel()
217    {
218        return getEnumeratedAttributes(ProgramFactory.PROGRAM_CONTENT_TYPE, false);
219    }
220    
221    /**
222     * Get the enumerated attribute definitions for the given content type.
223     * Attribute with enumerator or content attribute are considered as enumerated
224     * @param programContentTypeId The content type's id 
225     * @param allowMultiple <code>true</code> true to allow multiple attribute
226     * @return The definitions of enumerated attributes
227     */
228    public Map<String, ModelItem> getEnumeratedAttributes(String programContentTypeId, boolean allowMultiple)
229    {
230        ContentType cType = _cTypeEP.getExtension(programContentTypeId);
231        
232        return _collectEligibleChildAttribute(cType, allowMultiple)
233                    .collect(LambdaUtils.Collectors.toLinkedHashMap(Map.Entry::getKey, Map.Entry::getValue));
234    }
235    
236    private Stream<Map.Entry<String, ModelItem>> _collectEligibleChildAttribute(ModelItemContainer modelItemContainer, boolean allowMultiple)
237    {
238        // repeaters are not supported
239        if (modelItemContainer instanceof RepeaterDefinition)
240        {
241            return Stream.empty();
242        }
243        
244        return modelItemContainer.getModelItems().stream()
245            .flatMap(modelItem ->
246            {
247                if (_isModelItemEligible(modelItem, allowMultiple))
248                {
249                    return Stream.of(Maps.immutableEntry(modelItem.getPath(), modelItem));
250                }
251                else if (modelItem instanceof ModelItemContainer)
252                {
253                    return _collectEligibleChildAttribute((ModelItemContainer) modelItem, allowMultiple);
254                }
255                else
256                {
257                    return Stream.empty();
258                }
259            });
260    }
261    
262    @SuppressWarnings("static-access")
263    private boolean _isModelItemEligible(ModelItem modelItem, boolean allowMultiple)
264    {
265        if (!(modelItem instanceof ElementDefinition))
266        {
267            return false;
268        }
269        
270        ElementDefinition elementDefinition = (ElementDefinition) modelItem;
271        if (elementDefinition.isMultiple() && !allowMultiple)
272        {
273            return false;
274        }
275        
276        
277        String typeId = elementDefinition.getType().getId();
278        if (elementDefinition instanceof ContentElementDefinition contentElementDefinition)
279        {
280            String contentTypeId = contentElementDefinition.getContentTypeId();
281            
282            Stream<String> selfAndAncestors = Stream.concat(
283                Stream.of(contentTypeId),
284                _contentTypesHelper.getAncestors(contentTypeId).stream()
285            );
286            
287            return selfAndAncestors.noneMatch(NON_ELIGIBLE_CTYPES_FOR_LEVEL::contains);
288        }
289        else if (ModelItemTypeConstants.STRING_TYPE_ID.equals(typeId))
290        {
291            return elementDefinition.getEnumerator() != null && !elementDefinition.getName().startsWith("dc_");
292        }
293        else
294        {
295            return false;
296        }
297    }
298    
299    /**
300     * Get the level value of a program by extracting and transforming the raw program value at the desired metadata path
301     * @param program The program
302     * @param levelMetaPath The desired metadata path that represent a level
303     * @return The list of final level values
304     */
305    public List<String> getProgramLevelValues(Program program, String levelMetaPath)
306    {
307        List<String> rawValues = getProgramLevelRawValues(program, levelMetaPath);
308        return rawValues.stream()
309                .map(e -> _convertRawValue2LevelValue(levelMetaPath, e))
310                .filter(Objects::nonNull)
311                .collect(Collectors.toList());
312    }
313    
314    /**
315     * Get the level value of a program by extracting and transforming the raw program value at the desired attribute path
316     * @param program The program
317     * @param levelAttributePath The desired attribute path that represent a level
318     * @return The list of level raw value
319     */
320    public List<String> getProgramLevelRawValues(Program program, String levelAttributePath)
321    {
322        String typeId = program.getType(levelAttributePath).getId();
323    
324        if (ModelItemTypeConstants.CONTENT_ELEMENT_TYPE_ID.equals(typeId))
325        {
326            if (program.isMultiple(levelAttributePath))
327            {
328                return ContentDataHelper.getContentIdsListFromMultipleContentData(program, levelAttributePath);
329            }
330            else
331            {
332                return Collections.singletonList(ContentDataHelper.getContentIdFromContentData(program, levelAttributePath));
333            }
334        }
335        else if (org.ametys.runtime.model.type.ModelItemTypeConstants.STRING_TYPE_ID.equals(typeId))
336        {
337            if (program.isMultiple(levelAttributePath))
338            {
339                return Arrays.asList(program.getValue(levelAttributePath));
340            }
341            else
342            {
343                return Collections.singletonList(program.getValue(levelAttributePath));
344            }
345        }
346        else
347        {
348            throw new IllegalArgumentException("The attribute at path '" + levelAttributePath + "' is not an eligible attribute for level");
349        }
350    }
351    
352    /**
353     * Convert the attribute raw value into a level value
354     * @param attributePath The path of the attribute corresponding to the level 
355     * @param rawLevelValue The raw level value
356     * @return the converted value or <code>null</code> if there is no level value for this raw value
357     */
358    protected String _convertRawValue2LevelValue(String attributePath, String rawLevelValue)
359    {
360        // FIXME a raw <=> level value cache would be useful, but need a specific cache management strategy
361        
362        String levelValue = rawLevelValue;
363        
364        ContentType programCType = _cTypeEP.getExtension(ProgramFactory.PROGRAM_CONTENT_TYPE);
365        ModelItem modelItem = programCType.getModelItem(attributePath);
366        
367        String attributeContentTypeId = null;
368        if (modelItem instanceof ContentAttributeDefinition)
369        {
370            attributeContentTypeId = ((ContentAttributeDefinition) modelItem).getContentTypeId();
371        }
372        
373        if (StringUtils.isNotEmpty(attributeContentTypeId))
374        {
375            // Odf reference table
376            if (_odfReferenceTableHelper.isTableReference(attributeContentTypeId))
377            {
378                levelValue = _convertRaw2LevelForRefTable(rawLevelValue);
379            }
380            // Orgunit
381            else if (OrgUnitFactory.ORGUNIT_CONTENT_TYPE.equals(attributeContentTypeId))
382            {
383                levelValue = _convertRaw2LevelForOrgUnit(rawLevelValue);
384            }
385            // Other content
386            else
387            {
388                levelValue = _convertRaw2LevelForContent(rawLevelValue);
389            }
390        }
391        
392        return StringUtils.defaultIfEmpty(levelValue, null);
393    }
394
395    private String _convertRaw2LevelForRefTable(String contentId)
396    {
397        return _odfReferenceTableHelper.getItemCode(contentId);
398    }
399    
400    private String _convertRaw2LevelForOrgUnit(String orgUnitId)
401    {
402        try
403        {
404            OrgUnit orgUnit = _resolver.resolveById(orgUnitId);
405            return orgUnit.getUAICode();
406        }
407        catch (UnknownAmetysObjectException e)
408        {
409            getLogger().warn("Unable to get level value for orgunit with id '{}'.", orgUnitId, e);
410            return "";
411        }
412    }
413    
414    private String _convertRaw2LevelForContent(String contentId)
415    {
416        try
417        {
418//            Content content = _resolver.resolveById(contentId);
419            // FIXME name might not be unique between sites, languages, content without site etc...
420            // return content.getName();
421            return contentId;
422        }
423        catch (UnknownAmetysObjectException e)
424        {
425            getLogger().warn("Unable to get level value for content with id '{}'.", contentId, e);
426            return "";
427        }
428    }
429    
430    private String _convertLevel2RawForRefTable(String metaContentType, String levelValue)
431    {
432        return Optional.ofNullable(_odfReferenceTableHelper.getItemFromCode(metaContentType, levelValue))
433                .map(OdfReferenceTableEntry::getId)
434                .orElse(null);
435    }
436    
437    private String _convertLevel2RawForContent(String levelValue)
438    {
439        // must return the content id
440        // FIXME currently the level value is the content id (see #_convertRaw2LevelForContent)
441        return levelValue;
442    }
443    
444    /**
445     * Clear the cache of available values for levels used for ODF virtual pages
446     */
447    public void clearLevelValues()
448    {
449        _getLevelValuesCache().invalidateAll();
450    }
451    
452    /**
453     * Clear the cache  of available values for level 
454     * @param lang the language. Can be null to clear values for all languages
455     * @param metadataPath the path of level's metadata
456     */
457    public void clearLevelValues(String metadataPath, String lang)
458    {
459        Cache<LevelValuesCacheKey, Map<String, LevelValue>> levelValuesCache = _getLevelValuesCache();
460        
461        LevelValuesCacheKey levelValuesKey = LevelValuesCacheKey.of(metadataPath, lang, null);
462        levelValuesCache.invalidate(levelValuesKey);
463    }
464
465    /**
466     * Get the first level metadata values (with translated label).
467     * @param metadata Metadata of first level
468     * @param language Lang to get
469     * @return the first level metadata values.
470     */
471    public Map<String, LevelValue> getLevelValues(String metadata, String language)
472    {
473        Cache<LevelValuesCacheKey, Map<String, LevelValue>> levelValuesCache = _getLevelValuesCache();
474        
475        String workspace = _workspaceSelector.getWorkspace();
476        LevelValuesCacheKey levelValuesKey = LevelValuesCacheKey.of(metadata, language, workspace);
477        
478        return levelValuesCache.get(levelValuesKey, __ -> _getLevelValues(metadata, language));
479    }
480
481    /**
482     * Encode level value to be use into a URI.
483     * Double-encode characters ':', '-' and '/'.
484     * @param value The raw value
485     * @return the encoded value
486     */
487    public String encodeLevelValue(String value)
488    {
489        String encodedValue = StringUtils.replace(value, "-", "@2D");
490        encodedValue = StringUtils.replace(encodedValue, "/", "@2F");
491        encodedValue = StringUtils.replace(encodedValue, ":", "@3A");
492        return encodedValue;
493    }
494    
495    /**
496     * Decode level value used in a URI
497     * @param value The encoded value
498     * @return the decoded value
499     */
500    public String decodeLevelValue(String value)
501    {
502        String decodedValue = StringUtils.replace(value, "@2F", "/");
503        decodedValue = StringUtils.replace(decodedValue, "@3A", ":");
504        return StringUtils.replace(decodedValue, "@2D", "-");
505    }
506    
507    /**
508     * Get the available values of a program's attribute to be used as a level in the virtual ODF page hierarchy.
509     * @param attributePath the attribute path.
510     * @param language the language.
511     * @return the available attribute values.
512     */
513    private Map<String, LevelValue> _getLevelValues(String attributePath, String language)
514    {
515        try
516        {
517            ContentType programCType = _cTypeEP.getExtension(ProgramFactory.PROGRAM_CONTENT_TYPE);
518            ModelItem modelItem = programCType.getModelItem(attributePath);
519            
520            String attributeContentTypeId = null;
521            if (modelItem instanceof ContentAttributeDefinition)
522            {
523                attributeContentTypeId = ((ContentAttributeDefinition) modelItem).getContentTypeId();
524            }
525            
526            if (StringUtils.isNotEmpty(attributeContentTypeId))
527            {
528                // Odf reference table
529                if (_odfReferenceTableHelper.isTableReference(attributeContentTypeId))
530                {
531                    return _getLevelValuesForRefTable(attributeContentTypeId, language);
532                }
533                // Orgunit
534                else if (OrgUnitFactory.ORGUNIT_CONTENT_TYPE.equals(attributeContentTypeId))
535                {
536                    return _getLevelValuesForOrgUnits();
537                }
538                // Other content
539                else
540                {
541                    return _getLevelValuesForContentType(attributeContentTypeId, language);
542                }
543            }
544            
545            Enumerator<?> enumerator = null;
546            if (modelItem instanceof ElementDefinition)
547            {
548                enumerator = ((ElementDefinition) modelItem).getEnumerator();
549            }
550            
551            if (enumerator != null)
552            {
553                return _getLevelValuesForEnumerator(language, enumerator);
554            }
555        }
556        catch (Exception e)
557        {
558            // Log and return empty map.
559            getLogger().error("Error retrieving values for metadata {} in language {}", attributePath, language, e);
560        }
561        
562        return Maps.newHashMap();
563    }
564    
565    private Map<String, LevelValue> _getLevelValuesForRefTable(String metaContentType, String language)
566    {
567        Map<String, LevelValue> levelValues = new LinkedHashMap<>();
568        
569        List<OdfReferenceTableEntry> entries = _odfReferenceTableHelper.getItems(metaContentType);
570        for (OdfReferenceTableEntry entry : entries)
571        {
572            if (StringUtils.isEmpty(entry.getCode()))
573            {
574                getLogger().warn("There is no code for entry {} ({}) of reference table '{}'. It will be ignored for classification", entry.getLabel(language), entry.getId(), metaContentType);
575            }
576            else if (levelValues.containsKey(entry.getCode()))
577            {
578                getLogger().warn("Duplicate key code {} into reference table '{}'. The entry {} ({}) will be ignored for classification", entry.getCode(), metaContentType, entry.getLabel(language), entry.getId());
579            }
580            else
581            {
582                LevelValue levelValue = new LevelValue(
583                                            entry.getLabel(language), 
584                                            entry.getOrder()
585                                        );
586                levelValues.put(entry.getCode(), levelValue);
587            }
588        }
589        
590        return levelValues;
591    }
592    
593    private Map<String, LevelValue> _getLevelValuesForOrgUnits()
594    {
595        String rootOrgUnitId = _orgUnitProvider.getRootId();
596        Set<String> childOrgUnitIds = _orgUnitProvider.getChildOrgUnitIds(rootOrgUnitId, true);
597        
598        Map<String, LevelValue> levelValues = new LinkedHashMap<>();
599        
600        for (String childOUId : childOrgUnitIds)
601        {
602            OrgUnit childOU = _resolver.resolveById(childOUId);
603            if (StringUtils.isEmpty(childOU.getUAICode()))
604            {
605                getLogger().warn("There is no UAI code for orgunit {} ({}). It will be ignored for classification", childOU.getTitle(), childOU.getId());
606            }
607            else if (levelValues.containsKey(childOU.getUAICode()))
608            {
609                getLogger().warn("Duplicate UAI code {}. The orgunit {} ({}) will be ignored for classification", childOU.getUAICode(), childOU.getTitle(), childOU.getId());
610            }
611            else
612            {
613                levelValues.put(childOU.getUAICode(), _convertToLevelValue(childOU.getTitle()));
614            }
615        }
616        return levelValues;
617    }
618    
619    private Map<String, LevelValue> _getLevelValuesForContentType(String metaContentType, String language)
620    {
621        Expression expr = new AndExpression(
622            new ContentTypeExpression(Operator.EQ, metaContentType),
623            new LanguageExpression(Operator.EQ, language)
624        );
625        
626        String xpathQuery = ContentQueryHelper.getContentXPathQuery(expr);
627        
628        return _resolver.<Content>query(xpathQuery).stream()
629            .collect(LambdaUtils.Collectors.toLinkedHashMap(Content::getId, c -> _convertToLevelValue(c.getTitle())));
630    }
631    
632    private <T extends Object> Map<String, LevelValue> _getLevelValuesForEnumerator(String language,  Enumerator<T> enumerator) throws Exception
633    {
634        return enumerator.getTypedEntries().entrySet().stream()
635            .filter(entry -> StringUtils.isNotEmpty(entry.getKey().toString()))
636            .map(entry ->
637            {
638                String code = entry.getKey().toString();
639                
640                I18nizableText label = entry.getValue();
641                String itemLabel = _i18nUtils.translate(label, language);
642                
643                return Maps.immutableEntry(code, itemLabel);
644            })
645            .collect(LambdaUtils.Collectors.toLinkedHashMap(Map.Entry::getKey, e -> _convertToLevelValue(e.getValue())));
646    }
647    
648    /**
649     * Get a collection of programs corresponding to following parameters.
650     * @param catalog Name of the catalog. Can be null to get all programs matching other arguments.
651     * @param lang the content language. Can not be null.
652     * @param level1MetaPath Having a non-empty value for the metadata path
653     * @param level1 If this parameter is not null or empty and level1MetaPath too, we filter programs by the metadata level1MetaPath value of level1
654     * @param level2MetaPath Having a non-empty value for the metadata path
655     * @param level2 If this parameter is not null or empty and level2MetaPath too, we filter programs by the metadata level2MetaPath value of level2
656     * @param programCode The program's code. Can be null to get all programs matching other arguments.
657     * @param programName The program's name. Can be null to get all programs matching other arguments.
658     * @param additionalExpressions Additional expressions to add to search
659     * @return A collection of programs
660     */
661    public AmetysObjectIterable<Program> getPrograms(String catalog, String lang, String level1MetaPath, String level1, String level2MetaPath, String level2, String programCode, String programName, Collection<Expression> additionalExpressions)
662    {
663        List<Expression> exprs = new ArrayList<>();
664
665        exprs.add(new ContentTypeExpression(Operator.EQ, ProgramFactory.PROGRAM_CONTENT_TYPE));
666        exprs.add(new LanguageExpression(Operator.EQ, lang));
667
668        /* Level 1 */
669        if (StringUtils.isNotEmpty(level1))
670        {
671            exprs.add(new StringExpression(level1MetaPath, Operator.EQ, _convertLevelValue2RawValue(lang, level1MetaPath, level1)));
672        }
673        else if (StringUtils.isNotBlank(level1MetaPath))
674        {
675            exprs.add(new StringExpression(level1MetaPath, Operator.NE, ""));
676        }
677
678        /* Level 2 */
679        if (StringUtils.isNotEmpty(level2))
680        {
681            exprs.add(new StringExpression(level2MetaPath, Operator.EQ, _convertLevelValue2RawValue(lang, level2MetaPath, level2)));
682        }
683        else if (StringUtils.isNotBlank(level2MetaPath))
684        {
685            exprs.add(new StringExpression(level2MetaPath, Operator.NE, ""));
686        }
687        
688        if (catalog != null)
689        {
690            exprs.add(new StringExpression(ProgramItem.CATALOG, Operator.EQ, catalog));
691        }
692        
693        if (StringUtils.isNotEmpty(programCode))
694        {
695            exprs.add(new StringExpression(ProgramItem.CODE, Operator.EQ, programCode));
696        }
697        
698        if (additionalExpressions != null)
699        {
700            exprs.addAll(additionalExpressions);
701        }
702        
703        SortCriteria sortCriteria = new SortCriteria();
704        sortCriteria.addCriterion(Content.ATTRIBUTE_TITLE, true, true);
705        
706        Expression contentExpression = new AndExpression(exprs.toArray(new Expression[exprs.size()]));
707        
708        String xPathQuery = QueryHelper.getXPathQuery(StringUtils.defaultIfEmpty(programName, null), "ametys:content", contentExpression, sortCriteria);
709        return _resolver.query(xPathQuery);
710    }
711
712    /**
713     * Get the orgunit identifier given an uai code
714     * @param lang Language
715     * @param uaiCode The uai code
716     * @return The orgunit id or null if not found
717     */
718    public String getOrgunitIdFromUaiCode(String lang, String uaiCode)
719    {
720        Expression ouExpression = new AndExpression(
721            new ContentTypeExpression(Operator.EQ, OrgUnitFactory.ORGUNIT_CONTENT_TYPE),
722            new LanguageExpression(Operator.EQ, lang),
723            new StringExpression(OrgUnit.CODE_UAI, Operator.EQ, uaiCode)
724        );
725        
726        String query = ContentQueryHelper.getContentXPathQuery(ouExpression);
727        return _resolver.query(query).stream().findFirst().map(AmetysObject::getId).orElse(null);
728    }
729
730    /**
731     * Convert a level value to the raw value
732     * @param lang The language
733     * @param levelAttribute The name of attribute holding the level
734     * @param levelValue The level value
735     * @return The raw value
736     */
737    private String _convertLevelValue2RawValue(String lang, String levelAttribute, String levelValue)
738    {
739        // FIXME a raw <=> level value cache would be useful, but need a specific cache management strategy
740        
741        String rawValue = null;
742        
743        ContentType programCType = _cTypeEP.getExtension(ProgramFactory.PROGRAM_CONTENT_TYPE);
744        ModelItem modelItem = programCType.getModelItem(levelAttribute);
745        
746        String attributeContentTypeId = null;
747        if (modelItem instanceof ContentAttributeDefinition)
748        {
749            attributeContentTypeId = ((ContentAttributeDefinition) modelItem).getContentTypeId();
750        }
751        
752        if (StringUtils.isNotEmpty(attributeContentTypeId))
753        {
754            if (_odfReferenceTableHelper.isTableReference(attributeContentTypeId))
755            {
756                rawValue = _convertLevel2RawForRefTable(attributeContentTypeId, levelValue);
757            }
758            // Orgunit
759            else if (OrgUnitFactory.ORGUNIT_CONTENT_TYPE.equals(attributeContentTypeId))
760            {
761                rawValue = _convertLevel2RawForOrgUnit(lang, levelValue);
762            }
763            // Other content
764            else
765            {
766                rawValue = _convertLevel2RawForContent(levelValue);
767            }
768        }
769        
770        return StringUtils.defaultIfEmpty(rawValue, levelValue);
771    }
772    
773    private String _convertLevel2RawForOrgUnit(String lang, String levelValue)
774    {
775        return getOrgunitIdFromUaiCode(lang, levelValue);
776    }
777    
778    /**
779     * Organize passed programs by levels into a Map.
780     * @param programs Programs to organize
781     * @param level1 Name of the metadata of first level
782     * @param level2 Name of the metadata of second level
783     * @return A Map of Map with a Collection of programs which representing the organization of programs by levels.
784     * @throws SAXException if an error occured
785     */
786    public Map<String, Map<String, Collection<Program>>> organizeProgramsByLevels(AmetysObjectIterable<Program> programs, String level1, String level2) throws SAXException
787    {
788        Map<String, Map<String, Collection<Program>>> level1Map = new TreeMap<>();
789        
790        for (Program program : programs)
791        {
792            List<String> programL1RawValues = getProgramLevelRawValues(program, level1);
793            List<String> programL2RawValues = getProgramLevelRawValues(program, level2);
794            for (String programL1Value : programL1RawValues)
795            {
796                if (StringUtils.isNotEmpty(programL1Value))
797                {
798                    Map<String, Collection<Program>> level2Map = level1Map.computeIfAbsent(programL1Value, x -> new TreeMap<>());
799                    for (String programL2Value : programL2RawValues)
800                    {
801                        if (StringUtils.isNotEmpty(programL2Value))
802                        {
803                            Collection<Program> programCache = level2Map.computeIfAbsent(programL2Value, x -> new ArrayList<>());
804                            programCache.add(program);
805                        }
806                    }
807                }
808            }
809        }
810        
811        return level1Map;
812    }
813    
814    private LevelValue _convertToLevelValue(String value)
815    {
816        return new LevelValue(value, Long.MAX_VALUE);
817    }
818    
819    /**
820     * Wrapper object for a level value
821     */
822    public static class LevelValue
823    {
824        private String _value;
825        private Long _order;
826        
827        /**
828         * The constructor
829         * @param value the value
830         * @param order the order
831         */
832        public LevelValue(String value, Long order)
833        {
834            _value = value;
835            _order = order;
836        }
837        
838        /**
839         * Get the value
840         * @return the value
841         */
842        public String getValue()
843        {
844            return _value;
845        }
846        
847        /**
848         * Get the order
849         * @return the order
850         */
851        public Long getOrder()
852        {
853            return _order;
854        }
855        
856        /**
857         * Compare to a level value depends of the order first then the value
858         * @param levelValue the level value to compare
859         * @return the int value of the comparaison
860         */
861        public int compareTo(LevelValue levelValue)
862        {
863            if (_order.equals(levelValue.getOrder()))
864            {
865                String value1 = Normalizer.normalize(_value, Normalizer.Form.NFD).replaceAll("[\\p{InCombiningDiacriticalMarks}]", "");
866                String value2 = Normalizer.normalize(levelValue.getValue(), Normalizer.Form.NFD).replaceAll("[\\p{InCombiningDiacriticalMarks}]", "");
867                
868                return value1.compareToIgnoreCase(value2);
869            }
870            else
871            {
872                return _order.compareTo(levelValue.getOrder());
873            }
874        }
875    }
876    
877    private Cache<LevelValuesCacheKey, Map<String, LevelValue>> _getLevelValuesCache()
878    {
879        return _cacheManager.get(LEVEL_VALUES_CACHE);
880    }
881    
882    private static final class LevelValuesCacheKey extends AbstractCacheKey
883    {
884        private LevelValuesCacheKey(String attributeName, String lang, String workspace)
885        {
886            super(attributeName, lang, workspace);
887        }
888        
889        static LevelValuesCacheKey of(String attributeName, String lang, String workspace)
890        {
891            return new LevelValuesCacheKey(attributeName, lang, workspace);
892        }
893    }
894}