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}