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