001/*
002 *  Copyright 2018 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.io.ByteArrayInputStream;
019import java.io.IOException;
020import java.io.StringReader;
021import java.lang.reflect.Array;
022import java.nio.charset.StandardCharsets;
023import java.util.ArrayList;
024import java.util.Date;
025import java.util.HashMap;
026import java.util.HashSet;
027import java.util.List;
028import java.util.Map;
029import java.util.Set;
030
031import javax.jcr.RepositoryException;
032import javax.jcr.Value;
033
034import org.apache.avalon.framework.component.Component;
035import org.apache.avalon.framework.service.ServiceException;
036import org.apache.avalon.framework.service.ServiceManager;
037import org.apache.avalon.framework.service.Serviceable;
038import org.apache.commons.io.IOUtils;
039import org.apache.commons.lang3.ArrayUtils;
040import org.slf4j.Logger;
041
042import org.ametys.cms.FilterNameHelper;
043import org.ametys.cms.content.external.ExternalizableMetadataHelper;
044import org.ametys.cms.content.external.ExternalizableMetadataProvider.ExternalizableMetadataStatus;
045import org.ametys.cms.contenttype.ContentType;
046import org.ametys.cms.contenttype.MetadataDefinition;
047import org.ametys.cms.contenttype.MetadataType;
048import org.ametys.cms.repository.Content;
049import org.ametys.cms.repository.DefaultContent;
050import org.ametys.cms.repository.ModifiableDefaultContent;
051import org.ametys.cms.repository.WorkflowAwareContent;
052import org.ametys.cms.workflow.ContentWorkflowHelper;
053import org.ametys.cms.workflow.CreateContentFunction;
054import org.ametys.core.observation.Event;
055import org.ametys.core.observation.ObservationManager;
056import org.ametys.core.user.CurrentUserProvider;
057import org.ametys.plugins.contentio.ContentImporterHelper;
058import org.ametys.plugins.repository.AmetysObjectResolver;
059import org.ametys.plugins.repository.AmetysRepositoryException;
060import org.ametys.plugins.repository.lock.LockHelper;
061import org.ametys.plugins.repository.metadata.ModifiableBinaryMetadata;
062import org.ametys.plugins.repository.metadata.ModifiableCompositeMetadata;
063import org.ametys.plugins.repository.metadata.ModifiableRichText;
064import org.ametys.plugins.workflow.AbstractWorkflowComponent;
065
066import com.google.common.base.CharMatcher;
067import com.google.common.collect.ImmutableMap;
068import com.opensymphony.workflow.InvalidActionException;
069import com.opensymphony.workflow.WorkflowException;
070
071/**
072 * Class for basics operations on SCC.
073 */
074public class BaseSynchroComponent implements Serviceable, Component
075{
076    /** Avalon Role */
077    public static final String ROLE = BaseSynchroComponent.class.getName();
078
079    /** Id of workflow action for synchronization */
080    public static final int SYNCHRONIZE_WORKFLOW_ACTION_ID = 800;
081    
082    /** The content workflow helper */
083    protected ContentWorkflowHelper _workflowHelper;
084
085    /** The observation manager */
086    protected ObservationManager _observationManager;
087    
088    /** The current user provider */
089    protected CurrentUserProvider _currentUserProvider;
090    
091    /** The ametys object resolver */
092    protected AmetysObjectResolver _resolver;
093    
094    @Override
095    public void service(ServiceManager manager) throws ServiceException
096    {
097        _workflowHelper = (ContentWorkflowHelper) manager.lookup(ContentWorkflowHelper.ROLE);
098        _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE);
099        _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE);
100        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
101    }
102    
103    /**
104     * Remove the metadata if exists
105     * @param metadataHolder The metadata holder
106     * @param metadataName The name of the metadata
107     * @param synchronize <code>true</code> if the data is synchronize
108     * @return <code>true</code> if the metadata have been removed
109     */
110    public boolean removeMetadataIfExists(ModifiableCompositeMetadata metadataHolder, String metadataName, boolean synchronize)
111    {
112        if (synchronize)
113        {
114            return ExternalizableMetadataHelper.removeExternalMetadataIfExists(metadataHolder, metadataName);
115        }
116        else
117        {
118            boolean hasMetadata = metadataHolder.hasMetadata(metadataName);
119            if (hasMetadata)
120            {
121                metadataHolder.removeMetadata(metadataName);
122            }
123            return hasMetadata;
124        }
125    }
126
127    /**
128     * Validates a content after import
129     * @param content The content to validate
130     * @param validationActionId Validation action ID to use for this content
131     * @param logger The logger
132     */
133    public void validateContent(WorkflowAwareContent content, int validationActionId, Logger logger)
134    {
135        try
136        {
137            _workflowHelper.doAction(content, validationActionId);
138            logger.info("The content '{}' ({}) has been validated after import", content.getTitle(), content.getId());
139        }
140        catch (InvalidActionException e)
141        {
142            logger.error("The content '{}' ({}) cannot be validated after import: may miss mandatory metadata ?", content.getTitle(), content.getId(), e);
143        }
144        catch (WorkflowException e)
145        {
146            logger.error("The content '{}' ({}) cannot be validated after import", content.getTitle(), content.getId(), e);
147        }
148    }
149
150    /**
151     * Does workflow action
152     * @param content The synchronized content
153     * @param actionId Workflow action
154     * @param event Type of event
155     * @param logger The logger
156     * @return A {@link Map} with one or two {@link Boolean}, "success" tells if the operation have been done successfully, "error" tells if an error occurs during the content saving. The save can be successful but an error can occurs during the workflow update.
157     * @throws RepositoryException if an error occurs when trying to rollback pending changes in the repository.
158     */
159    public Map<String, Boolean> applyChanges(ModifiableDefaultContent content, Integer actionId, String event, Logger logger) throws RepositoryException
160    {
161        Map<String, Boolean> resultMap = new HashMap<>();
162        
163        try
164        {
165            content.setLastModified(new Date());
166            try
167            {
168                content.saveChanges();
169            }
170            catch (AmetysRepositoryException e)
171            {
172                resultMap.put("error", Boolean.TRUE);
173                logger.error("An error occurred while saving changes on content '{}'.", content.getId(), e);
174                
175                // Rollback pending changes
176                content.getNode().getSession().refresh(false);
177                resultMap.put("success", Boolean.FALSE);
178                return resultMap;
179            }
180            
181            if (content.isLocked() && !LockHelper.isLockOwner(content, _currentUserProvider.getUser()))
182            {
183                logger.warn("Cannot apply changes because content {} is currently locked by {}", content.getTitle(), _currentUserProvider.getUser());
184                resultMap.put("success", Boolean.TRUE);
185                return resultMap;
186            }
187            
188            // Create new version
189            content.checkpoint();
190            
191            // Notify observers that the content has been modified
192            Map<String, Object> eventParams = new HashMap<>();
193            eventParams.put(org.ametys.cms.ObservationConstants.ARGS_CONTENT, content);
194            eventParams.put(org.ametys.cms.ObservationConstants.ARGS_CONTENT_ID, content.getId());
195            _observationManager.notify(new Event(event, _currentUserProvider.getUser(), eventParams));
196            
197            _workflowHelper.doAction(content, actionId);
198        }
199        catch (WorkflowException | InvalidActionException e)
200        {
201            resultMap.put("error", Boolean.TRUE);
202            logger.error("Unable to update workflow of content '{}' ({})", content.getTitle(), content.getId(), e);
203        }
204
205        resultMap.put("success", Boolean.TRUE);
206        return resultMap;
207    }
208
209    /**
210     * Update the invert relation by adding/removing the content to/from the old values.
211     * @param metadataToEdit Metadata holder to edit
212     * @param metadataName Metadata name to set
213     * @param content The content to add or remove
214     * @param remove <code>true</code> if we wan't to remove the content from the relation
215     * @return <code>true</code> if there are changes
216     */
217    public boolean updateRelation(ModifiableCompositeMetadata metadataToEdit, String metadataName, Content content, boolean remove)
218    {
219        return updateRelation(metadataToEdit, metadataName, content.getId(), remove);
220    }
221    
222    /**
223     * Update the invert relation by adding/removing the content to/from the old values.
224     * @param metadataToEdit Metadata holder to edit
225     * @param metadataName Metadata name to set
226     * @param contentId The content to add or remove
227     * @param remove <code>true</code> if we wan't to remove the content from the relation
228     * @return <code>true</code> if there are changes
229     */
230    public boolean updateRelation(ModifiableCompositeMetadata metadataToEdit, String metadataName, String contentId, boolean remove)
231    {
232        String[] oldValues = metadataToEdit.getStringArray(metadataName, new String[0]);
233        String[] newValues = null;
234        
235        // If we need to remove the value
236        if (remove && ArrayUtils.contains(oldValues, contentId))
237        {
238            newValues = ArrayUtils.removeElement(oldValues, contentId);
239        }
240        // If we need to add the value
241        else if (!remove && !ArrayUtils.contains(oldValues, contentId))
242        {
243            newValues = ArrayUtils.add(oldValues, contentId);
244        }
245        
246        // If there is a change to apply
247        if (newValues != null)
248        {
249            List<Content> contents = new ArrayList<>();
250            for (String value : newValues)
251            {
252                contents.add(_resolver.resolveById(value));
253            }
254            
255            return ExternalizableMetadataHelper.setMetadata(metadataToEdit, metadataName, contents.toArray(new Content[contents.size()]));
256        }
257        
258        return false;
259    }
260
261    /**
262     * Add the current synchronizable collection as property
263     * @param content The synchronized content
264     * @param collectionId The ID of the collection
265     * @throws RepositoryException if an error occurred
266     */
267    public void updateSCCProperty(DefaultContent content, String collectionId) throws RepositoryException
268    {
269        if (content.getNode().hasProperty(SynchronizableContentsCollection.COLLECTION_ID_PROPERTY))
270        {
271            Value[] values = content.getNode().getProperty(SynchronizableContentsCollection.COLLECTION_ID_PROPERTY).getValues();
272            Set<String> collectionIds = new HashSet<>();
273            for (Value value : values)
274            {
275                collectionIds.add(value.getString());
276            }
277            collectionIds.add(collectionId);
278            
279            content.getNode().setProperty(SynchronizableContentsCollection.COLLECTION_ID_PROPERTY, collectionIds.toArray(new String[] {}));
280        }
281        else
282        {
283            content.getNode().setProperty(SynchronizableContentsCollection.COLLECTION_ID_PROPERTY, new String[] {collectionId});
284        }
285    }
286
287    /**
288     * Creates content action with result from request
289     * @param contentType Type of the content to create
290     * @param workflowName Workflow to use for this content
291     * @param initialActionId Action ID for initialization
292     * @param lang The language
293     * @param contentTitle The content title
294     * @param contentPrefix The content prefix for the node creation
295     * @param logger The logger
296     * @return A {@link Map} with the created content in "content", and a {@link Boolean} in "error" if an error occurs.
297     */
298    public Map<String, Object> createContentAction(String contentType, String workflowName, int initialActionId, String lang, String contentTitle, String contentPrefix, Logger logger)
299    {
300        logger.info("Creating content '{}' with the content type '{}' for language {}", contentTitle, contentType, lang);
301        
302        Map<String, Object> resultMap = new HashMap<>();
303        
304        String contentName = _getContentName(contentTitle, lang, contentPrefix);
305        
306        Map<String, Object> inputs = new HashMap<>();
307        
308        inputs.put(CreateContentFunction.CONTENT_LANGUAGE_KEY, lang);
309        inputs.put(CreateContentFunction.CONTENT_NAME_KEY, contentName); 
310        inputs.put(CreateContentFunction.CONTENT_TITLE_KEY, contentTitle);
311        inputs.put(CreateContentFunction.CONTENT_TYPES_KEY, new String[] {contentType});
312        
313        Map<String, Object> results = new HashMap<>();
314        inputs.put(AbstractWorkflowComponent.RESULT_MAP_KEY, results);
315        
316        try
317        {
318            Map<String, Object> workflowResult = _workflowHelper.createContent(workflowName, initialActionId, contentName, contentTitle, new String[] {contentType}, null, lang);
319            resultMap.put("content", _resolver.resolveById((String) workflowResult.get("contentId")));
320        }
321        catch (WorkflowException e)
322        {
323            resultMap.put("error", Boolean.TRUE);
324            logger.error("Failed to initialize workflow for content " + contentTitle + " and language " + lang, e);
325        }
326        
327        return resultMap;
328    }
329
330    /**
331     * Gets the content name
332     * @param title The name
333     * @param lang The lang of the content
334     * @return The content name
335     */
336    private String _getContentName(String title, String lang, String prefix)
337    {
338        return FilterNameHelper.filterName(prefix + "-" + title + "-" + lang);
339    }
340    
341    /**
342     * Fill the metadata with remove value.
343     * @param content The content to synchronize
344     * @param contentType The content type
345     * @param logicalMetadataPath The logical metadata path without the entries
346     * @param completeMetadataPath The complete metadata path from the root of the content
347     * @param remoteValue The remote value
348     * @param synchronize <code>true</code> if synchronizable
349     * @param create <code>true</code> if content is creating, false if it is updated
350     * @param logger The logger
351     * @return A {@link Map} with a {@link Boolean} in "hasChanges" value if changes has been made, and a {@link Boolean} in "error" value if an error occurs.
352     */
353    public Map<String, Boolean> synchronizeMetadata(ModifiableDefaultContent content, ContentType contentType, String logicalMetadataPath, String completeMetadataPath, List<Object> remoteValue, boolean synchronize, boolean create, Logger logger)
354    {
355        MetadataDefinition metadataDef = contentType.getMetadataDefinitionByPath(logicalMetadataPath);
356        ModifiableCompositeMetadata metadataHolder = getMetadataHolder(content.getMetadataHolder(), completeMetadataPath);
357        String[] arrayPath = completeMetadataPath.split("/");
358        String metadataName = arrayPath[arrayPath.length - 1];
359        
360        if (metadataDef != null)
361        {
362            if (remoteValue != null && !remoteValue.isEmpty())
363            {
364                Object valueToSet;
365                if (metadataDef.getType().equals(MetadataType.RICH_TEXT))
366                {
367                    valueToSet = remoteValue.get(0); // remoteValue is not empty at this stage
368                    return ImmutableMap.of("hasChanges", _setRichTextMetadata(metadataHolder, metadataName, valueToSet, synchronize, content.getTitle(), logger));
369                }
370                else if (metadataDef.getType().equals(MetadataType.BINARY))
371                {
372                    valueToSet = remoteValue.get(0); // remoteValue is not empty at this stage
373                    return ImmutableMap.of("hasChanges", _setBinaryMetadata(metadataHolder, metadataName, valueToSet, synchronize, content.getTitle(), logger));
374                }
375                else if (metadataDef.isMultiple())
376                {
377                    valueToSet = _toTypedArray(remoteValue.get(0).getClass(), remoteValue);
378                    return _setMetadata(metadataHolder, metadataName, valueToSet, synchronize, create, content.getTitle(), contentType.getId(), logger);
379                }
380                else
381                {
382                    valueToSet = remoteValue.get(0); // remoteValue is not empty at this stage
383                    return _setMetadata(metadataHolder, metadataName, valueToSet, synchronize, create, content.getTitle(), contentType.getId(), logger);
384                }
385            }
386            else if (metadataDef.getDefaultValue() != null)
387            {
388                return _setMetadata(metadataHolder, metadataName, metadataDef.getDefaultValue(), synchronize, create, content.getTitle(), contentType.getId(), logger);
389            }
390        }
391
392        return ImmutableMap.of("hasChanges", DefaultContent.METADATA_TITLE.equals(metadataName) ? false : removeMetadataIfExists(metadataHolder, metadataName, synchronize));
393    }
394    
395    /**
396     * Set the richtext metadata
397     * @param metadataHolder the metadata holder
398     * @param metadataName the metadata name
399     * @param valueToSet the value to set
400     * @param synchronize true if the metadata is synchronize
401     * @param title the content title
402     * @param logger the logger
403     * @return <code>true</code> if changes were made
404     */
405    private boolean _setRichTextMetadata(ModifiableCompositeMetadata metadataHolder, String metadataName, Object valueToSet, boolean synchronize, String title, Logger logger)
406    {
407        try
408        {
409            String docbook = ContentImporterHelper.textToDocbook(_getLinesFromValue(valueToSet));
410        
411            ByteArrayInputStream is =  new ByteArrayInputStream(docbook.getBytes(StandardCharsets.UTF_8));
412            ModifiableRichText richText = ExternalizableMetadataHelper.getRichText(metadataHolder, metadataName, synchronize ? ExternalizableMetadataStatus.EXTERNAL : ExternalizableMetadataStatus.LOCAL, true);
413            richText.setInputStream(is);
414            richText.setMimeType("text/xml");
415            richText.setLastModified(new Date());
416            
417            return true;
418        }
419        catch (IOException e)
420        {
421            logger.error("An error occured while setting the rich text value for metadata '{}' of the content '{}'", metadataName, title, e);
422            return false;
423        }
424    }
425    
426    /**
427     * Set the binary metadata
428     * @param metadataHolder the metadata holder
429     * @param metadataName the metadata name
430     * @param valueToSet the value to set
431     * @param synchronize true if the metadata is synchronize
432     * @param title the content title
433     * @param logger the logger
434     * @return <code>true</code> if changes were made
435     */
436    protected boolean _setBinaryMetadata(ModifiableCompositeMetadata metadataHolder, String metadataName, Object valueToSet, boolean synchronize, String title, Logger logger)
437    {
438        try
439        {
440            byte[] bytes = _getBytesFromValue(valueToSet);
441            
442            ByteArrayInputStream is =  new ByteArrayInputStream(bytes);
443            ModifiableBinaryMetadata binary = ExternalizableMetadataHelper.getBinaryMetadata(metadataHolder, metadataName, synchronize ? ExternalizableMetadataStatus.EXTERNAL : ExternalizableMetadataStatus.LOCAL, true);
444            binary.setInputStream(is);
445            binary.setMimeType("application/unknown");
446            binary.setLastModified(new Date());
447            binary.setFilename("noname.ext"); 
448            
449            return true;
450        }
451        catch (IOException e)
452        {
453            logger.error("An error occured while setting the binary value for metadata '{}' of the content '{}'", metadataName, title, e);
454            return false;
455        }
456    }
457
458    /**
459     * Get the bytes array from the value to set
460     * @param valueToSet the value to set
461     * @return the bytes array
462     * @throws IOException if an error occurred
463     */
464    private byte[] _getBytesFromValue(Object valueToSet) throws IOException
465    {
466        if (valueToSet instanceof byte[])
467        {
468            return (byte[]) valueToSet;
469        }
470        
471        return IOUtils.toByteArray(new StringReader(valueToSet.toString()), StandardCharsets.UTF_8);
472    }
473
474    /**
475     * Get the lines array from valueToSet for the richText
476     * @param valueToSet the value to set
477     * @return the lines array
478     * @throws IOException if an error occurred
479     */
480    private String[] _getLinesFromValue(Object valueToSet) throws IOException
481    {
482        if (valueToSet instanceof byte[])
483        {
484            String strValue = IOUtils.toString((byte[]) valueToSet, "UTF-8");
485            return new String[] {CharMatcher.javaIsoControl().and(CharMatcher.anyOf("\r\n\t").negate()).removeFrom(strValue)};
486        }
487        
488        return new String[] {valueToSet.toString()};
489    }
490
491    private Map<String, Boolean> _setMetadata(ModifiableCompositeMetadata metadataHolder, String metadataName, Object valueToSet, boolean synchronize, boolean forceExternalStatus, String contentTitle, String contentType, Logger logger)
492    {
493        Map<String, Boolean> resultMap = new HashMap<>();
494        
495        try
496        {
497            boolean hasChanges;
498            if (synchronize)
499            {
500                hasChanges = ExternalizableMetadataHelper.setExternalMetadata(metadataHolder, metadataName, valueToSet, forceExternalStatus);
501            }
502            else
503            {
504                hasChanges = ExternalizableMetadataHelper.setMetadata(metadataHolder, metadataName, valueToSet);
505            }
506            resultMap.put("hasChanges", hasChanges);
507        }
508        catch (UnsupportedOperationException e)
509        {
510            logger.error("An error occured during the synchronization of the field '{}' with the value '{}' in the content '{}' type of '{}'", metadataName, valueToSet.toString(), contentTitle, contentType);
511            resultMap.put("error", Boolean.TRUE);
512        }
513        return resultMap;
514    }
515    
516    /**
517     * Get the metadata holder for the requested metadata path.
518     * @param parentMetadata Initial metadata
519     * @param metadataPath Metadata path from the parent
520     * @return A metadata holder
521     */
522    public ModifiableCompositeMetadata getMetadataHolder(ModifiableCompositeMetadata parentMetadata, String metadataPath)
523    {
524        int pos = metadataPath.indexOf("/");
525        if (pos == -1)
526        {
527            return parentMetadata;
528        }
529        else
530        {
531            return getMetadataHolder(parentMetadata.getCompositeMetadata(metadataPath.substring(0, pos), true), metadataPath.substring(pos + 1));
532        }
533    }
534    
535    // CONTENTIO-95 Multiple values are always converted as Object[] and cannot be set
536    /**
537     * This method have been build to go through the CONTENTIO-95 problem. The toArray() method returns an Object[] and it's not supported by {@link ExternalizableMetadataHelper} methods.
538     * @param <T> The type of the array
539     * @param classToCastTo The class to cast to
540     * @param list The list to transform to an array
541     * @return A typed array.
542     */
543    @SuppressWarnings({"cast", "unchecked"})
544    private static <T> T[] _toTypedArray(Class<?> classToCastTo, List<Object> list)
545    {
546        // Cast to Content if instance of Content instead of another type (for structures containing both Container and SubProgram)
547        if (Content.class.isAssignableFrom(classToCastTo))
548        {
549            return (T[]) list.toArray((Content[]) Array.newInstance(Content.class, list.size()));
550        }
551        return (T[]) list.toArray((T[]) Array.newInstance(classToCastTo, list.size()));
552    }
553}