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.util.ArrayList;
019import java.util.Arrays;
020import java.util.Collection;
021import java.util.HashMap;
022import java.util.LinkedHashMap;
023import java.util.List;
024import java.util.Map;
025import java.util.Objects;
026import java.util.Optional;
027import java.util.Set;
028import java.util.TreeMap;
029import java.util.stream.Collectors;
030import java.util.stream.Stream;
031
032import org.apache.avalon.framework.activity.Initializable;
033import org.apache.avalon.framework.component.Component;
034import org.apache.avalon.framework.service.ServiceException;
035import org.apache.avalon.framework.service.ServiceManager;
036import org.apache.avalon.framework.service.Serviceable;
037import org.apache.commons.lang3.StringUtils;
038import org.xml.sax.SAXException;
039
040import org.ametys.cms.content.ContentHelper;
041import org.ametys.cms.contenttype.ContentConstants;
042import org.ametys.cms.contenttype.ContentType;
043import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
044import org.ametys.cms.contenttype.ContentTypesHelper;
045import org.ametys.cms.contenttype.MetadataDefinition;
046import org.ametys.cms.contenttype.MetadataDefinitionHolder;
047import org.ametys.cms.contenttype.MetadataType;
048import org.ametys.cms.contenttype.RepeaterDefinition;
049import org.ametys.cms.repository.Content;
050import org.ametys.cms.repository.ContentLanguageExpression;
051import org.ametys.cms.repository.ContentQueryHelper;
052import org.ametys.cms.repository.ContentTypeExpression;
053import org.ametys.cms.repository.DefaultContent;
054import org.ametys.core.util.CollectorUtils;
055import org.ametys.core.util.I18nUtils;
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.repository.AmetysObject;
067import org.ametys.plugins.repository.AmetysObjectIterable;
068import org.ametys.plugins.repository.AmetysObjectResolver;
069import org.ametys.plugins.repository.UnknownAmetysObjectException;
070import org.ametys.plugins.repository.provider.WorkspaceSelector;
071import org.ametys.plugins.repository.query.QueryHelper;
072import org.ametys.plugins.repository.query.SortCriteria;
073import org.ametys.plugins.repository.query.expression.AndExpression;
074import org.ametys.plugins.repository.query.expression.Expression;
075import org.ametys.plugins.repository.query.expression.Expression.Operator;
076import org.ametys.plugins.repository.query.expression.StringExpression;
077import org.ametys.runtime.i18n.I18nizableText;
078import org.ametys.runtime.parameter.Enumerator;
079import org.ametys.runtime.plugin.component.AbstractLogEnabled;
080
081import com.google.common.collect.Maps;
082
083/**
084 * Component providing methods to retrieve ODF virtual pages, such as the ODF root,
085 * level 1 and 2 metadata names, and so on.
086 */
087public class OdfClassificationHandler extends AbstractLogEnabled implements Component, Initializable, Serviceable
088{
089    /** The avalon role. */
090    public static final String ROLE = OdfClassificationHandler.class.getName();
091    
092    /** First level metadata name. */
093    public static final String LEVEL1_METADATA_NAME = "firstLevel";
094    
095    /** Second level metadata name. */
096    public static final String LEVEL2_METADATA_NAME = "secondLevel";
097    
098    /** Catalog metadata name. */
099    public static final String CATALOG_METADATA_NAME = "odf-root-catalog";
100    
101    /** The default level 1 metadata. */
102    protected static final String DEFAULT_LEVEL1_METADATA = "degree";
103    
104    /** The default level 2 metadata. */
105    protected static final String DEFAULT_LEVEL2_METADATA = "domain";
106    
107    /** Content types that are not eligible for first and second level */
108    // See ODF-1115 Exclude the mentions enumerator from the list : 
109    protected static final List<String> NON_ELIGIBLE_CTYPES_FOR_LEVEL = Arrays.asList("org.ametys.plugins.odf.Content.programItem", "odf-enumeration.Mention");
110    
111    /** The ametys object resolver. */
112    protected AmetysObjectResolver _resolver;
113    
114    /** The i18n utils. */
115    protected I18nUtils _i18nUtils;
116    
117    /** The content type extension point. */
118    protected ContentTypeExtensionPoint _cTypeEP;
119    
120    /** The ODF Catalog enumeration */
121    protected CatalogsManager _catalogsManager;
122    
123    /** Level values cache. */
124    protected Map<String, Map<String, String>> _levelValuesCache;
125    
126    /** The workspace selector. */
127    protected WorkspaceSelector _workspaceSelector;
128    
129    /** Avalon service manager */
130    protected ServiceManager _manager;
131    
132    /** Content types helper */
133    protected ContentTypesHelper _contentTypesHelper;
134    
135    /** Content helper */
136    protected ContentHelper _contentHelper;
137    
138    /** Odf reference table helper */
139    protected OdfReferenceTableHelper _odfReferenceTableHelper;
140    
141    /** Root orgunit provider */
142    protected RootOrgUnitProvider _orgUnitProvider;
143    
144    @Override
145    public void service(ServiceManager serviceManager) throws ServiceException
146    {
147        _manager = serviceManager;
148        _resolver = (AmetysObjectResolver) serviceManager.lookup(AmetysObjectResolver.ROLE);
149        _i18nUtils = (I18nUtils) serviceManager.lookup(I18nUtils.ROLE);
150        _cTypeEP = (ContentTypeExtensionPoint) serviceManager.lookup(ContentTypeExtensionPoint.ROLE);
151        _workspaceSelector = (WorkspaceSelector) serviceManager.lookup(WorkspaceSelector.ROLE);
152        _catalogsManager = (CatalogsManager) serviceManager.lookup(CatalogsManager.ROLE);
153        _contentTypesHelper = (ContentTypesHelper) serviceManager.lookup(ContentTypesHelper.ROLE);
154        _contentHelper = (ContentHelper) serviceManager.lookup(ContentHelper.ROLE);
155        _odfReferenceTableHelper = (OdfReferenceTableHelper) serviceManager.lookup(OdfReferenceTableHelper.ROLE);
156        _orgUnitProvider = (RootOrgUnitProvider) serviceManager.lookup(RootOrgUnitProvider.ROLE);
157    }
158    
159    @Override
160    public void initialize() throws Exception
161    {
162        _levelValuesCache = new HashMap<>();
163    }
164    
165    /**
166     * Get the ODF catalogs
167     * @return the ODF catalogs
168     */
169    public Map<String, I18nizableText> getCatalogs ()
170    {
171        Map<String, I18nizableText> catalogs = new HashMap<>();
172        
173        for (Catalog catalog : _catalogsManager.getCatalogs())
174        {
175            catalogs.put(catalog.getName(), new I18nizableText(catalog.getTitle()));
176        }
177        
178        return catalogs;
179    }
180    
181    /**
182     * True if the program metadata is eligible
183     * @param metadataPath the metadata path
184     * @param allowMultiple true is we allow multiple metadata
185     * @return true if the program metadata is eligible
186     */
187    public boolean isEligibleMetadataForLevel(String metadataPath, boolean allowMultiple)
188    {
189        ContentType cType = _cTypeEP.getExtension(ProgramFactory.PROGRAM_CONTENT_TYPE);
190        MetadataDefinition metadataDefinition = cType.getMetadataDefinitionByPath(metadataPath);
191        
192        return metadataDefinition != null && _isEligibleMetadata(metadataDefinition, allowMultiple);
193    }
194    
195    /**
196     * Get the eligible enumerated metadata for ODF page level
197     * @return the eligible metadata definition
198     */
199    public Map<String, MetadataDefinition> getEligibleMetadataForLevel()
200    {
201        return getEnumeratedMetadata(ProgramFactory.PROGRAM_CONTENT_TYPE, false);
202    }
203    
204    /**
205     * Get the enumerated metadata definition for the given content type.
206     * Metadata with enumerator or content metadata are considered as enumrated
207     * @param programContentTypeId The content type's id 
208     * @param allowMultiple <code>true</code> true to allow multiple metadata
209     * @return The definition of enumerated metadata
210     */
211    public Map<String, MetadataDefinition> getEnumeratedMetadata(String programContentTypeId, boolean allowMultiple)
212    {
213        ContentType cType = _cTypeEP.getExtension(programContentTypeId);
214        
215        return _collectEligibleChildMetadata(cType, "", allowMultiple).collect(
216            CollectorUtils.toLinkedMap(Map.Entry::getKey, Map.Entry::getValue)
217        );
218    }
219    
220    private Stream<Map.Entry<String, MetadataDefinition>> _collectEligibleChildMetadata(MetadataDefinitionHolder parentMetaDef, String prefix, boolean allowMultiple)
221    {
222        // repeaters are not supported
223        if (parentMetaDef instanceof RepeaterDefinition)
224        {
225            return Stream.empty();
226        }
227        
228        return parentMetaDef.getMetadataNames().stream()
229            .map(metadataName -> parentMetaDef.getMetadataDefinition(metadataName))
230            .flatMap(metaDef ->
231            {
232                String metadataPath = prefix + metaDef.getName();
233                
234                if (MetadataType.COMPOSITE.equals(metaDef.getType()))
235                {
236                    return _collectEligibleChildMetadata(metaDef, metadataPath + ContentConstants.METADATA_PATH_SEPARATOR, allowMultiple);
237                }
238                else if (_isEligibleMetadata(metaDef, allowMultiple))
239                {
240                    return Stream.of(Maps.immutableEntry(metadataPath, metaDef));
241                }
242                
243                return Stream.empty();
244            });
245    }
246        
247    private boolean _isEligibleMetadata(MetadataDefinition metaDef, boolean allowMultiple)
248    {
249        if (metaDef.isMultiple() && !allowMultiple)
250        {
251            return false;
252        }
253        
254        MetadataType type = metaDef.getType();
255        switch (type)
256        {
257            case CONTENT:
258                String contentTypeId = metaDef.getContentType();
259                
260                Stream<String> selfAndAncestors = Stream.concat(
261                    Stream.of(contentTypeId),
262                    _contentTypesHelper.getAncestors(contentTypeId).stream()
263                );
264                
265                return selfAndAncestors.noneMatch(NON_ELIGIBLE_CTYPES_FOR_LEVEL::contains);
266            case STRING:
267                return metaDef.getEnumerator() != null && !metaDef.getId().startsWith("/dc/dc_");
268            default:
269                return false;
270        }
271    }
272    
273    /**
274     * Get the level value of a program by extracting and transforming the raw program value at the desired metadata path
275     * @param program The program
276     * @param levelMetaPath The desired metadata path that represent a level
277     * @return The list of final level values
278     */
279    public List<String> getProgramLevelValues(Program program, String levelMetaPath)
280    {
281        List<String> rawValues = getProgramLevelRawValues(program, levelMetaPath);
282        return rawValues.stream()
283                .map(e -> _convertRawValue2LevelValue(levelMetaPath, e))
284                .filter(Objects::nonNull)
285                .collect(Collectors.toList());
286    }
287    
288    /**
289     * Get the level value of a program by extracting and transforming the raw program value at the desired metadata path
290     * @param program The program
291     * @param levelMetaPath The desired metadata path that represent a level
292     * @return The list of level raw value
293     */
294    public List<String> getProgramLevelRawValues(Program program, String levelMetaPath)
295    {
296        List<Object> values = _contentHelper.getMetadataValues(program, levelMetaPath, null, false, false);
297        return values.stream()
298                .map(e -> (String) e)
299                .collect(Collectors.toList());
300    }
301    
302    /**
303     * Convert the metadata raw value into a level value
304     * @param metadataPath The path of the metadata corresponding to the level 
305     * @param rawLevelValue The raw level value
306     * @return the converted value or <code>null</code> if there is no level value for this raw value
307     */
308    protected String _convertRawValue2LevelValue(String metadataPath, String rawLevelValue)
309    {
310        // FIXME a raw <=> level value cache would be useful, but need a specific cache management strategy
311        
312        String levelValue = null;
313        
314        ContentType programCType = _cTypeEP.getExtension(ProgramFactory.PROGRAM_CONTENT_TYPE);
315        MetadataDefinition metadataDefinition = _contentTypesHelper.getMetadataDefinition(metadataPath, programCType);
316        String metaContentType = metadataDefinition.getContentType();
317        
318        if (StringUtils.isNotEmpty(metaContentType))
319        {
320            // Odf reference table
321            if (_odfReferenceTableHelper.isTableReference(metaContentType))
322            {
323                levelValue = _convertRaw2LevelForRefTable(metaContentType, rawLevelValue);
324            }
325            // Orgunit
326            else if (OrgUnitFactory.ORGUNIT_CONTENT_TYPE.equals(metaContentType))
327            {
328                levelValue = _convertRaw2LevelForOrgUnit(rawLevelValue);
329            }
330            // Other content
331            else
332            {
333                levelValue = _convertRaw2LevelForContent(rawLevelValue);
334            }
335        }
336        
337        return StringUtils.defaultIfEmpty(levelValue, null);
338    }
339
340    private String _convertRaw2LevelForRefTable(String tableRefId, String contentId)
341    {
342        return _odfReferenceTableHelper.getItemCode(tableRefId, contentId);
343    }
344    
345    private String _convertRaw2LevelForOrgUnit(String orgUnitId)
346    {
347        try
348        {
349            OrgUnit orgUnit = _resolver.resolveById(orgUnitId);
350            return orgUnit.getUAICode();
351        }
352        catch (UnknownAmetysObjectException e)
353        {
354            if (getLogger().isWarnEnabled())
355            {
356                getLogger().warn("Unable to get level value for orgunit with id '%s'.", e);
357            }
358            return "";
359        }
360    }
361    
362    private String _convertRaw2LevelForContent(String contentId)
363    {
364        try
365        {
366//            Content content = _resolver.resolveById(contentId);
367            // FIXME name might not be unique between sites, languages, content without site etc...
368            // return content.getName();
369            return contentId;
370        }
371        catch (UnknownAmetysObjectException e)
372        {
373            if (getLogger().isWarnEnabled())
374            {
375                getLogger().warn("Unable to get level value for content with id '%s'.", e);
376            }
377            return "";
378        }
379    }
380    
381    private String _convertLevel2RawForRefTable(String metaContentType, String levelValue)
382    {
383        return Optional.ofNullable(_odfReferenceTableHelper.getItemFromCode(metaContentType, levelValue))
384                .map(OdfReferenceTableEntry::getId)
385                .orElse(null);
386    }
387    
388    private String _convertLevel2RawForContent(String levelValue)
389    {
390        // must return the content id
391        // FIXME currently the level value is the content id (see #_convertRaw2LevelForContent)
392        return levelValue;
393    }
394    
395    /**
396     * Clear the cache of available values for levels used for ODF virtual pages
397     */
398    public void clearLevelValues()
399    {
400        _levelValuesCache.clear();
401    }
402    
403    /**
404     * Clear the cache  of available values for level 
405     * @param lang the language
406     * @param metadataPath the path of level's metadata
407     */
408    public void clearLevelValues(String metadataPath, String lang)
409    {
410        String cacheKey = metadataPath + "/" + lang;
411        if (_levelValuesCache.containsKey(cacheKey))
412        {
413            _levelValuesCache.remove(cacheKey);
414        }
415    }
416
417    /**
418     * Get the first level metadata values (with translated label).
419     * @param metadata Metadata of first level
420     * @param language Lang to get
421     * @return the first level metadata values.
422     */
423    public Map<String, String> getLevelValues(String metadata, String language)
424    {
425        Map<String, String> values;
426        
427        String cacheKey = metadata + "/" + language;
428        
429        if (_levelValuesCache.containsKey(cacheKey))
430        {
431            values = _levelValuesCache.get(cacheKey);
432        }
433        else
434        {
435            values = _getLevelValues(metadata, language);
436            _levelValuesCache.put(cacheKey, values);
437        }
438        
439        return values;
440    }
441
442    /**
443     * Encode level value to be use into a URI.
444     * Double-encode characters ':', '-' and '/'.
445     * @param value The raw value
446     * @return the encoded value
447     */
448    public String encodeLevelValue(String value)
449    {
450        String encodedValue = StringUtils.replace(value, "-", "@2D");
451        encodedValue = StringUtils.replace(encodedValue, "/", "@2F");
452        encodedValue = StringUtils.replace(encodedValue, ":", "@3A");
453        return encodedValue;
454    }
455    
456    /**
457     * Decode level value used in a URI
458     * @param value The encoded value
459     * @return the decoded value
460     */
461    public String decodeLevelValue(String value)
462    {
463        String decodedValue = StringUtils.replace(value, "@2F", "/");
464        decodedValue = StringUtils.replace(decodedValue, "@3A", ":");
465        return StringUtils.replace(decodedValue, "@2D", "-");
466    }
467    
468    /**
469     * Get the available values of a program's metadata to be used as a level in the virtual odf page hierarchy.
470     * @param metadata the metadata path.
471     * @param language the language.
472     * @return the available metadata values.
473     */
474    private Map<String, String> _getLevelValues(String metadata, String language)
475    {
476        try
477        {
478            ContentType programCType = _cTypeEP.getExtension(ProgramFactory.PROGRAM_CONTENT_TYPE);
479            MetadataDefinition metadataDefinition = _contentTypesHelper.getMetadataDefinition(metadata, programCType);
480            
481            String metaContentType = metadataDefinition.getContentType();
482            
483            if (StringUtils.isNotEmpty(metaContentType))
484            {
485                // Odf reference table
486                if (_odfReferenceTableHelper.isTableReference(metaContentType))
487                {
488                    return _getLevelValuesForRefTable(metaContentType, language);
489                }
490                // Orgunit
491                else if (OrgUnitFactory.ORGUNIT_CONTENT_TYPE.equals(metaContentType))
492                {
493                    return _getLevelValuesForOrgUnits();
494                }
495                // Other content
496                else
497                {
498                    return _getLevelValuesForContentType(metaContentType, language);
499                }
500            }
501            
502            Enumerator enumerator = metadataDefinition.getEnumerator();
503            if (enumerator != null)
504            {
505                return _getLevelValuesForEnumerator(language, enumerator);
506            }
507        }
508        catch (Exception e)
509        {
510            // Log and return empty map.
511            getLogger().error("Error retrieving values for metadata " + metadata + " in language " + language, e);
512        }
513        
514        return Maps.newHashMap();
515    }
516    
517    private Map<String, String> _getLevelValuesForRefTable(String metaContentType, String language)
518    {
519        Map<String, String> levelValues = new LinkedHashMap<>();
520        
521        List<OdfReferenceTableEntry> entries = _odfReferenceTableHelper.getItems(metaContentType);
522        for (OdfReferenceTableEntry entry : entries)
523        {
524            if (StringUtils.isEmpty(entry.getCode()))
525            {
526                getLogger().warn("There is no code for entry {} ({}) of reference table '{}'. It will be ignored for classification", entry.getLabel(language), entry.getId(), metaContentType);
527            }
528            else if (levelValues.containsKey(entry.getCode()))
529            {
530                getLogger().warn("Duplicate key code {} into reference table '{}'. The entry {} ({}) will be ignored for classification", entry.getCode(), metaContentType, entry.getLabel(language), entry.getId());
531            }
532            else
533            {
534                levelValues.put(entry.getCode(), entry.getLabel(language));
535            }
536        }
537        
538        return levelValues;
539    }
540    
541    private Map<String, String> _getLevelValuesForOrgUnits()
542    {
543        String rootOrgUnitId = _orgUnitProvider.getRootId();
544        Set<String> childOrgUnitIds = _orgUnitProvider.getChildOrgUnitIds(rootOrgUnitId, true);
545        
546        Map<String, String> levelValues = new LinkedHashMap<>();
547        
548        for (String childOUId : childOrgUnitIds)
549        {
550            OrgUnit childOU = _resolver.resolveById(childOUId);
551            if (StringUtils.isEmpty(childOU.getUAICode()))
552            {
553                getLogger().warn("There is no UAI code for orgunit {} ({}). It will be ignored for classification", childOU.getTitle(), childOU.getId());
554            }
555            else if (levelValues.containsKey(childOU.getUAICode()))
556            {
557                getLogger().warn("Duplicate UAI code {}. The orgunit {} ({}) will be ignored for classification", childOU.getUAICode(), childOU.getTitle(), childOU.getId());
558            }
559            else
560            {
561                levelValues.put(childOU.getUAICode(), childOU.getTitle());
562            }
563        }
564        return levelValues;
565    }
566    
567    private Map<String, String> _getLevelValuesForContentType(String metaContentType, String language)
568    {
569        Expression expr = new AndExpression(
570            new ContentTypeExpression(Operator.EQ, metaContentType),
571            new ContentLanguageExpression(Operator.EQ, language)
572        );
573        
574        String xpathQuery = ContentQueryHelper.getContentXPathQuery(expr);
575        
576        return _resolver.<Content>query(xpathQuery).stream()
577            .collect(CollectorUtils.toLinkedMap(Content::getId, Content::getTitle));
578    }
579    
580    private Map<String, String> _getLevelValuesForEnumerator(String language,  Enumerator enumerator) throws Exception
581    {
582        return enumerator.getEntries().entrySet().stream()
583            .filter(entry -> StringUtils.isNotEmpty(entry.getKey().toString()))
584            .map(entry ->
585            {
586                String code = entry.getKey().toString();
587                
588                I18nizableText label = entry.getValue();
589                String itemLabel = _i18nUtils.translate(label, language);
590                
591                return Maps.immutableEntry(code, itemLabel);
592            })
593            .collect(CollectorUtils.toLinkedMap(Map.Entry::getKey, Map.Entry::getValue));
594    }
595    
596    /**
597     * Get a collection of programs corresponding to following parameters.
598     * @param catalog Name of the catalog
599     * @param lang Language
600     * @param level1MetaPath Having a non-empty value for the metadata path
601     * @param level1 If this parameter is not null or empty and level1MetaPath too, we filter programs by the metadata level1MetaPath value of level1
602     * @param level2MetaPath Having a non-empty value for the metadata path
603     * @param level2 If this parameter is not null or empty and level2MetaPath too, we filter programs by the metadata level2MetaPath value of level2
604     * @param programName The program name
605     * @param additionalExpressions Additional expressions to add to search
606     * @return A collection of programs
607     */
608    public AmetysObjectIterable<Program> getPrograms(String catalog, String lang, String level1MetaPath, String level1, String level2MetaPath, String level2, String programName, Collection<Expression> additionalExpressions)
609    {
610        List<Expression> exprs = new ArrayList<>();
611
612        exprs.add(new ContentTypeExpression(Operator.EQ, ProgramFactory.PROGRAM_CONTENT_TYPE));
613        exprs.add(new ContentLanguageExpression(Operator.EQ, lang));
614
615        /* Level 1 */
616        if (StringUtils.isNotEmpty(level1))
617        {
618            exprs.add(new StringExpression(level1MetaPath, Operator.EQ, _convertLevelValue2RawValue(lang, level1MetaPath, level1)));
619        }
620        else
621        {
622            exprs.add(new StringExpression(level1MetaPath, Operator.NE, ""));
623        }
624
625        /* Level 1 */
626        if (StringUtils.isNotEmpty(level2))
627        {
628            exprs.add(new StringExpression(level2MetaPath, Operator.EQ, _convertLevelValue2RawValue(lang, level2MetaPath, level2)));
629        }
630        else
631        {
632            exprs.add(new StringExpression(level2MetaPath, Operator.NE, ""));
633        }
634        
635        if (catalog != null)
636        {
637            exprs.add(new StringExpression(ProgramItem.METADATA_CATALOG, Operator.EQ, catalog));
638        }
639        
640        if (additionalExpressions != null)
641        {
642            exprs.addAll(additionalExpressions);
643        }
644        
645        SortCriteria sortCriteria = new SortCriteria();
646        sortCriteria.addCriterion(DefaultContent.METADATA_TITLE, true, true);
647        
648        Expression contentExpression = new AndExpression(exprs.toArray(new Expression[exprs.size()]));
649        
650        String xPathQuery = QueryHelper.getXPathQuery(StringUtils.defaultIfEmpty(programName, null), "ametys:content", contentExpression, sortCriteria);
651        return _resolver.query(xPathQuery);
652    }
653
654    /**
655     * Get the orgunit identifier given an uai code
656     * @param lang Language
657     * @param uaiCode The uai code
658     * @return The orgunit id or null if not found
659     */
660    public String getOrgunitIdFromUaiCode(String lang, String uaiCode)
661    {
662        Expression ouExpression = new AndExpression(
663            new ContentTypeExpression(Operator.EQ, OrgUnitFactory.ORGUNIT_CONTENT_TYPE),
664            new ContentLanguageExpression(Operator.EQ, lang),
665            new StringExpression(OrgUnit.METADATA_CODE_UAI, Operator.EQ, uaiCode)
666        );
667        
668        String query = ContentQueryHelper.getContentXPathQuery(ouExpression);
669        return _resolver.query(query).stream().findFirst().map(AmetysObject::getId).orElse(null);
670    }
671
672    /**
673     * Convert a level value to the raw value
674     * @param lang The language
675     * @param levelMeta The name of metadata holding the level
676     * @param levelValue The level value
677     * @return The raw value
678     */
679    private String _convertLevelValue2RawValue(String lang, String levelMeta, String levelValue)
680    {
681        // FIXME a raw <=> level value cache would be useful, but need a specific cache management strategy
682        
683        String rawValue = null;
684        
685        ContentType programCType = _cTypeEP.getExtension(ProgramFactory.PROGRAM_CONTENT_TYPE);
686        MetadataDefinition metadataDefinition = _contentTypesHelper.getMetadataDefinition(levelMeta, programCType);
687        String metaContentType = metadataDefinition.getContentType();
688        
689        if (StringUtils.isNotEmpty(metaContentType))
690        {
691            if (_odfReferenceTableHelper.isTableReference(metaContentType))
692            {
693                rawValue = _convertLevel2RawForRefTable(metaContentType, levelValue);
694            }
695            // Orgunit
696            else if (OrgUnitFactory.ORGUNIT_CONTENT_TYPE.equals(metaContentType))
697            {
698                rawValue = _convertLevel2RawForOrgUnit(lang, levelValue);
699            }
700            // Other content
701            else
702            {
703                rawValue = _convertLevel2RawForContent(levelValue);
704            }
705        }
706        
707        return StringUtils.defaultIfEmpty(rawValue, levelValue);
708    }
709    
710    private String _convertLevel2RawForOrgUnit(String lang, String levelValue)
711    {
712        return getOrgunitIdFromUaiCode(lang, levelValue);
713    }
714    
715    /**
716     * Organize passed programs by levels into a Map.
717     * @param programs Programs to organize
718     * @param level1 Name of the metadata of first level
719     * @param level2 Name of the metadata of second level
720     * @return A Map of Map with a Collection of programs which representing the organization of programs by levels.
721     * @throws SAXException if an error occured
722     */
723    public Map<String, Map<String, Collection<Program>>> organizeProgramsByLevels(AmetysObjectIterable<Program> programs, String level1, String level2) throws SAXException
724    {
725        Map<String, Map<String, Collection<Program>>> level1Map = new TreeMap<>();
726        
727        for (Program program : programs)
728        {
729            List<String> programL1RawValues = getProgramLevelRawValues(program, level1);
730            List<String> programL2RawValues = getProgramLevelRawValues(program, level2);
731            for (String programL1Value : programL1RawValues)
732            {
733                if (StringUtils.isNotEmpty(programL1Value))
734                {
735                    Map<String, Collection<Program>> level2Map = level1Map.computeIfAbsent(programL1Value, x -> new TreeMap<>());
736                    for (String programL2Value : programL2RawValues)
737                    {
738                        if (StringUtils.isNotEmpty(programL2Value))
739                        {
740                            Collection<Program> programCache = level2Map.computeIfAbsent(programL2Value, x -> new ArrayList<>());
741                            programCache.add(program);
742                        }
743                    }
744                }
745            }
746        }
747        
748        return level1Map;
749    }
750}