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