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