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