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