001/*
002 *  Copyright 2013 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.cms.content;
017
018import java.io.InputStream;
019import java.io.OutputStream;
020import java.util.ArrayList;
021import java.util.Arrays;
022import java.util.HashMap;
023import java.util.HashSet;
024import java.util.List;
025import java.util.Map;
026import java.util.Map.Entry;
027import java.util.Optional;
028import java.util.Properties;
029import java.util.Set;
030
031import javax.xml.transform.OutputKeys;
032import javax.xml.transform.TransformerFactory;
033import javax.xml.transform.sax.SAXTransformerFactory;
034import javax.xml.transform.sax.TransformerHandler;
035import javax.xml.transform.stream.StreamResult;
036
037import org.apache.avalon.framework.component.Component;
038import org.apache.avalon.framework.service.ServiceException;
039import org.apache.avalon.framework.service.ServiceManager;
040import org.apache.avalon.framework.service.Serviceable;
041import org.apache.cocoon.ProcessingException;
042import org.apache.commons.lang3.StringUtils;
043import org.apache.commons.lang3.tuple.Pair;
044import org.apache.excalibur.xml.sax.ContentHandlerProxy;
045import org.apache.excalibur.xml.sax.SAXParser;
046import org.apache.xml.serializer.OutputPropertiesFactory;
047import org.slf4j.Logger;
048import org.xml.sax.Attributes;
049import org.xml.sax.ContentHandler;
050import org.xml.sax.InputSource;
051import org.xml.sax.SAXException;
052
053import org.ametys.cms.content.CopyReport.CopyMode;
054import org.ametys.cms.content.CopyReport.CopyState;
055import org.ametys.cms.contenttype.ContentType;
056import org.ametys.cms.contenttype.ContentTypesHelper;
057import org.ametys.cms.contenttype.RichTextUpdater;
058import org.ametys.cms.data.ContentValue;
059import org.ametys.cms.data.RichText;
060import org.ametys.cms.data.type.ModelItemTypeConstants;
061import org.ametys.cms.repository.Content;
062import org.ametys.cms.repository.DefaultContent;
063import org.ametys.cms.repository.ModifiableContent;
064import org.ametys.cms.repository.WorkflowAwareContent;
065import org.ametys.cms.workflow.ContentWorkflowHelper;
066import org.ametys.cms.workflow.CreateContentFunction;
067import org.ametys.cms.workflow.copy.CreateContentByCopyFunction;
068import org.ametys.plugins.explorer.resources.ModifiableResourceCollection;
069import org.ametys.plugins.explorer.resources.ResourceCollection;
070import org.ametys.plugins.explorer.resources.jcr.JCRResourcesCollectionFactory;
071import org.ametys.plugins.repository.AmetysObject;
072import org.ametys.plugins.repository.AmetysObjectResolver;
073import org.ametys.plugins.repository.AmetysRepositoryException;
074import org.ametys.plugins.repository.CopiableAmetysObject;
075import org.ametys.plugins.repository.ModifiableTraversableAmetysObject;
076import org.ametys.plugins.repository.model.ViewHelper;
077import org.ametys.plugins.workflow.AbstractWorkflowComponent;
078import org.ametys.plugins.workflow.support.WorkflowProvider;
079import org.ametys.plugins.workflow.support.WorkflowProvider.AmetysObjectWorkflow;
080import org.ametys.runtime.model.ElementDefinition;
081import org.ametys.runtime.model.View;
082import org.ametys.runtime.model.ViewItemContainer;
083import org.ametys.runtime.plugin.component.AbstractLogEnabled;
084
085/**
086 * <p>
087 * This component is used to copy a content (either totally or partially).
088 * </p><p>
089 * In this whole file a Map named <em>copyMap</em> is regularly used. This map
090 * provide the name of the attribute to copy as well as some optional parameters.
091 * It has the following form (JSON) :
092 * </p>
093 * <pre>
094 * {
095 *   "$param1": value,
096 *   "attributeA": null,
097 *   "attributeB": {
098 *     "subattributeB1": null,
099 *     "subattributeB2": {
100 *       "$param1": value,
101 *       "$param2": value,
102 *       "subSubattributeB21": {...}
103 *     },
104 *     ...
105 *   }
106 * }
107 * </pre>
108 * <p>
109 * Each attribute that should be copied must be present as a key in the map.
110 * Composite attribute can contains child attributes but as seen on the example the
111 * map must be well structured, it is not a flat map. Parameters in the map must
112 * always start with the reserved character '$', in order to be differentiated
113 * from attribute name.
114 * </p><p>
115 * The entry points are the copyContent and editContent methods, which run a dedicated workflow
116 * function (createByCopy or edit).<br>
117 * Actual write of values is made through the EditContentFunction, with the values computed by this component.
118 */
119public class CopyContentComponent extends AbstractLogEnabled implements Serviceable, Component
120{
121    /** Avalon ROLE. */
122    public static final String ROLE = CopyContentComponent.class.getName();
123    
124    /** Workflow provider. */
125    protected WorkflowProvider _workflowProvider;
126    
127    /** Ametys object resolver available to subclasses. */
128    protected AmetysObjectResolver _resolver;
129    
130    /** Helper for content types */
131    protected ContentTypesHelper _contentTypesHelper;
132    
133    /** The content helper */
134    protected ContentHelper _contentHelper;
135    
136    /** The content workflow helper */
137    protected ContentWorkflowHelper _contentWorkflowHelper;
138    
139    /** Avalon service manager */
140    protected ServiceManager _manager;
141    
142    @Override
143    public void service(ServiceManager manager) throws ServiceException
144    {
145        _workflowProvider = (WorkflowProvider) manager.lookup(WorkflowProvider.ROLE);
146        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
147        _contentTypesHelper = (ContentTypesHelper) manager.lookup(ContentTypesHelper.ROLE);
148        _contentHelper = (ContentHelper) manager.lookup(ContentHelper.ROLE);
149        _contentWorkflowHelper = (ContentWorkflowHelper) manager.lookup(ContentWorkflowHelper.ROLE);
150        _manager = manager;
151    }
152    
153    /**
154     * Copy a content by creating a new content and copying the attributes value a source content into the new one.
155     * @param contentId The source content id
156     * @param title Desired title for the new content or null if computed from the source's title
157     * @param copyMap The map of properties as described in {@link CopyContentComponent}. 
158     *                Can be null in which case the map will be constructed from the provided view.
159     * @param viewName The name of the view to be used to construct to copyMap if not provided. This will also be the
160     *                 default name for possible inner copies (if not provided as a copyMap parameter).
161     * @param fallbackViewName The fallback view name if 'viewName' is not found
162     * @param targetContentType The type of content to create. If null the type(s) of created content will be those of base content.
163     * @param initActionId The init workflow action id for main content only
164     * @return The copy report containing valuable information about the copy and the possible encountered errors.
165     */
166    public CopyReport copyContent(String contentId, String title, Map<String, Object> copyMap, String viewName, String fallbackViewName, String targetContentType, int initActionId)
167    {
168        Content content = _resolver.resolveById(contentId);
169        CopyReport report = new CopyReport(contentId, _contentHelper.getTitle(content), _contentHelper.isReferenceTable(content), viewName, fallbackViewName, CopyMode.CREATION);
170        
171        try
172        {
173            Map<String, Object> inputs = getInputsForCopy(content, title, copyMap, targetContentType, report);
174            String workflowName = getWorkflowName(content, inputs);
175            
176            AmetysObjectWorkflow workflow = _workflowProvider.getAmetysObjectWorkflow();
177            workflow.initialize(workflowName, initActionId, inputs);
178            
179            ModifiableContent targetContent = workflow.getAmetysObject();
180            
181            report.notifyContentCreation(targetContent.getId(), _contentHelper.getTitle(targetContent), _contentHelper.isReferenceTable(content));
182            report.notifyContentCopySuccess();
183        }
184        catch (Exception e)
185        {
186            getLogger().error("An error has been encountered during the content copy, or the copy is not allowed (base content identifier : {}).", contentId, e);
187            report.notifyContentCopyError();
188        }
189        
190        return report;
191    }
192    
193    /**
194     * Retrieve the inputs for the copy workflow function.
195     * @param baseContent The content to copy
196     * @param title The title to set
197     * @param copyMap The map with properties to copy
198     * @param targetContentType The type of content to create. If null the type(s) of created content will be those of base content.
199     * @param copyReport The report of the copy
200     * @return The map of inputs.
201     */
202    protected Map<String, Object> getInputsForCopy(Content baseContent, String title, Map<String, Object> copyMap, String targetContentType, CopyReport copyReport)
203    {
204        Map<String, Object> inputs = new HashMap<>();
205        
206        inputs.put(CreateContentByCopyFunction.BASE_CONTENT_KEY, baseContent);
207        inputs.put(CreateContentByCopyFunction.COPY_MAP_KEY, copyMap);
208        inputs.put(CreateContentByCopyFunction.COPY_REPORT_KEY, copyReport);
209        inputs.put(CreateContentByCopyFunction.COPY_VIEW_NAME, copyReport.getViewName());
210        inputs.put(CreateContentByCopyFunction.COPY_FALLBACK_VIEW_NAME, copyReport.getFallbackViewName());
211        
212        if (StringUtils.isNoneBlank(title))
213        {
214            inputs.put(CreateContentFunction.CONTENT_TITLE_KEY, title);
215        }
216
217        inputs.put(AbstractWorkflowComponent.RESULT_MAP_KEY, new HashMap<String, Object>());
218        inputs.put(AbstractWorkflowComponent.FAIL_CONDITIONS_KEY, new ArrayList<String>());
219        
220        if (targetContentType != null)
221        {
222            inputs.put(CreateContentFunction.CONTENT_TYPES_KEY, new String[] {targetContentType});
223        }
224        
225        return inputs;
226    }
227    
228    /**
229     * Retrieve the workflow name of a content.
230     * @param content The content to consider
231     * @param inputs The inputs that will be provided to the workflow function
232     * @return The name of the workflow.
233     * @throws IllegalArgumentException if the content is not workflow aware.
234     */
235    protected String getWorkflowName(Content content, Map<String, Object> inputs) throws IllegalArgumentException
236    {
237        String workflowName = null;
238        
239        if (content instanceof WorkflowAwareContent)
240        {
241            WorkflowAwareContent waContent = (WorkflowAwareContent) content;
242            AmetysObjectWorkflow workflow = _workflowProvider.getAmetysObjectWorkflow(waContent);
243            workflowName = workflow.getWorkflowName(waContent.getWorkflowId());
244        }
245        
246        if (workflowName == null)
247        {
248            String errorMsg = String.format("Unable to retrieve the workflow name for the content with identifier '%s'.", content.getId());
249            
250            getLogger().error(errorMsg);
251            throw new IllegalArgumentException(errorMsg);
252        }
253        
254        return workflowName;
255    }
256    
257    /**
258     * Edit a content by copying attribute values a source content into a target content.
259     * @param contentId The identifier of the source content
260     * @param targetContentId The identifier of the target content
261     * @param copyMap The map of properties as described in {@link CopyContentComponent}. 
262     *                Can be null in which case the map will be constructed from the provided view.
263     * @param viewName The name of the view to be used to construct to copyMap if not provided. This will also be the
264     *                 default name for possible inner copies (if not provided as a copyMap parameter).
265     * @param fallbackViewName The fallback view name if 'viewName' is not found
266     * @return The copy report containing valuable information about the copy and the possible encountered errors.
267     */
268    public CopyReport editContent(String contentId, String targetContentId, Map<String, Object> copyMap, String viewName, String fallbackViewName)
269    {
270        return editContent(contentId, targetContentId, copyMap, viewName, fallbackViewName, getDefaultActionIdForContentEdition());
271    }
272
273    /**
274     * Edit a content by copying attribute values a source content into a target content.
275     * @param contentId The identifier of the source content
276     * @param targetContentId The identifier of the target content
277     * @param copyMap The map of properties as described in {@link CopyContentComponent}. 
278     *                Can be null in which case the map will be constructed from the provided view.
279     * @param viewName The name of the view to be used to construct to copyMap if not provided. This will also be the
280     *                 default name for possible inner copies (if not provided as a copyMap parameter).
281     * @param fallbackViewName The fallback view name if 'viewName' is not found
282     * @param actionId the edit workflow action id
283     * @return The copy report containing valuable information about the copy and the possible encountered errors.
284     */
285    public CopyReport editContent(String contentId, String targetContentId, Map<String, Object> copyMap, String viewName, String fallbackViewName, int actionId)
286    {
287        Content content = _resolver.resolveById(contentId);
288        
289        String auxViewName = StringUtils.defaultIfEmpty(viewName, "default-edition");
290        String auxFallbackViewName = StringUtils.defaultIfEmpty(fallbackViewName, "main");
291        
292        CopyReport report = new CopyReport(contentId, _contentHelper.getTitle(content), _contentHelper.isReferenceTable(content), auxViewName, auxFallbackViewName, CopyMode.EDITION);
293        try
294        {
295            WorkflowAwareContent targetContent = _resolver.resolveById(targetContentId);
296            
297            Map<String, Object> values = computeValues(content, (ModifiableContent) targetContent, copyMap, null, auxViewName, auxFallbackViewName, report);
298            
299            _contentWorkflowHelper.editContent(targetContent, values, actionId);
300            
301            report.notifyContentCreation(targetContent.getId(), _contentHelper.getTitle(targetContent), _contentHelper.isReferenceTable(content));
302            report.notifyContentCopySuccess();
303        }
304        catch (Exception e)
305        {
306            getLogger().error("An error has been encountered during the content edition, or the edition is not allowed (base content identifier : {}, target content identifier : {}).",
307                    contentId, targetContentId, e);
308            
309            report.notifyContentCopyError();
310        }
311        
312        return report;
313    }
314    
315    /**
316     * Extract values to copy from the given parameters.
317     * @param content the source content
318     * @param targetContent the target content
319     * @param copyMap the map of properties as described in {@link CopyContentComponent}. 
320     * @param additionalCopyMap an additional map of properties if needed. Can be null. Often used in case of recursive copies.
321     * @param viewName The name of the view to be used to construct to copyMap if not provided. This will also be the
322     *                 default name for possible inner copies (if not provided as a copyMap parameter).
323     * @param fallbackViewName The fallback view name if 'viewName' is not found
324     * @param copyReport The copy report containing valuable information about the copy and the possible encountered errors.
325     * @return the computed values, ready to be synchronized to the target content.
326     */
327    public Map<String, Object> computeValues(Content content, ModifiableContent targetContent, Map<String, Object> copyMap, Map<String, Object> additionalCopyMap, String viewName, String fallbackViewName, CopyReport copyReport)
328    {
329        Pair<View, Map<String, Object>> viewAndValues = _getViewAndValues(content, copyMap, additionalCopyMap, viewName, fallbackViewName, copyReport);
330        View view = viewAndValues.getLeft();
331        Map<String, Object> values = viewAndValues.getRight();
332
333        _updateRichTexts(content, targetContent, view, values, copyReport);
334        
335        return values;
336    }
337    
338    private Pair<View, Map<String, Object>> _getViewAndValues(Content content, Map<String, Object> copyMap, Map<String, Object> additionalCopyMap, String viewName, String fallbackViewName, CopyReport copyReport)
339    {
340        Set<String> viewItems;
341        Map<String, Map<String, Object>> contentToCopy = new HashMap<>();
342        
343        Map<String, Object> finalCopyMap = null;
344        if (copyMap != null)
345        {
346            finalCopyMap = new HashMap<>();
347            for (Entry<String, Object> entry : copyMap.entrySet())
348            {
349                if (!entry.getKey().startsWith("$"))
350                {
351                    // cannot use stream here as entry values are often null and Collectors.toMap don't like that...
352                    finalCopyMap.put(entry.getKey(), entry.getValue());
353                }
354            }
355        }
356        
357        if (finalCopyMap != null && !finalCopyMap.isEmpty())
358        {
359            viewItems = new HashSet<>();
360            _fillViewItemsFromCopyMap(content, viewItems, contentToCopy, finalCopyMap, "");
361        }
362        else
363        {
364            View view = _contentTypesHelper.getViewWithFallback(viewName, fallbackViewName, content.getTypes(), content.getMixinTypes());
365            viewItems = new HashSet<>(org.ametys.runtime.model.ViewHelper.getModelItemsPathsFromView(view));
366        }
367        
368        if (additionalCopyMap != null)
369        {
370            _fillViewItemsFromCopyMap(content, viewItems, contentToCopy, additionalCopyMap, "");
371        }
372        
373        View view = View.of(content.getModel(), viewItems.toArray(String[]::new));
374        Map<String, Object> values = content.dataToMap(view);
375
376        _processLinkedContents(content, view, values, contentToCopy, copyReport);
377
378        return Pair.of(view, values);
379    }
380    
381    @SuppressWarnings("unchecked")
382    private void _fillViewItemsFromCopyMap(Content content, Set<String> items, Map<String, Map<String, Object>> contentToCopy, Map<String, Object> copyMap, String prefix)
383    {
384        for (String name : copyMap.keySet())
385        {
386            if (!name.startsWith("$"))
387            {
388                Object value = copyMap.get(name);
389                if (value == null)
390                {
391                    items.add(prefix + name);
392                }
393                else
394                {
395                    Map<String, Object> subCopyMap = (Map<String, Object>) value;
396                    if (subCopyMap.containsKey("$mode"))
397                    {
398                        // content attribute
399                        items.add(prefix + name);
400                        contentToCopy.put(prefix + name, subCopyMap);
401                    }
402                    else
403                    {
404                        // composite or repeater
405                        _fillViewItemsFromCopyMap(content, items, contentToCopy, subCopyMap, prefix + name + "/");
406                    }
407                }
408            }
409        }
410    }
411    
412    @SuppressWarnings("unchecked")
413    private void _processLinkedContents(Content content, ViewItemContainer viewItemContainer, Map<String, Object> values, Map<String, Map<String, Object>> contentToCopy, CopyReport copyReport)
414    {
415        ViewHelper.visitView(viewItemContainer, 
416            (element, definition) -> {
417                // simple element
418                String name = definition.getName();
419                String path = definition.getPath();
420                Object value = values.get(name);
421                if (value != null && definition.getType().getId().equals(ModelItemTypeConstants.CONTENT_ELEMENT_TYPE_ID))
422                {
423                    // create the content and replace the old value by the new one
424                    Map<String, Object> copyMap = contentToCopy.get(path);
425                    boolean referenceMode = copyMap == null || !"create".equals(copyMap.get("$mode"));
426                    
427                    if (definition.isMultiple())
428                    {
429                        ContentValue[] contentValues = (ContentValue[]) value;
430                        List<ContentValue> targets = new ArrayList<>();
431                        
432                        Arrays.stream(contentValues)
433                              .map(contentValue -> contentValue.getContentIfExists())
434                              .flatMap(Optional::stream)
435                              .forEach(subContent -> {
436                                  ContentValue contentValue = handleLinkedContent(definition, subContent, referenceMode, copyMap, copyReport);
437                                  if (contentValue != null)
438                                  {
439                                      targets.add(contentValue);
440                                  }
441                              });
442                        
443                        values.put(name, targets.toArray(ContentValue[]::new));
444                    }
445                    else
446                    {
447                        ContentValue contentValue = (ContentValue) value;
448                        ModifiableContent subContent = contentValue.getContentIfExists().orElse(null);
449                        if (subContent != null)
450                        {
451                            ContentValue linkedValue = handleLinkedContent(definition, subContent, referenceMode, copyMap, copyReport);
452                            values.put(name, linkedValue);
453                        }
454                    }
455                }
456            }, 
457            (group, definition) -> {
458                // composite
459                String name = definition.getName();
460                Map<String, Object> composite = (Map<String, Object>) values.get(name);
461                if (composite != null)
462                {
463                    _processLinkedContents(content, group, composite, contentToCopy, copyReport);
464                }
465            }, 
466            (group, definition) -> {
467                // repeater
468                String name = definition.getName();
469                List<Map<String, Object>> entries = (List<Map<String, Object>>) values.get(name);
470                if (entries != null)
471                {
472                    for (Map<String, Object> entry : entries)
473                    {
474                        _processLinkedContents(content, group, entry, contentToCopy, copyReport);
475                    }
476                }
477            }, 
478            group -> _processLinkedContents(content, group, values, contentToCopy, copyReport));
479    }
480    
481    /**
482     * Handle a single value of a content attribute
483     * @param definition the attribute definition
484     * @param value the linked value on the source content
485     * @param referenceMode true if a reference was initially requested, false if it was a copy
486     * @param copyMap the current copy map
487     * @param copyReport the copy report
488     * @return the {@link ContentValue} (copied or not) to insert in the current Content. 
489     */
490    protected ContentValue handleLinkedContent(ElementDefinition definition, ModifiableContent value, boolean referenceMode, Map<String, Object> copyMap, CopyReport copyReport)
491    {
492        if (!referenceMode)
493        {
494            String targetContentId = copyLinkedContent(value, copyMap, copyReport);
495            if (targetContentId != null)
496            {
497                return new ContentValue(_resolver, targetContentId);
498            }
499        }
500        else
501        {
502            return new ContentValue(value);
503        }
504        
505        return null;
506    }
507    
508    /**
509     * Copy a value of a content attribute.
510     * @param content the initial content value.
511     * @param copyMap the current copy map
512     * @param copyReport the current copy report
513     * @return the id of the copied Content.
514     */
515    protected String copyLinkedContent(Content content, Map<String, Object> copyMap, CopyReport copyReport)
516    {
517        String defaultViewName = copyMap != null ? (String) copyMap.getOrDefault("$viewName", copyReport.getViewName()) : copyReport.getViewName();
518        String defaultFallbackViewName = copyMap != null ? (String) copyMap.getOrDefault("$fallbackViewName", copyReport.getFallbackViewName()) : copyReport.getFallbackViewName();
519        
520        CopyReport innerReport = copyContent(content.getId(), content.getTitle(), copyMap, defaultViewName, defaultFallbackViewName, null, getDefaultInitActionId());
521        
522        String targetContentId = null;
523        if (innerReport.getStatus() == CopyState.SUCCESS)
524        {
525            targetContentId = innerReport.getTargetContentId();
526        }
527        
528        copyReport.addReport(innerReport);
529        
530        return targetContentId;
531    }
532    
533    @SuppressWarnings("unchecked")
534    private void _updateRichTexts(Content content, ModifiableContent targetContent, ViewItemContainer viewItemContainer, Map<String, Object> values, CopyReport copyReport)
535    {
536        ViewHelper.visitView(viewItemContainer, 
537            (element, definition) -> {
538                // simple element
539                String name = definition.getName();
540                Object value = values.get(name);
541                if (value != null && definition.getType().getId().equals(ModelItemTypeConstants.RICH_TEXT_ELEMENT_TYPE_ID))
542                {
543                    // update richTexts
544                    RichTextUpdater richTextUpdater = ((ContentType) definition.getModel()).getRichTextUpdater();
545                    if (richTextUpdater != null)
546                    {
547                        if (definition.isMultiple())
548                        {
549                            RichText[] richTexts = (RichText[]) value;
550                            for (RichText richText : richTexts)
551                            {
552                                _updateRichText(richText, richTextUpdater, content, targetContent, copyReport);
553                            }
554                        }
555                        else
556                        {
557                            RichText richText = (RichText) value;
558                            _updateRichText(richText, richTextUpdater, content, targetContent, copyReport);
559                        }
560                    }
561                }
562            }, 
563            (group, definition) -> {
564                // composite
565                String name = definition.getName();
566                Map<String, Object> composite = (Map<String, Object>) values.get(name);
567                if (composite != null)
568                {
569                    _updateRichTexts(content, targetContent, group, composite, copyReport);
570                }
571            }, 
572            (group, definition) -> {
573                // repeater
574                String name = definition.getName();
575                List<Map<String, Object>> entries = (List<Map<String, Object>>) values.get(name);
576                if (entries != null)
577                {
578                    for (Map<String, Object> entry : entries)
579                    {
580                        _updateRichTexts(content, targetContent, group, entry, copyReport);
581                    }
582                }
583            }, 
584            group -> _updateRichTexts(content, targetContent, group, values, copyReport));
585    }
586    
587    private void _updateRichText(RichText richText, RichTextUpdater richTextUpdater, Content initialContent, ModifiableContent targetContent, CopyReport copyReport)
588    {
589        try
590        {
591            Map<String, Object> params = new HashMap<>();
592            params.put("initialContent", initialContent);
593            params.put("createdContent", targetContent);
594            params.put("initialAO", initialContent);
595            params.put("createdAO", targetContent);
596            
597            // create the transformer instance
598            TransformerHandler th = ((SAXTransformerFactory) TransformerFactory.newInstance()).newTransformerHandler();
599            
600            // create the format of result
601            Properties format = new Properties();
602            format.put(OutputKeys.METHOD, "xml");
603            format.put(OutputKeys.INDENT, "yes");
604            format.put(OutputKeys.ENCODING, "UTF-8");
605            format.put(OutputPropertiesFactory.S_KEY_INDENT_AMOUNT, "2");
606            th.getTransformer().setOutputProperties(format);
607            
608            // Update rich text contents
609            // Copy the needed original attachments.
610            try (InputStream is = richText.getInputStream(); OutputStream os = richText.getOutputStream())
611            {
612                StreamResult result = new StreamResult(os);
613                th.setResult(result);
614                
615                ContentHandler richTextHandler = richTextUpdater.getContentHandler(th, th, params);
616                
617                // Copy attachments handler.
618                ContentHandlerProxy copyAttachmentsHandler = new CopyAttachmentsHandler(richTextHandler, initialContent, targetContent, copyReport, _resolver, getLogger());
619                
620                // Rich text update.
621                SAXParser saxParser = null;
622                try
623                {
624                    saxParser = (SAXParser) _manager.lookup(SAXParser.ROLE);
625                    saxParser.parse(new InputSource(is), copyAttachmentsHandler);
626                }
627                catch (ServiceException e)
628                {
629                    throw new ProcessingException("Unable to get a SAX parser", e);
630                }
631                finally
632                {
633                    _manager.release(saxParser);
634                }
635            }
636        }
637        catch (Exception e)
638        {
639            getLogger().error("An error occurred while updating rich text attribute for content '{}' after copy from initial content '{}'", targetContent.getId(), initialContent.getId(), e);
640        }
641    }
642    
643    /**
644     * Get the default workflow action id for initialization of main content
645     * @return the default action id
646     */
647    public int getDefaultInitActionId ()
648    {
649        return 111;
650    }
651    
652    /**
653     * Get the default workflow action id for editing content by copy
654     * @return the default action id
655     */
656    public int getDefaultActionIdForContentEdition()
657    {
658        return 222;
659    }
660    
661    /**
662     * A copy attachments content handler.
663     * To be used to copy the attachments linked in a rich text attribute.
664     */
665    protected static class CopyAttachmentsHandler extends ContentHandlerProxy
666    {
667        /** base content */
668        protected Content _baseContent;
669        /** target content */
670        protected ModifiableContent _targetContent;
671        /** copy report */
672        protected CopyReport _copyReport;
673        /** Ametys object resolver */
674        protected AmetysObjectResolver _resolver;
675        /** logger */
676        protected Logger _logger;
677        
678        /**
679         * Ctor
680         * @param contentHandler The content handler to delegate to.
681         * @param baseContent The content to copy
682         * @param targetContent The content where to copy
683         * @param copyReport The report of the copy
684         * @param resolver The ametys object resolver
685         * @param logger A logger to log informations
686         */
687        protected CopyAttachmentsHandler(ContentHandler contentHandler, Content baseContent, ModifiableContent targetContent, CopyReport copyReport, AmetysObjectResolver resolver, Logger logger)
688        {
689            super(contentHandler);
690            _baseContent = baseContent;
691            _targetContent = targetContent;
692            _copyReport = copyReport;
693            _resolver = resolver;
694            _logger = logger;
695        }
696        
697        @Override
698        public void startElement(String uri, String loc, String raw, Attributes attrs) throws SAXException
699        {
700            if ("link".equals(loc))
701            {
702                // Copy attachment
703                _copyIfAttachment(attrs.getValue("xlink:href"));
704            }
705            
706            super.startElement(uri, loc, raw, attrs);
707        }
708        
709        /**
710         * Copy the linked resource to the new content if it is an attachment. 
711         * @param href link href attribute
712         */
713        protected void _copyIfAttachment(String href)
714        {
715            try
716            {
717                if (_baseContent.getId().equals(href) || _targetContent.getId().equals(href))
718                {
719                    // nothing to do
720                    return;
721                }
722                else if (_resolver.hasAmetysObjectForId(href))
723                {
724                    AmetysObject ametysObject = _resolver.resolveById(href);
725                    
726                    ResourceCollection baseRootAttachments = _baseContent.getRootAttachments();
727                    if (!(ametysObject instanceof org.ametys.plugins.explorer.resources.Resource) || baseRootAttachments == null)
728                    {
729                        // nothing to do
730                        return;
731                    }
732                    
733                    String baseAttachmentsPath = _baseContent.getRootAttachments().getPath();
734                    String resourcePath = ametysObject.getPath();
735                    
736                    if (resourcePath.startsWith(baseAttachmentsPath + '/'))
737                    {
738                        // Is in attachments path
739                        String relPath = StringUtils.removeStart(resourcePath, baseAttachmentsPath + '/');
740                        _copyAttachment(ametysObject, relPath);
741                    }
742                }
743            }
744            catch (AmetysRepositoryException e)
745            {
746                // the reference was not <protocol>://<protocol-specific-part> (for example : mailto:mymail@example.com )
747                _logger.debug("The link '{}' is not recognized as Ametys object. It will be ignored", href);
748                return; 
749            }
750        }
751
752        /**
753         * Copy an attachment
754         * @param baseResource The resource to copy
755         * @param relPath The path where to copy
756         */
757        protected void _copyAttachment(AmetysObject baseResource, String relPath)
758        {
759            boolean success = false;
760            Exception exception = null;
761            
762            try
763            {
764                if (_targetContent instanceof ModifiableTraversableAmetysObject)
765                {
766                    ModifiableTraversableAmetysObject mtaoTargetContent = (ModifiableTraversableAmetysObject) _targetContent;
767                    ModifiableResourceCollection targetParentCollection = mtaoTargetContent.getChild(DefaultContent.ATTACHMENTS_NODE_NAME);
768                    
769                    String[] parts = StringUtils.split(relPath, '/');
770                    if (parts.length > 0)
771                    {
772                        // Traverse the path and create necessary resources collections
773                        for (int i = 0; i < parts.length - 1; i++)
774                        {
775                            String childName = parts[i];
776                            if (!targetParentCollection.hasChild(childName))
777                            {
778                                targetParentCollection = targetParentCollection.createChild(childName, JCRResourcesCollectionFactory.RESOURCESCOLLECTION_NODETYPE);
779                            }
780                            else
781                            {
782                                targetParentCollection = targetParentCollection.getChild(childName);
783                            }
784                        }
785                        
786                        // Copy the attachment resource.
787                        String resourceName = parts[parts.length - 1];
788                        if (baseResource instanceof CopiableAmetysObject)
789                        {
790                            ((CopiableAmetysObject) baseResource).copyTo(targetParentCollection, resourceName);
791                            success = true;
792                            _copyReport.addAttachment(relPath);
793                        }
794                    }
795                }
796            }
797            catch (Exception e)
798            {
799                exception = e;
800            }
801            
802            if (!success)
803            {
804                String warnMsg = "Unable to copy attachment from base path '" + baseResource.getPath() + "' to the content at path : '" + _targetContent.getPath() + "'.";
805                
806                if (_logger.isWarnEnabled())
807                {
808                    if (exception != null)
809                    {
810                        _logger.warn(warnMsg, exception);
811                    }
812                    else
813                    {
814                        _logger.warn(warnMsg);
815                    }
816                }
817            }
818        }
819    }
820}