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