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.plugins.odfsync.apogee.scc;
017
018import java.io.ByteArrayInputStream;
019import java.io.File;
020import java.io.FileInputStream;
021import java.io.IOException;
022import java.io.InputStream;
023import java.math.BigDecimal;
024import java.sql.Clob;
025import java.sql.SQLException;
026import java.util.ArrayList;
027import java.util.Arrays;
028import java.util.Collection;
029import java.util.Date;
030import java.util.HashMap;
031import java.util.HashSet;
032import java.util.LinkedHashMap;
033import java.util.LinkedHashSet;
034import java.util.LinkedList;
035import java.util.List;
036import java.util.Map;
037import java.util.Map.Entry;
038import java.util.Objects;
039import java.util.Optional;
040import java.util.Set;
041import java.util.stream.Collectors;
042import java.util.stream.Stream;
043
044import org.apache.avalon.framework.configuration.Configuration;
045import org.apache.avalon.framework.configuration.ConfigurationException;
046import org.apache.avalon.framework.configuration.DefaultConfigurationBuilder;
047import org.apache.avalon.framework.context.Context;
048import org.apache.avalon.framework.context.ContextException;
049import org.apache.avalon.framework.context.Contextualizable;
050import org.apache.avalon.framework.service.ServiceException;
051import org.apache.avalon.framework.service.ServiceManager;
052import org.apache.cocoon.Constants;
053import org.apache.cocoon.components.ContextHelper;
054import org.apache.cocoon.environment.Request;
055import org.apache.commons.collections4.ListUtils;
056import org.apache.commons.io.IOUtils;
057import org.apache.commons.lang.StringUtils;
058import org.slf4j.Logger;
059
060import org.ametys.cms.content.external.ExternalizableMetadataHelper;
061import org.ametys.cms.content.external.ExternalizableMetadataProvider.ExternalizableMetadataStatus;
062import org.ametys.cms.contenttype.ContentType;
063import org.ametys.cms.data.ContentValue;
064import org.ametys.cms.repository.ModifiableContent;
065import org.ametys.cms.repository.ModifiableDefaultContent;
066import org.ametys.core.util.JSONUtils;
067import org.ametys.odf.ODFHelper;
068import org.ametys.plugins.contentio.ContentImporterHelper;
069import org.ametys.plugins.contentio.synchronize.AbstractSimpleSynchronizableContentsCollection;
070import org.ametys.plugins.contentio.synchronize.SynchronizableContentsCollection;
071import org.ametys.plugins.contentio.synchronize.SynchronizableContentsCollectionDAO;
072import org.ametys.plugins.odfsync.apogee.ApogeeDAO;
073import org.ametys.plugins.odfsync.apogee.scc.impl.OrgUnitSynchronizableContentsCollection;
074import org.ametys.plugins.repository.metadata.ModifiableCompositeMetadata;
075import org.ametys.plugins.repository.metadata.ModifiableRichText;
076import org.ametys.runtime.config.Config;
077import org.ametys.runtime.i18n.I18nizableText;
078import org.ametys.runtime.model.ModelItem;
079import org.ametys.runtime.model.type.ModelItemTypeConstants;
080
081import com.google.common.base.CharMatcher;
082import com.google.common.collect.ImmutableList;
083import com.google.common.collect.ImmutableMap;
084
085/**
086 * Abstract class for Apogee synchronization
087 */
088public abstract class AbstractApogeeSynchronizableContentsCollection extends AbstractSimpleSynchronizableContentsCollection implements Contextualizable, ApogeeSynchronizableContentsCollection
089{
090    /** Name of parameter holding the data source id */
091    public static final String PARAM_DATASOURCE_ID = "datasourceId";
092    /** Name of parameter holding the administrative year */
093    public static final String PARAM_YEAR = "year";
094    /** Name of parameter holding the adding unexisting children parameter  */
095    public static final String PARAM_ADD_UNEXISTING_CHILDREN = "add-unexisting-children";
096    /** Name of parameter holding the field ID column */
097    protected static final String __PARAM_ID_COLUMN = "idColumn";
098    /** Name of parameter holding the fields mapping */
099    protected static final String __PARAM_MAPPING = "mapping";
100    /** Name of parameter into mapping holding the synchronized property */
101    protected static final String __PARAM_MAPPING_SYNCHRO = "synchro";
102    /** Name of parameter into mapping holding the path of metadata */
103    protected static final String __PARAM_MAPPING_METADATA_REF = "metadata-ref";
104    /** Name of parameter into mapping holding the remote attribute */
105    protected static final String __PARAM_MAPPING_ATTRIBUTE = "attribute";
106    /** Name of parameter holding the criteria */
107    protected static final String __PARAM_CRITERIA = "criteria";
108    /** Name of parameter into criteria holding a criterion */
109    protected static final String __PARAM_CRITERIA_CRITERION = "criterion";
110    /** Name of parameter into criterion holding the id */
111    protected static final String __PARAM_CRITERIA_CRITERION_ID = "id";
112    /** Name of parameter into criterion holding the label */
113    protected static final String __PARAM_CRITERIA_CRITERION_LABEL = "label";
114    /** Name of parameter into criterion holding the type */
115    protected static final String __PARAM_CRITERIA_CRITERION_TYPE = "type";
116    /** Name of paramter holding columns */
117    protected static final String __PARAM_COLUMNS = "columns";
118    /** Name of paramter into columns holding column */
119    protected static final String __PARAM_COLUMNS_COLUMN = "column";
120    
121    /** Default language configured for ODF */
122    protected String _odfLang;
123
124    /** Name of the Apogée column which contains the ID */
125    protected String _idColumn;
126    
127    /** Mapping between metadata and columns */
128    protected Map<String, List<String>> _mapping;
129    
130    /** External fields */
131    protected Set<String> _extFields;
132    
133    /** Synchronized fields */
134    protected Set<String> _syncFields;
135    
136    /** Synchronized fields */
137    protected Set<String> _columns;
138    
139    /** Synchronized fields */
140    protected Set<ApogeeCriterion> _criteria;
141    
142    /** Context */
143    protected Context _context;
144    
145    /** The DAO for remote DB Apogee */
146    protected ApogeeDAO _apogeeDAO;
147    
148    /** The JSON utils */
149    protected JSONUtils _jsonUtils;
150    
151    /** SCC DAO */
152    protected SynchronizableContentsCollectionDAO _sccDAO;
153    
154    /** The ODF helper */
155    protected ODFHelper _odfHelper;
156    
157    /** The Apogee SCC helper */
158    protected ApogeeSynchronizableContentsCollectionHelper _apogeeSCCHelper;
159    
160    @Override
161    public void service(ServiceManager manager) throws ServiceException
162    {
163        super.service(manager);
164        _apogeeDAO = (ApogeeDAO) manager.lookup(ApogeeDAO.ROLE);
165        _jsonUtils = (JSONUtils) manager.lookup(JSONUtils.ROLE);
166        _sccDAO = (SynchronizableContentsCollectionDAO) manager.lookup(SynchronizableContentsCollectionDAO.ROLE);
167        _odfHelper = (ODFHelper) manager.lookup(ODFHelper.ROLE);
168        _apogeeSCCHelper = (ApogeeSynchronizableContentsCollectionHelper) manager.lookup(ApogeeSynchronizableContentsCollectionHelper.ROLE);
169    }
170
171    @Override
172    public void contextualize(Context context) throws ContextException
173    {
174        _context = context;
175    }
176    
177    @Override
178    protected void configureDataSource(Configuration configuration) throws ConfigurationException
179    {
180        @SuppressWarnings("resource")
181        InputStream is = null;
182        _odfLang = Config.getInstance().getValue("odf.programs.lang");
183        try
184        {
185            org.apache.cocoon.environment.Context ctx = (org.apache.cocoon.environment.Context) _context.get(Constants.CONTEXT_ENVIRONMENT_CONTEXT);
186            File apogeeMapping = new File(ctx.getRealPath("/WEB-INF/param/odf/apogee-mapping.xml"));
187            if (!apogeeMapping.isFile())
188            {
189                is = getClass().getResourceAsStream("/org/ametys/plugins/odfsync/apogee/apogee-mapping.xml");
190            }
191            else
192            {
193                is = new FileInputStream(apogeeMapping);
194            }
195            Configuration cfg = new DefaultConfigurationBuilder().build(is);
196            Configuration child = cfg.getChild(getMappingName());
197            if (child != null)
198            {
199                _criteria = new LinkedHashSet<>();
200                _columns = new LinkedHashSet<>();
201                _idColumn = child.getChild(__PARAM_ID_COLUMN).getValue();
202                _mapping = new HashMap<>();
203                _extFields = new HashSet<>();
204                _syncFields = new HashSet<>();
205                String mappingAsString = child.getChild(__PARAM_MAPPING).getValue();
206                _mapping.put(getIdField(), ImmutableList.of(getIdColumn()));
207                if (StringUtils.isNotEmpty(mappingAsString))
208                {
209                    List<Object> mappingAsList = _jsonUtils.convertJsonToList(mappingAsString);
210                    for (Object object : mappingAsList)
211                    {
212                        @SuppressWarnings("unchecked")
213                        Map<String, Object> field = (Map<String, Object>) object;
214                        
215                        String metadataRef = (String) field.get(__PARAM_MAPPING_METADATA_REF);
216                        
217                        String[] attributes = ((String) field.get(__PARAM_MAPPING_ATTRIBUTE)).split(",");
218                        _mapping.put(metadataRef, Arrays.asList(attributes));
219    
220                        boolean isSynchronized = field.containsKey(__PARAM_MAPPING_SYNCHRO) ? (Boolean) field.get(__PARAM_MAPPING_SYNCHRO) : false;
221                        if (isSynchronized)
222                        {
223                            _syncFields.add(metadataRef);
224                        }
225                        else
226                        {
227                            _extFields.add(metadataRef);
228                        }
229                    }
230                }
231
232                Configuration[] criteria = child.getChild(__PARAM_CRITERIA).getChildren(__PARAM_CRITERIA_CRITERION);
233                for (Configuration criterion : criteria)
234                {
235                    String id = criterion.getChild(__PARAM_CRITERIA_CRITERION_ID).getValue();
236                    I18nizableText label = _getCriterionLabel(criterion.getChild(__PARAM_CRITERIA_CRITERION_LABEL), id);
237                    String type = criterion.getChild(__PARAM_CRITERIA_CRITERION_TYPE).getValue("STRING");
238                    
239                    _criteria.add(new ApogeeCriterion(id, label, type));
240                }
241
242                Configuration[] columns = child.getChild(__PARAM_COLUMNS).getChildren(__PARAM_COLUMNS_COLUMN);
243                for (Configuration column : columns)
244                {
245                    _columns.add(column.getValue());
246                }
247            }
248        }
249        catch (Exception e)
250        {
251            throw new ConfigurationException("Error while parsing apogee-mapping.xml", e);
252        }
253        finally
254        {
255            IOUtils.closeQuietly(is);
256        }
257    }
258    
259    private I18nizableText _getCriterionLabel(Configuration configuration, String defaultValue)
260    {
261        if (configuration.getAttributeAsBoolean("i18n", false))
262        {
263            return new I18nizableText("plugin.odf-sync", configuration.getValue(defaultValue));
264        }
265        else
266        {
267            return new I18nizableText(configuration.getValue(defaultValue));
268        }
269    }
270    
271    @Override
272    public List<ModifiableDefaultContent> populate(Logger logger)
273    {
274        boolean isRequestAttributeOwner = false;
275        
276        Request request = ContextHelper.getRequest(_context);
277        if (request.getAttribute(ApogeeSynchronizableContentsCollectionHelper.HANDLE_CONTENTS) == null)
278        {
279            request.setAttribute(ApogeeSynchronizableContentsCollectionHelper.HANDLE_CONTENTS, new HashSet<String>());
280            isRequestAttributeOwner = true;
281        }
282        
283        List<ModifiableDefaultContent> populatedContents = super.populate(logger);
284        
285        if (isRequestAttributeOwner)
286        {
287            request.removeAttribute(ApogeeSynchronizableContentsCollectionHelper.HANDLE_CONTENTS);
288        }
289
290        return populatedContents;
291    }
292    
293    @Override
294    protected List<ModifiableDefaultContent> _internalPopulate(Logger logger)
295    {
296        return _importOrSynchronizeContents(Map.of("isGlobalSync", true), false, logger);
297    }
298    
299    @Override
300    protected Map<String, Map<String, Object>> internalSearch(Map<String, Object> parameters, int offset, int limit, List<Object> sort, Logger logger)
301    {
302        Map<String, Object> searchParams = new HashMap<>(parameters);
303        if (offset > 0)
304        {
305            searchParams.put("__offset", offset);
306        }
307        if (limit < Integer.MAX_VALUE)
308        {
309            searchParams.put("__limit", offset + limit);
310        }
311        searchParams.put("__order", _getSort(sort));
312
313        // We don't use session.selectMap which reorder data
314        List<Map<String, Object>> requestValues = _search(searchParams, logger);
315
316        // Transform CLOBs to String
317        Set<String> clobColumns = getClobColumns();
318        if (!clobColumns.isEmpty())
319        {
320            for (Map<String, Object> contentValues : requestValues)
321            {
322                String idValue = contentValues.get(getIdColumn()).toString();
323                for (String clobKey : getClobColumns())
324                {
325                    // Get the old values for the CLOB
326                    @SuppressWarnings("unchecked")
327                    Optional<List<Object>> oldValues = Optional.of(clobKey)
328                        .map(contentValues::get)
329                        .map(obj -> (List<Object>) obj);
330                    
331                    if (oldValues.isPresent())
332                    {
333                        // Get the new values for the CLOB
334                        List<Object> newValues = oldValues.get()
335                            .stream()
336                            .map(value -> _transformClobToString(value, idValue, logger))
337                            .filter(Objects::nonNull)
338                            .collect(Collectors.toList());
339                        
340                        // Set the transformed CLOB values
341                        contentValues.put(clobKey, newValues);
342                    }
343                }
344            }
345        }
346        
347        // Reorganize results
348        String idColumn = getIdColumn();
349        Map<String, Map<String, Object>> results = new LinkedHashMap<>();
350        for (Map<String, Object> contentValues : requestValues)
351        {
352            results.put(contentValues.get(idColumn).toString(), contentValues);
353        }
354        
355        for (Map<String, Object> result : results.values())
356        {
357            result.put(SCC_UNIQUE_ID, result.get(getIdColumn()));
358        }
359        
360        return results;
361    }
362    
363    @Override
364    protected Map<String, Map<String, List<Object>>> getRemoteValues(Map<String, Object> parameters, Logger logger)
365    {
366        Map<String, Map<String, List<Object>>> remoteValues = new HashMap<>();
367        
368        Map<String, Map<String, Object>> results = internalSearch(parameters, 0, Integer.MAX_VALUE, null, logger);
369        
370        if (results != null)
371        {
372            remoteValues = _sccHelper.organizeRemoteValuesByMetadata(results, _mapping);
373        }
374        
375        return remoteValues;
376    }
377    
378    @Override
379    protected List<ModifiableDefaultContent> _importOrSynchronizeContent(String idValue, Map<String, List<Object>> remoteValues, boolean forceImport, Logger logger)
380    {
381        return _importOrSynchronizeContent(idValue, _odfLang, remoteValues, forceImport, logger);
382    }
383
384    @Override
385    public List<ModifiableDefaultContent> importContent(String idValue, Map<String, Object> importParams, Logger logger) throws Exception
386    {
387        boolean isRequestAttributeOwner = false;
388        
389        Request request = ContextHelper.getRequest(_context);
390        if (request.getAttribute(ApogeeSynchronizableContentsCollectionHelper.HANDLE_CONTENTS) == null)
391        {
392            request.setAttribute(ApogeeSynchronizableContentsCollectionHelper.HANDLE_CONTENTS, new HashSet<String>());
393            isRequestAttributeOwner = true;
394        }
395
396        List<ModifiableDefaultContent> createdContents = new ArrayList<>();
397        
398        Map<String, Object> parameters = putIdParameter(idValue);
399        Map<String, Map<String, List<Object>>> results = getTransformedRemoteValues(parameters, logger);
400        if (!results.isEmpty())
401        {
402            try
403            {
404                createdContents.add(_importContent(idValue, importParams, _odfLang, results.get(idValue), logger));
405            }
406            catch (Exception e)
407            {
408                _nbError++;
409                logger.error("An error occurred while importing or synchronizing content", e);
410            }
411        }
412        
413        if (isRequestAttributeOwner)
414        {
415            request.removeAttribute(ApogeeSynchronizableContentsCollectionHelper.HANDLE_CONTENTS);
416        }
417
418        return createdContents;
419    }
420    
421    @Override
422    public void synchronizeContent(ModifiableDefaultContent content, Logger logger) throws Exception
423    {
424        boolean isRequestAttributeOwner = false;
425        
426        Request request = ContextHelper.getRequest(_context);
427        if (request.getAttribute(ApogeeSynchronizableContentsCollectionHelper.HANDLE_CONTENTS) == null)
428        {
429            request.setAttribute(ApogeeSynchronizableContentsCollectionHelper.HANDLE_CONTENTS, new HashSet<String>());
430            isRequestAttributeOwner = true;
431        }
432        
433        super.synchronizeContent(content, logger);
434        
435        if (isRequestAttributeOwner)
436        {
437            request.removeAttribute(ApogeeSynchronizableContentsCollectionHelper.HANDLE_CONTENTS);
438        }
439    }
440    
441    @Override
442    protected Map<String, Object> putIdParameter(String idValue)
443    {
444        Map<String, Object> parameters = new HashMap<>();
445        parameters.put(getIdField(), idValue);
446        return parameters;
447    }
448    
449    /**
450     * Search the contents with the search parameters. Use id parameter to search an unique content.
451     * @param searchParams Search parameters
452     * @param logger The logger
453     * @return A Map of mapped metadatas extract from Apogée database ordered by content unique Apogée ID
454     */
455    protected abstract List<Map<String, Object>> _search(Map<String, Object> searchParams, Logger logger);
456    
457    /**
458     * Convert the {@link BigDecimal} values retrieved from database into long values
459     * @param searchResults The initial search results from database
460     * @return The converted search results
461     */
462    protected List<Map<String, Object>> _convertBigDecimal(List<Map<String, Object>> searchResults)
463    {
464        List<Map<String, Object>> convertedSearchResults = new ArrayList<>();
465        
466        for (Map<String, Object> searchResult : searchResults)
467        {
468            for (String key : searchResult.keySet())
469            {
470                searchResult.put(key, _convertBigDecimal(getContentType(), key, searchResult.get(key))); 
471            }
472            
473            convertedSearchResults.add(searchResult);
474        }
475        
476        return convertedSearchResults;
477    }
478    
479    /**
480     * Convert the object in parameter to a long if it's a {@link BigDecimal}, otherwise return the object itself.
481     * @param contentTypeId The content type of the parent content
482     * @param attributeName The metadata name
483     * @param objectToConvert The object to convert if necessary
484     * @return The converted object
485     */
486    protected Object _convertBigDecimal(String contentTypeId, String attributeName, Object objectToConvert)
487    {
488        if (objectToConvert instanceof BigDecimal)
489        {
490            ContentType contentType = _contentTypeEP.getExtension(contentTypeId);
491            if (contentType.hasModelItem(attributeName))
492            {
493                ModelItem definition = contentType.getModelItem(attributeName);    
494                String typeId = definition.getType().getId();
495                switch (typeId)
496                {
497                    case ModelItemTypeConstants.DOUBLE_TYPE_ID:
498                        return ((BigDecimal) objectToConvert).doubleValue();
499                    case ModelItemTypeConstants.LONG_TYPE_ID:
500                        return ((BigDecimal) objectToConvert).longValue();
501                    default:
502                        // Do nothing
503                        break;
504                }
505            }
506            return ((BigDecimal) objectToConvert).toString();
507        }
508        return objectToConvert;
509    }
510    
511    /**
512     * Transform CLOB value to String value.
513     * @param value The input value
514     * @param idValue The identifier of the program
515     * @param logger The logger
516     * @return the same value, with CLOB transformed to String.
517     */
518    protected Object _transformClobToString(Object value, String idValue, Logger logger)
519    {
520        if (value instanceof Clob)
521        {
522            Clob clob = (Clob) value;
523            try
524            {
525                String strValue = IOUtils.toString(clob.getCharacterStream());
526                return CharMatcher.javaIsoControl().and(CharMatcher.anyOf("\r\n\t").negate()).removeFrom(strValue);
527            }
528            catch (SQLException | IOException e)
529            {
530                logger.error("Unable to get education add elements from the program '{}'.", idValue, e);
531                return null;
532            }
533            finally
534            {
535                try
536                {
537                    clob.free();
538                }
539                catch (SQLException e)
540                {
541                    // Ignore the exception.
542                }
543            }
544        }
545        
546        return value;
547    }
548
549    /**
550     * Get the list of CLOB column's names.
551     * @return The list of the CLOB column's names to transform to {@link String}
552     */
553    protected Set<String> getClobColumns()
554    {
555        return Set.of();
556    }
557    
558    /**
559     * Transform a {@link List} of {@link Map} to a {@link Map} of {@link List} computed by keys (lines to columns).
560     * @param lines {@link List} to reorganize
561     * @return {@link Map} of {@link List}
562     */
563    protected Map<String, List<Object>> _transformListOfMap2MapOfList(List<Map<String, Object>> lines)
564    {
565        return lines.stream()
566            .filter(Objects::nonNull)
567            .map(Map::entrySet)
568            .flatMap(Collection::stream)
569            .filter(e -> Objects.nonNull(e.getValue()))
570            .collect(
571                Collectors.toMap(
572                    Entry::getKey,
573                    e -> List.of(e.getValue()),
574                    (l1, l2) -> ListUtils.union(l1, l2)
575                )
576            );
577    }
578    
579    /**
580     * Get the name of the mapping.
581     * @return the mapping name
582     */
583    protected abstract String getMappingName();
584    
585    /**
586     * Get the id of data source
587     * @return The id of data source
588     */
589    protected String getDataSourceId()
590    {
591        return (String) getParameterValues().get(PARAM_DATASOURCE_ID);
592    }
593    
594    /**
595     * Get the administrative year
596     * @return The administrative year
597     */
598    protected String getYear()
599    {
600        return (String) getParameterValues().get(PARAM_YEAR);
601    }
602    
603    /**
604     * Check if unexisting contents in Ametys should be added from data source
605     * @return <code>true</code> if unexisting contents in Ametys should be added from data source, default value is <code>false</code>
606     */
607    protected boolean addUnexistingChildren()
608    {
609        return Boolean.valueOf((String) getParameterValues().getOrDefault(PARAM_ADD_UNEXISTING_CHILDREN, Boolean.TRUE.toString()));
610    }
611    
612    /**
613     * Get the identifier column (can be a concatened column).
614     * @return the column id
615     */
616    protected String getIdColumn()
617    {
618        return _idColumn;
619    }
620    
621    @Override
622    public String getIdField()
623    {
624        return "apogeeSyncCode";
625    }
626
627    @Override
628    public Set<String> getLocalAndExternalFields(Map<String, Object> additionalParameters)
629    {
630        return _syncFields;
631    }
632
633    @Override
634    public Set<String> getExternalOnlyFields(Map<String, Object> additionalParameters)
635    {
636        return _extFields;
637    }
638    
639    @Override
640    protected boolean _fillContent(Map<String, List<Object>> remoteValues, ModifiableDefaultContent content, boolean create, Logger logger)
641    {
642        ModifiableCompositeMetadata holder = content.getMetadataHolder();
643        
644        boolean hasChanges = _fillRichTexts(remoteValues, content, create, logger);
645        hasChanges = super._fillContent(remoteValues, content, create, logger) || hasChanges;
646        hasChanges = _handleAdditionalMetadata(holder, create) || hasChanges;
647        
648        if (!holder.hasMetadata(getIdField()))
649        {
650            holder.setMetadata(getIdField(), remoteValues.get(getIdField()).get(0).toString());
651            hasChanges |= create;
652        }
653        
654        return hasChanges;
655    }
656    
657    /**
658     * Fill the richt texts of the content with the remote values.
659     * @param remoteValues The remote values
660     * @param content The content to synchronize
661     * @param create <code>true</code> if content is creating, false if it is updated
662     * @param logger The logger
663     * @return <code>true</code> if changes were made
664     */
665    protected boolean _fillRichTexts(Map<String, List<Object>> remoteValues, ModifiableDefaultContent content, boolean create, Logger logger)
666    {
667        boolean hasChanges = false;
668
669        ModifiableCompositeMetadata holder = content.getMetadataHolder();
670        Map<String, Object> params = Map.of("contentTypes", List.of(getContentType()));
671        
672        for (String metadataName : getRichTextFields())
673        {
674            if (remoteValues.containsKey(metadataName))
675            {
676                boolean synchronize = getLocalAndExternalFields(params).contains(metadataName);
677                
678                ModifiableRichText richText = ExternalizableMetadataHelper.getRichText(holder, metadataName, synchronize ? ExternalizableMetadataStatus.EXTERNAL : ExternalizableMetadataStatus.LOCAL, true);
679                richText.setMimeType("text/xml");
680                richText.setLastModified(new Date());
681                try
682                {
683                    _setRichTextValue(richText, remoteValues.get(metadataName));
684                }
685                catch (IOException e)
686                {
687                    logger.error("An error occured while parsing the rich text '{}' of the content '{}'", metadataName, content.getTitle(), e);
688                }
689                
690                if (synchronize && create)
691                {
692                    // Force external status
693                    ExternalizableMetadataHelper.updateStatus(holder,  metadataName, ExternalizableMetadataStatus.EXTERNAL);
694                }
695                
696                remoteValues.remove(metadataName);
697                hasChanges = true;
698            }
699        }
700        
701        return hasChanges;
702    }
703    
704    /**
705     * Set the value on the rich text.
706     * @param richText The rich text to update
707     * @param remoteValue The remote value of the rich text (can be composed of several texts to concatenate)
708     * @throws IOException if an exception occurs
709     */
710    protected void _setRichTextValue(ModifiableRichText richText, List<Object> remoteValue) throws IOException
711    {
712        List<String> lines = new LinkedList<>();
713        for (Object remoteText : remoteValue)
714        {
715            lines.add(remoteText.toString());
716        }
717        
718        String docbook = ContentImporterHelper.textToDocbook(lines.toArray(new String[lines.size()]));
719        
720        try (ByteArrayInputStream is =  new ByteArrayInputStream(docbook.getBytes("UTF-8")))
721        {
722            richText.setInputStream(is);
723        }
724    }
725
726    /**
727     * Method to add additional metadata on import or synchronize.
728     * @param holder The holder of the content to update
729     * @param create If we are on creation mode
730     * @return <code>true</code> if changes has been made
731     */
732    protected boolean _handleAdditionalMetadata(ModifiableCompositeMetadata holder, boolean create)
733    {
734        // Do nothing
735        return false;
736    }
737
738    /**
739     * Get the list of rich text fields of the imported content.
740     * @return The list of the rich text fields metadata name
741     */
742    protected Set<String> getRichTextFields()
743    {
744        return new HashSet<>();
745    }
746    
747    @Override
748    protected void configureSearchModel()
749    {
750        for (ApogeeCriterion criterion : _criteria)
751        {
752            _searchModelConfiguration.addCriterion(criterion.getId(), criterion.getLabel(), criterion.getType());
753        }
754        for (String columnName : _columns)
755        {
756            _searchModelConfiguration.addColumn(columnName);
757        }
758    }
759    
760    @Override
761    protected boolean additionalCommonOperations(ModifiableDefaultContent content, Map<String, List<Object>> remoteValues, Map<String, Object> importParams, boolean create, Logger logger)
762    {
763        boolean hasChanges = super.additionalCommonOperations(content, remoteValues, importParams, create, logger);
764
765        Object parentId = importParams != null && importParams.containsKey("parentId") ? importParams.get("parentId") : null;
766        ModifiableDefaultContent parentContent = parentId != null ? _resolver.resolveById(parentId.toString()) : null;
767        
768        hasChanges = handleParent(content, parentContent, logger) || hasChanges;
769        hasChanges = handleChildren(content, create, logger) || hasChanges;
770        hasChanges = setAdditionalMetadata(content, remoteValues, create, logger) || hasChanges;
771        
772        return hasChanges;
773    }
774    
775    /**
776     * Set the parent metadata and invert relation.
777     * @param currentContent Current content
778     * @param parentContent Parent content to set
779     * @param logger The logger
780     * @return <code>true</code> if there are changes
781     */
782    protected boolean handleParent(ModifiableDefaultContent currentContent, ModifiableDefaultContent parentContent, Logger logger)
783    {
784        // Nothing to do by default
785        return false;
786    }
787    
788    /**
789     * Set the children metadata and invert relation, import and synchronize the children too.
790     * @param content Current content
791     * @param create true if the content has been newly created
792     * @param logger The logger
793     * @return <code>true</code> if there are changes
794     */
795    protected boolean handleChildren(ModifiableDefaultContent content, boolean create, Logger logger)
796    {
797        return importOrSynchronizeChildren(content, getChildrenSCCModelId(), getChildrenMetadataName(), getChildrenInvertMetadataName(), create, logger);
798    }
799    
800    /**
801     * Get the children SCC model id. Can be null if no implementation is defined.
802     * @return the children SCC model id
803     */
804    protected String getChildrenSCCModelId()
805    {
806        // Default implementation
807        return null;
808    }
809    
810    /**
811     * Get the metadata name to get children
812     * @return the metadata name to get children
813     */
814    protected abstract String getChildrenMetadataName();
815
816    /**
817     * Get the metadata name to get parent from children
818     * @return the metadata name to get parent from children
819     */
820    protected abstract String getChildrenInvertMetadataName();
821
822    /**
823     * Set the additional metadata.
824     * @param content Current content
825     * @param remoteValues Values of the content
826     * @param create true if the content has been newly created
827     * @param logger The logger
828     * @return <code>true</code> if there are changes
829     */
830    protected boolean setAdditionalMetadata(ModifiableDefaultContent content, Map<String, List<Object>> remoteValues, boolean create, Logger logger)
831    {
832        // Nothing to do by default
833        return false;
834    }
835    
836    /**
837     * Transform the given {@link List} of {@link Object} to a {@link String} representing the ordered fields for SQL.
838     * @param sortList The sort list object to transform to the list of ordered fields compatible with SQL.
839     * @return A string representing the list of ordered fields
840     */
841    @SuppressWarnings("unchecked")
842    protected String _getSort(List<Object> sortList)
843    {
844        if (sortList != null)
845        {
846            StringBuilder sort = new StringBuilder();
847            
848            for (Object sortValueObj : sortList)
849            {
850                Map<String, Object> sortValue = (Map<String, Object>) sortValueObj;
851                
852                sort.append(sortValue.get("property"));
853                if (sortValue.containsKey("direction"))
854                {
855                    sort.append(" ");
856                    sort.append(sortValue.get("direction"));
857                    sort.append(",");
858                }
859                else
860                {
861                    sort.append(" ASC,");
862                }
863            }
864            
865            sort.deleteCharAt(sort.length() - 1);
866            
867            return sort.toString();
868        }
869        
870        return null;
871    }
872    
873    @Override
874    public int getTotalCount(Map<String, Object> parameters, Logger logger)
875    {
876        // Remove empty parameters
877        Map<String, Object> searchParams = new HashMap<>();
878        for (String parameterName : parameters.keySet())
879        {
880            Object parameterValue = parameters.get(parameterName);
881            if (parameterValue != null && !parameterValue.toString().isEmpty())
882            {
883                searchParams.put(parameterName, parameterValue);
884            }
885        }
886        
887        searchParams.put("__count", true);
888        
889        List<Map<String, Object>> results = _search(searchParams, logger);
890        if (results != null && !results.isEmpty())
891        {
892            return Integer.valueOf(results.get(0).get("COUNT(*)").toString()).intValue();
893        }
894        
895        return 0;
896    }
897    
898    @Override
899    protected ModifiableDefaultContent _importContent(String idValue, Map<String, Object> importParams, String lang, Map<String, List<Object>> remoteValues, Logger logger) throws Exception
900    {
901        ModifiableDefaultContent content = super._importContent(idValue, importParams, lang, _transformOrgUnitMetadata(remoteValues, logger), logger);
902        if (content != null)
903        {
904            _apogeeSCCHelper.addToHandleContents(content.getId());
905        }
906        return content;
907    }
908    
909    @Override
910    protected ModifiableDefaultContent _synchronizeContent(ModifiableDefaultContent content, Map<String, List<Object>> remoteValues, Logger logger) throws Exception
911    {
912        if (!_apogeeSCCHelper.addToHandleContents(content.getId()))
913        {
914            return content;
915        }
916        return super._synchronizeContent(content, _transformOrgUnitMetadata(remoteValues, logger), logger);
917    }
918    
919    /**
920     * Import and synchronize children of the given content, then edit the structure of the content and its children.
921     * In the usual case, if we are in import mode (create to true) or if we trust the source (removalSync to true) we just synchronize structure but we don't synchronize child contents
922     * @param content Parent content
923     * @param sccModelId SCC model ID
924     * @param metadataName Metadata name to set
925     * @param invertMetadataName Metadata name of the invert relation
926     * @param create true if the content has been newly created
927     * @param logger The logger
928     * @return <code>true</code> if there are changes
929     */
930    protected boolean importOrSynchronizeChildren(ModifiableDefaultContent content, String sccModelId, String metadataName, String invertMetadataName, boolean create, Logger logger)
931    {
932        // Get the SCC for children
933        SynchronizableContentsCollection scc = _sccHelper.getSCCFromModelId(sccModelId);
934        
935        return create
936            // Import mode
937            ? _importChildren(content, scc, metadataName, invertMetadataName, logger)
938            // Synchronization mode
939            : _synchronizeChildren(content, scc, metadataName, invertMetadataName, logger);
940    }
941    
942    
943
944    /**
945     * Import children
946     * @param content Parent content
947     * @param scc the scc
948     * @param metadataName Metadata name to set
949     * @param invertMetadataName Metadata name of the invert relation
950     * @param logger The logger
951     * @return <code>true</code> if there are changes
952     */
953    protected boolean _importChildren(ModifiableDefaultContent content, SynchronizableContentsCollection scc, String metadataName, String invertMetadataName, Logger logger)
954    {
955        // If SCC exists, search for children
956        if (scc != null && scc instanceof ApogeeSynchronizableContentsCollection)
957        {
958            // Synchronize or import children content
959            ModifiableCompositeMetadata cm = content.getMetadataHolder();
960            String syncCode = cm.getString(getIdField());
961            List<ModifiableDefaultContent> importedOrSynchronizedChildren = ((ApogeeSynchronizableContentsCollection) scc).importOrSynchronizeContents(ImmutableMap.of("parentCode", syncCode), logger);
962
963            return _resetChildren(content, importedOrSynchronizedChildren, metadataName, invertMetadataName, logger);
964        }
965        
966        return false;
967    }
968    
969    /**
970     * Synchronize children
971     * @param content Parent content
972     * @param scc the scc
973     * @param metadataName Metadata name to set
974     * @param invertMetadataName Metadata name of the invert relation
975     * @param logger The logger
976     * @return <code>true</code> if there are changes
977     */
978    protected boolean _synchronizeChildren(ModifiableDefaultContent content, SynchronizableContentsCollection scc, String metadataName, String invertMetadataName, Logger logger)
979    {
980        // Get the children remote sync codes if needed
981        // These remote sync codes are needed if we want to add unexisting contents in Ametys from Apogee or delete obsolete contents 
982        Set<String> childrenRemoteSyncCode = (removalSync() || addUnexistingChildren())
983            ? _getChildrenRemoteSyncCode(content, scc, logger)
984            : null;
985        
986        // First we get the existing children in Ametys
987        List<ModifiableContent> ametysChildren = Stream.of(content.getValue(metadataName, false, new ContentValue[0]))
988            .map(ContentValue::getContentIfExists)
989            .filter(Optional::isPresent)
990            .map(Optional::get)
991            .collect(Collectors.toList());
992        
993        // Remove obsolete children if removeSync is active
994        if (childrenRemoteSyncCode != null && removalSync())
995        {
996            ametysChildren = ametysChildren.stream()
997                .filter(c -> !_isChildWillBeRemoved(c, scc, childrenRemoteSyncCode, logger))
998                .collect(Collectors.toList());
999        }
1000        
1001        // Then we synchronize the existing children in Ametys
1002        for (ModifiableContent childContent : ametysChildren)
1003        {
1004            _apogeeSCCHelper.synchronizeContent(childContent, logger);
1005        }
1006        
1007        // Then we add unexisting children if needed
1008        if (childrenRemoteSyncCode != null && addUnexistingChildren())
1009        {
1010            ametysChildren.addAll(_addUnexistingChildren(scc, childrenRemoteSyncCode, logger));
1011        }
1012        
1013        // Reset children if needed
1014        if (childrenRemoteSyncCode != null && (removalSync() || addUnexistingChildren()))
1015        {
1016            return _resetChildren(content, ametysChildren, metadataName, invertMetadataName, logger);
1017        }
1018        
1019        return false;
1020    }
1021
1022    /**
1023     * Get the remote sync codes
1024     * @param content Parent content
1025     * @param scc the scc
1026     * @param logger The logger
1027     * @return the remote sync codes or null if the scc is not from Apogee
1028     */
1029    protected Set<String> _getChildrenRemoteSyncCode(ModifiableDefaultContent content, SynchronizableContentsCollection scc, Logger logger)
1030    {
1031        if (scc != null && scc instanceof AbstractApogeeSynchronizableContentsCollection)
1032        {
1033            String syncCode = content.getMetadataHolder().getString(getIdField());
1034            return ((AbstractApogeeSynchronizableContentsCollection) scc)
1035                        .search(ImmutableMap.of("parentCode", syncCode), 0, Integer.MAX_VALUE, null, logger)
1036                        .keySet();
1037        }
1038        
1039        return null;
1040    }
1041
1042    /**
1043     * Add unexisting children in Ametys from Apogee
1044     * @param scc the scc to import contents
1045     * @param childrenRemoteSyncCode the remote sync codes
1046     * @param logger the logger
1047     * @return the list of unexisting contents
1048     */
1049    protected List<ModifiableContent> _addUnexistingChildren(SynchronizableContentsCollection scc, Set<String> childrenRemoteSyncCode, Logger logger)
1050    {
1051        return childrenRemoteSyncCode
1052            .stream()
1053            .filter(code -> scc.getContent(_odfLang, code) == null) // get only remote content values which doesn't exist in Ametys
1054            .flatMap(code -> _importUnexistingChildren(scc, code, null, logger))
1055            .collect(Collectors.toList());
1056    }
1057
1058    /**
1059     * Import an unexisting child in Ametys
1060     * @param scc the scc to import the content
1061     * @param syncCode the sync code of the content
1062     * @param params the additional params
1063     * @param logger the logger
1064     * @return the list of created children
1065     */
1066    @SuppressWarnings("unchecked")
1067    protected Stream<ModifiableDefaultContent> _importUnexistingChildren(SynchronizableContentsCollection scc, String syncCode, Map<String, List<Object>> params, Logger logger)
1068    {
1069        try
1070        {
1071            return scc.importContent(syncCode, (Map<String, Object>) (Object) params, logger).stream();
1072        }
1073        catch (Exception e)
1074        {
1075            logger.error("An error occured while importing a new children content with syncCode '{}' from SCC '{}'", syncCode, scc.getId(), e);
1076        }
1077        
1078        return Stream.empty();
1079    }
1080    
1081    /**
1082     * True if the content will be removed from the structure
1083     * @param content the content
1084     * @param scc the scc
1085     * @param childrenRemoteSyncCode the remote sync codes
1086     * @param logger the logger
1087     * @return <code>true</code> if the content will be removed from the structure
1088     */
1089    protected boolean _isChildWillBeRemoved(ModifiableContent content, SynchronizableContentsCollection scc, Set<String> childrenRemoteSyncCode, Logger logger)
1090    {
1091        String syncCode = content.getMetadataHolder().getString(scc.getIdField(), null);
1092        return !childrenRemoteSyncCode.contains(syncCode);
1093    }
1094    
1095    /**
1096     * Reset the children of the structure to force with the children from Apogée.
1097     * @param content Parent content
1098     * @param importedOrSynchronizedChildren the imported or synchronized children
1099     * @param metadataName Metadata name to set
1100     * @param invertMetadataName Metadata name of the invert relation
1101     * @param logger The logger
1102     * @return <code>true</code> if there are changes
1103     */
1104    protected boolean _resetChildren(ModifiableDefaultContent content, List<? extends ModifiableContent> importedOrSynchronizedChildren , String metadataName, String invertMetadataName, Logger logger)
1105    {
1106        boolean hasChanges = false;
1107
1108        ModifiableCompositeMetadata cm = content.getMetadataHolder();
1109        
1110        // Remove all children relations
1111        ContentValue[] oldChildren = content.getValue(metadataName, false, new ContentValue[0]);
1112        if (oldChildren.length > 0)
1113        {
1114            hasChanges = ExternalizableMetadataHelper.removeMetadataIfExists(cm, metadataName) || hasChanges;
1115            
1116            for (ContentValue child : oldChildren)
1117            {
1118                Optional<ModifiableContent> childContent = child.getContentIfExists();
1119                if (childContent.isPresent())
1120                {
1121                    hasChanges = _updateRelation(childContent.get().getMetadataHolder(), invertMetadataName, content, true) || hasChanges;
1122                }
1123            }
1124        }
1125        
1126        if (!importedOrSynchronizedChildren.isEmpty())
1127        {
1128            // Update child relation to link to the parent
1129            ModifiableDefaultContent[] newChildren = importedOrSynchronizedChildren.toArray(new ModifiableDefaultContent[importedOrSynchronizedChildren.size()]);
1130            hasChanges = ExternalizableMetadataHelper.setMetadata(cm, metadataName, newChildren) || hasChanges;
1131
1132            // Update parent relation on each children
1133            for (ModifiableDefaultContent child : newChildren)
1134            {
1135                hasChanges = _updateRelation(child.getMetadataHolder(), invertMetadataName, content) || hasChanges;
1136            }
1137        }
1138        
1139        return hasChanges;
1140    }
1141
1142    @Override
1143    public List<ModifiableDefaultContent> importOrSynchronizeContents(Map<String, Object> searchParams, Logger logger)
1144    {
1145        return _importOrSynchronizeContents(searchParams, true, logger);
1146    }
1147    
1148    @SuppressWarnings("unchecked")
1149    private Map<String, List<Object>> _transformOrgUnitMetadata(Map<String, List<Object>> remoteValues, Logger logger)
1150    {
1151        // Transform orgUnit values and import content if necessary (useful for Course and SubProgram)
1152        SynchronizableContentsCollection scc = _sccHelper.getSCCFromModelId(OrgUnitSynchronizableContentsCollection.MODEL_ID);
1153        
1154        List<Object> orgUnitCodes = remoteValues.get("orgUnit");
1155        if (orgUnitCodes != null && !orgUnitCodes.isEmpty())
1156        {
1157            List<?> orgUnitContents = null;
1158            String orgUnitCode = orgUnitCodes.get(0).toString();
1159            
1160            if (scc != null)
1161            {
1162                try
1163                {
1164                    ModifiableDefaultContent orgUnitContent = scc.getContent(_odfLang, orgUnitCode);
1165                    if (orgUnitContent == null)
1166                    {
1167                        orgUnitContents = scc.importContent(orgUnitCode, null, logger);
1168                    }
1169                    else
1170                    {
1171                        orgUnitContents = ImmutableList.of(orgUnitContent);
1172                    }
1173                }
1174                catch (Exception e)
1175                {
1176                    logger.error("An error occured during the import of the OrgUnit identified by the synchronization code '{}'", orgUnitCode, e);
1177                }
1178            }
1179            
1180            if (orgUnitContents == null)
1181            {
1182                // Impossible link to orgUnit
1183                remoteValues.remove("orgUnit");
1184                logger.warn("Impossible to import the OrgUnit with the synchronization code '{}', check if you set the OrgUnit SCC with the following model ID: '{}'", orgUnitCode, OrgUnitSynchronizableContentsCollection.MODEL_ID);
1185            }
1186            else
1187            {
1188                remoteValues.put("orgUnit", (List<Object>) orgUnitContents);
1189            }
1190        }
1191        
1192        return remoteValues;
1193    }
1194
1195    @Override
1196    public boolean handleRightAssignmentContext()
1197    {
1198        // Rights on ODF contents are handled by ODFRightAssignmentContext
1199        return false;
1200    }
1201}