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.contentio.synchronize;
017
018import java.util.ArrayList;
019import java.util.Collections;
020import java.util.HashMap;
021import java.util.LinkedHashMap;
022import java.util.List;
023import java.util.Map;
024import java.util.stream.Collectors;
025
026import org.apache.avalon.framework.configuration.Configuration;
027import org.apache.avalon.framework.configuration.ConfigurationException;
028import org.apache.avalon.framework.service.ServiceException;
029import org.apache.avalon.framework.service.ServiceManager;
030import org.apache.commons.lang3.StringUtils;
031import org.slf4j.Logger;
032
033import org.ametys.cms.ObservationConstants;
034import org.ametys.cms.content.ContentHelper;
035import org.ametys.cms.contenttype.ContentType;
036import org.ametys.cms.repository.Content;
037import org.ametys.cms.repository.DefaultContent;
038import org.ametys.cms.repository.ModifiableDefaultContent;
039import org.ametys.cms.repository.WorkflowAwareContent;
040import org.ametys.core.observation.Event;
041import org.ametys.plugins.repository.AmetysObjectIterable;
042import org.ametys.plugins.repository.metadata.ModifiableCompositeMetadata;
043
044/**
045 * Abstract implementation of {@link SynchronizableContentsCollection}.
046 */
047public abstract class AbstractSimpleSynchronizableContentsCollection extends AbstractSynchronizableContentsCollection
048{
049    /** The extension point for Synchronizing Content Operators */
050    protected SynchronizingContentOperatorExtensionPoint _synchronizingContentOperatorEP;
051    
052    /** The content helper */
053    protected ContentHelper _contentHelper;
054    
055    private List<String> _handledContents;
056    
057    @Override
058    public void service(ServiceManager manager) throws ServiceException
059    {
060        super.service(manager);
061        _contentHelper = (ContentHelper) manager.lookup(ContentHelper.ROLE);
062        _synchronizingContentOperatorEP = (SynchronizingContentOperatorExtensionPoint) manager.lookup(SynchronizingContentOperatorExtensionPoint.ROLE);
063    }
064    
065    @Override
066    public void configure(Configuration configuration) throws ConfigurationException
067    {
068        super.configure(configuration);
069        _handledContents = new ArrayList<>();
070    }
071
072    @Override
073    public List<ModifiableDefaultContent> populate(Logger logger)
074    {
075        _handledContents.clear();
076        List<ModifiableDefaultContent> populatedContents = super.populate(logger);
077        _handledContents.clear();
078        return populatedContents;
079    }
080    
081    @Override
082    protected List<ModifiableDefaultContent> _internalPopulate(Logger logger)
083    {
084        return _importOrSynchronizeContents(new HashMap<>(), false, logger);
085    }
086    
087    /**
088     * Adds the given content as handled (i.e. will not be removed if _removalSync is true) 
089     * @param id The id of the content
090     */
091    protected void _handleContent(String id)
092    {
093        _handledContents.add(id);
094    }
095    
096    /**
097     * Returns true if the given content is handled
098     * @param id The content to test
099     * @return true if the given content is handled
100     */
101    protected boolean _isHandled(String id)
102    {
103        return _handledContents.contains(id);
104    }
105
106    /**
107     * Imports or synchronizes a content for each available language
108     * @param idValue The unique identifier of the content
109     * @param remoteValues The remote values
110     * @param forceImport To force import and ignoring the synchronize existing contents only option
111     * @param logger The logger
112     * @return The list of synchronized or imported contents
113     */
114    protected List<ModifiableDefaultContent> _importOrSynchronizeContent(String idValue, Map<String, List<Object>> remoteValues, boolean forceImport, Logger logger)
115    {
116        List<ModifiableDefaultContent> contents = new ArrayList<>();
117
118        for (String lang : getLanguages())
119        {
120            contents.addAll(_importOrSynchronizeContent(idValue, lang, remoteValues, forceImport, logger));
121        }
122        
123        return contents;
124    }
125    
126    /**
127     * Imports or synchronizes a content for a given language
128     * @param idValue The unique identifier of the content
129     * @param lang The language of content to import or synchronize
130     * @param remoteValues The remote values
131     * @param forceImport To force import and ignoring the synchronize existing contents only option
132     * @param logger The logger
133     * @return The list of imported and synchronized contents
134     */
135    protected List<ModifiableDefaultContent> _importOrSynchronizeContent(String idValue, String lang, Map<String, List<Object>> remoteValues, boolean forceImport, Logger logger)
136    {
137        List<ModifiableDefaultContent> contents = new ArrayList<>();
138        
139        try
140        {
141            ModifiableDefaultContent content = getContent(lang, idValue);
142            if (content != null)
143            {
144                contents.add(_synchronizeContent(content, remoteValues, logger));
145            }
146            else if (forceImport || !synchronizeExistingContentsOnly())
147            {
148                contents.add(_importContent(idValue, null, lang, remoteValues, logger));
149            }
150        }
151        catch (Exception e)
152        {
153            _nbError++;
154            logger.error("An error occurred while importing or synchronizing content", e);
155        }
156        
157        return contents;
158    }
159
160    @Override
161    public void synchronizeContent(ModifiableDefaultContent content, Logger logger) throws Exception
162    {
163        String idValue = content.getMetadataHolder().getString(getIdField());
164        
165        Map<String, Object> parameters = putIdParameter(idValue);
166        Map<String, Map<String, List<Object>>> results = getTransformedRemoteValues(parameters, logger);
167        if (!results.isEmpty())
168        {
169            try
170            {
171                _synchronizeContent(content, results.get(idValue), logger);
172            }
173            catch (Exception e)
174            {
175                _nbError++;
176                logger.error("An error occurred while importing or synchronizing content", e);
177                throw e;
178            }
179        }
180        else
181        {
182            logger.warn("The content {} ({}) with synchronization code '{}' doesn't exist anymore in the datasource from SCC '{}'", content.getTitle(), content.getId(), idValue, getId());
183        }
184    }
185
186    /**
187     * Synchronize a content with remove values.
188     * @param content The content to synchronize
189     * @param remoteValues Values to synchronize
190     * @param logger The logger
191     * @return The synchronized content
192     * @throws Exception if an error occurs.
193     */
194    protected ModifiableDefaultContent _synchronizeContent(ModifiableDefaultContent content, Map<String, List<Object>> remoteValues, Logger logger) throws Exception
195    {
196        long startTime = System.currentTimeMillis();
197        
198        String contentTitle = content.getTitle();
199        String lang = content.getLanguage();
200        
201        // Update content
202        logger.info("Start synchronizing content '{}' for language {}", contentTitle, lang);
203
204        _ensureTitleIsPresent(content, remoteValues, logger);
205        
206        boolean hasChanges = _fillContent(remoteValues, content, false, logger);
207        hasChanges = additionalSynchronizeOperations(content, remoteValues, logger) || hasChanges;
208        
209        if (hasChanges)
210        {
211            boolean success = applyChanges(content, logger);
212            if (success)
213            {
214                _nbSynchronizedContents++;
215                logger.info("Some changes were detected for content '{}' and language {}", contentTitle, lang);
216            }
217        }
218        else
219        {
220            _nbNotChangedContents++;
221            logger.info("No changes detected for content '{}' and language {}", contentTitle, lang);
222        }
223        
224        // Do additional operation on the content
225        SynchronizingContentOperator synchronizingContentOperator = _synchronizingContentOperatorEP.getExtension(getSynchronizingContentOperator());
226        if (synchronizingContentOperator != null)
227        {
228            synchronizingContentOperator.additionalOperation(content, remoteValues, logger);
229        }
230        else
231        {
232            logger.warn("Cannot find synchronizing content operator with id '{}'. No additional operation has been done.", getSynchronizingContentOperator());
233        }
234        
235        long endTime = System.currentTimeMillis();
236        logger.info("End synchronization of content '{}' for language {} in {} ms", contentTitle, lang, endTime - startTime);
237        
238        return content;
239    }
240    
241    @Override
242    public List<ModifiableDefaultContent> importContent(String idValue, Map<String, Object> importParams, Logger logger) throws Exception
243    {
244        List<ModifiableDefaultContent> createdContents = new ArrayList<>();
245        
246        Map<String, Object> parameters = putIdParameter(idValue);
247        Map<String, Map<String, List<Object>>> results = getTransformedRemoteValues(parameters, logger);
248        if (!results.isEmpty())
249        {
250            for (String lang : getLanguages())
251            {
252                try
253                {
254                    createdContents.add(_importContent(idValue, importParams, lang, results.get(idValue), logger));
255                }
256                catch (Exception e)
257                {
258                    _nbError++;
259                    logger.error("An error occurred while importing or synchronizing content", e);
260                }
261            }
262        }
263        
264        return createdContents;
265    }
266    
267    /**
268     * Set search parameters for the ID value.
269     * @param idValue Value to search
270     * @return Map with the search parameters
271     */
272    protected abstract Map<String, Object> putIdParameter(String idValue);
273
274    /**
275     * Import a content from remote values.
276     * @param idValue Id (for import/synchronization) of the content to import
277     * @param importParams Specific parameters for import
278     * @param lang Lang of the content
279     * @param remoteValues Values of the content
280     * @param logger The logger
281     * @return The content created by import, or null
282     * @throws Exception if an error occurs.
283     */
284    protected ModifiableDefaultContent _importContent(String idValue, Map<String, Object> importParams, String lang, Map<String, List<Object>> remoteValues, Logger logger) throws Exception
285    {
286        ModifiableDefaultContent content = getContent(lang, idValue);
287        if (content != null)
288        {
289            logger.warn("The content '{}' for language {} already exists and cannot be imported ", idValue, lang);
290        }
291        else
292        {
293            long startTime = System.currentTimeMillis();
294
295            // Calculate contentTitle
296            String contentTitle = idValue;
297            if (remoteValues.containsKey("title"))
298            {
299                List<Object> remoteTitles = remoteValues.get("title");
300                contentTitle = (String) remoteTitles.stream().filter(obj -> obj instanceof String && StringUtils.isNotEmpty((String) obj)).findFirst().orElse(idValue);
301            }
302            
303            // Create new content
304            logger.info("Start importing content '{}' for language {}", contentTitle, lang);
305    
306            content = createContentAction(lang, contentTitle, logger);
307            if (content != null)
308            {
309                // Synchronize content metadata
310                _fillContent(remoteValues, content, true, logger);
311                updateSCCProperty(content);
312                
313                additionalImportOperations(content, remoteValues, importParams, logger);
314                
315                content.saveChanges();
316                content.checkpoint();
317                
318                // Validate content if allowed
319                validateContent(content, logger);
320                
321                _nbCreatedContents++;
322
323                // Do additional operation on the content
324                SynchronizingContentOperator synchronizingContentOperator = _synchronizingContentOperatorEP.getExtension(getSynchronizingContentOperator());
325                synchronizingContentOperator.additionalOperation(content, remoteValues, logger);
326                
327                // Notify a content was imported
328                Map<String, Object> eventParams = new HashMap<>();
329                eventParams.put(ObservationConstants.ARGS_CONTENT, content);
330                eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId());
331                _observationManager.notify(new Event(org.ametys.plugins.contentio.synchronize.observation.ObservationConstants.EVENT_CONTENT_SYNCHRONIZED, _currentUserProvider.getUser(), eventParams));
332                
333                long endTime = System.currentTimeMillis();
334                logger.info("End import of content '{}' for language {} in {} ms", content.getId(), lang, endTime - startTime);
335            }
336        }
337        
338        return content;
339    }
340    
341    /**
342     * Add specific fields to the content.
343     * @param content Content to update
344     * @param remoteValues Values of the content
345     * @param importParams Import parameters
346     * @param logger The logger
347     * @return <code>true</code> if there are changes
348     */
349    protected boolean additionalImportOperations(ModifiableDefaultContent content, Map<String, List<Object>> remoteValues, Map<String, Object> importParams, Logger logger)
350    {
351        return additionalCommonOperations(content, remoteValues, importParams, true, logger);
352    }
353
354    /**
355     * Add specific fields to the content.
356     * @param content Content to update
357     * @param remoteValues Values of the content
358     * @param logger The logger
359     * @return <code>true</code> if there are changes
360     */
361    protected boolean additionalSynchronizeOperations(ModifiableDefaultContent content, Map<String, List<Object>> remoteValues, Logger logger)
362    {
363        return additionalCommonOperations(content, remoteValues, Collections.EMPTY_MAP, false, logger);
364    }
365    
366    /**
367     * Add specific fields to the content during import or synchronization.
368     * @param content Content to update
369     * @param remoteValues Values of the content
370     * @param importParams the import params
371     * @param create true if the content has been newly created
372     * @param logger The logger
373     * @return <code>true</code> if there are changes
374     */
375    protected boolean additionalCommonOperations(ModifiableDefaultContent content, Map<String, List<Object>> remoteValues, Map<String, Object> importParams, boolean create, Logger logger)
376    {
377        // Do nothing by default
378        return false;
379    }
380    
381    private void _ensureTitleIsPresent(Content content, Map<String, List<Object>> remoteValues, Logger logger)
382    {
383        if (remoteValues.containsKey("title"))
384        {
385            List<Object> titleValues = remoteValues.get("title");
386            boolean atLeastOneTitle = titleValues.stream()
387                                                 .filter(String.class::isInstance)
388                                                 .map(String.class::cast)
389                                                 .anyMatch(StringUtils::isNotBlank);
390            if (atLeastOneTitle)
391            {
392                return;
393            }
394        }
395        
396        // Force to current title
397        logger.warn("The remote value of 'title' is empty for the content {}. The 'title' metadata is mandatory, the current title will remain.", content);
398        remoteValues.put("title", Collections.singletonList(content.getTitle()));
399    }
400
401    @Override
402    public ModifiableDefaultContent getContent(String lang, String idValue)
403    {
404        String query = _getContentPathQuery(lang, idValue, getContentType());
405        AmetysObjectIterable<ModifiableDefaultContent> contents = _resolver.query(query);
406        
407        if (contents.getSize() > 0)
408        {
409            return contents.iterator().next();
410        }
411        return null;
412    }
413    
414    /**
415     * Creates content action with result from request
416     * @param lang The language
417     * @param contentTitle The content title
418     * @param logger The logger
419     * @return The content id, or null of a workflow error occured
420     */
421    protected ModifiableDefaultContent createContentAction(String lang, String contentTitle, Logger logger)
422    {
423        return createContentAction(getContentType(), getWorkflowName(), getInitialActionId(), lang, contentTitle, logger);
424    }
425    
426    /**
427     * Fill the content with remote values.
428     * @param remoteValues The remote values
429     * @param content The content to synchronize
430     * @param create <code>true</code> if content is creating, false if it is updated
431     * @param logger The logger
432     * @return <code>true</code> if changes were made
433     */
434    protected boolean _fillContent(Map<String, List<Object>> remoteValues, ModifiableDefaultContent content, boolean create, Logger logger)
435    {
436        boolean hasChanges = false;
437        
438        if (content.isLocked())
439        {
440            logger.warn("The content '{}' ({}) is currently locked by user {}: it cannot be synchronized", content.getTitle(), content.getId(), content.getLockOwner());
441        }
442        else
443        {
444            Map<String, Object> params = Map.of("contentTypes", List.of(getContentType()));
445            ContentType contentType = _contentTypeEP.getExtension(getContentType());
446            for (String metadataPath : remoteValues.keySet())
447            {
448                hasChanges = _synchronizeMetadata(content, contentType, metadataPath, metadataPath, remoteValues.get(metadataPath), getLocalAndExternalFields(params).contains(metadataPath), create, logger) || hasChanges;
449            }
450        }
451        
452        return hasChanges;
453    }
454    
455    /**
456     * Validates a content after import
457     * @param content The content to validate
458     * @param logger The logger
459     */
460    protected void validateContent(WorkflowAwareContent content, Logger logger)
461    {
462        if (validateAfterImport())
463        {
464            validateContent(content, getValidateActionId(), logger);
465        }
466    }
467    
468    /**
469     * Get the value of metadata holding the unique identifier of the synchronized content
470     * @param content The content
471     * @return The value
472     */
473    protected String _getIdFieldValue(DefaultContent content)
474    {
475        ModifiableCompositeMetadata metadataHolder = _getMetadataHolder(content.getMetadataHolder(), getIdField());
476        
477        String[] pathSegments = getIdField().split("/");
478        String metadataName = pathSegments[pathSegments.length - 1];
479        
480        return metadataHolder.getString(metadataName, null);
481    }
482    
483    @Override
484    public Map<String, Map<String, Object>> search(Map<String, Object> parameters, int offset, int limit, List<Object> sort, Logger logger)
485    {
486        // Search
487        Map<String, Map<String, Object>> results = internalSearch(_removeEmptyParameters(parameters), offset, limit, sort, logger);
488                
489        return results;
490    }
491
492    /**
493     * Search values and return the result without any treatment.
494     * @param parameters Search parameters to restrict the search
495     * @param offset Begin of the search
496     * @param limit Number of results
497     * @param sort Sort of results (ignored for LDAP results)
498     * @param logger The logger
499     * @return Map of results without any treatment.
500     */
501    protected abstract Map<String, Map<String, Object>> internalSearch(Map<String, Object> parameters, int offset, int limit, List<Object> sort, Logger logger);
502
503    /**
504     * Search values and return the result organized by metadata and transformed by the {@link SynchronizingContentOperator} if exists.
505     * @param parameters Search parameters to restrict the search
506     * @param logger The logger
507     * @return Map of results organized by metadata.
508     */
509    protected Map<String, Map<String, List<Object>>> getTransformedRemoteValues(Map<String, Object> parameters, Logger logger)
510    {
511        Map<String, Map<String, List<Object>>> remoteValues = getRemoteValues(parameters, logger);
512        
513        SynchronizingContentOperator synchronizingContentOperator = _synchronizingContentOperatorEP.getExtension(getSynchronizingContentOperator());
514        if (synchronizingContentOperator != null)
515        {
516            Map<String, Map<String, List<Object>>> transformedRemoteValues = new LinkedHashMap<>();
517            ContentType contentType = _contentTypeEP.getExtension(getContentType());
518            for (String key : remoteValues.keySet())
519            {
520                transformedRemoteValues.put(key, synchronizingContentOperator.transform(contentType, remoteValues.get(key), logger));
521            }
522            
523            return transformedRemoteValues;
524        }
525        else
526        {
527            logger.warn("Cannot find synchronizing content operator with id '{}'. No transformation has applied on remote values", getSynchronizingContentOperator());
528            return remoteValues; // no transformation
529        }
530    }
531    
532    /**
533     * Search values and return the result organized by metadata
534     * @param parameters Search parameters to restrict the search
535     * @param logger The logger
536     * @return Map of results organized by metadata.
537     */
538    protected abstract Map<String, Map<String, List<Object>>> getRemoteValues(Map<String, Object> parameters, Logger logger);
539    
540    @Override
541    public void updateSyncInformations(ModifiableDefaultContent content, String syncCode, Logger logger) throws Exception
542    {
543        updateSCCProperty(content);
544        content.getMetadataHolder().setMetadata(getIdField(), syncCode);
545        content.saveChanges();
546        content.checkpoint();
547    }
548
549    @Override
550    public int getTotalCount(Map<String, Object> parameters, Logger logger)
551    {
552        return search(parameters, 0, Integer.MAX_VALUE, null, logger).size();
553    }
554
555    /**
556     * Import or synchronize several contents from search params.
557     * @param searchParams Search parameters
558     * @param forceImport To force import and ignoring the synchronize existing contents only option
559     * @param logger The logger
560     * @return The {@link List} of imported or synchronized {@link ModifiableDefaultContent}
561     */
562    protected List<ModifiableDefaultContent> _importOrSynchronizeContents(Map<String, Object> searchParams, boolean forceImport, Logger logger)
563    {
564        List<ModifiableDefaultContent> contents = new ArrayList<>();
565        
566        Map<String, Map<String, List<Object>>> remoteValuesByContent = getTransformedRemoteValues(searchParams, logger);
567        for (String idValue : remoteValuesByContent.keySet())
568        {
569            Map<String, List<Object>> remoteValues = remoteValuesByContent.get(idValue);
570            _handleContent(idValue);
571            contents.addAll(_importOrSynchronizeContent(idValue, remoteValues, forceImport, logger));
572        }
573        
574        return contents;
575    }
576    
577    @Override
578    protected List<Content> _getContentsToRemove(AmetysObjectIterable<ModifiableDefaultContent> contents)
579    {
580        return contents.stream()
581            .filter(content -> !_isHandled(_getIdFieldValue(content)))
582            .collect(Collectors.toList());
583    }
584}