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