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