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