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