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