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