001/* 002 * Copyright 2010 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.plugins.newsletter.workflow; 017 018import java.io.ByteArrayOutputStream; 019import java.io.IOException; 020import java.io.InputStreamReader; 021import java.io.Reader; 022import java.io.StringReader; 023import java.io.UnsupportedEncodingException; 024import java.text.DateFormat; 025import java.text.SimpleDateFormat; 026import java.util.Date; 027import java.util.HashSet; 028import java.util.List; 029import java.util.Map; 030import java.util.Properties; 031import java.util.Set; 032 033import javax.xml.parsers.ParserConfigurationException; 034import javax.xml.parsers.SAXParserFactory; 035import javax.xml.transform.OutputKeys; 036import javax.xml.transform.Transformer; 037import javax.xml.transform.TransformerException; 038import javax.xml.transform.TransformerFactory; 039import javax.xml.transform.sax.SAXSource; 040import javax.xml.transform.stream.StreamResult; 041 042import org.apache.avalon.framework.activity.Initializable; 043import org.apache.avalon.framework.context.Context; 044import org.apache.avalon.framework.context.ContextException; 045import org.apache.avalon.framework.context.Contextualizable; 046import org.apache.avalon.framework.service.ServiceException; 047import org.apache.avalon.framework.service.ServiceManager; 048import org.apache.cocoon.components.ContextHelper; 049import org.apache.cocoon.components.source.impl.SitemapSource; 050import org.apache.cocoon.environment.Request; 051import org.apache.cocoon.xml.AttributesImpl; 052import org.apache.cocoon.xml.SaxBuffer; 053import org.apache.commons.io.IOUtils; 054import org.apache.commons.lang.StringUtils; 055import org.apache.excalibur.source.SourceResolver; 056import org.apache.excalibur.xml.sax.ContentHandlerProxy; 057import org.xml.sax.Attributes; 058import org.xml.sax.ContentHandler; 059import org.xml.sax.InputSource; 060import org.xml.sax.SAXException; 061import org.xml.sax.XMLReader; 062import org.xml.sax.helpers.XMLFilterImpl; 063 064import org.ametys.cms.contenttype.ContentType; 065import org.ametys.cms.contenttype.ContentTypeExtensionPoint; 066import org.ametys.cms.contenttype.MetadataDefinition; 067import org.ametys.cms.repository.ModifiableContent; 068import org.ametys.cms.transformation.RichTextTransformer; 069import org.ametys.plugins.newsletter.auto.AutomaticNewsletterFilterResult; 070import org.ametys.plugins.newsletter.category.Category; 071import org.ametys.plugins.newsletter.category.CategoryProvider; 072import org.ametys.plugins.newsletter.category.CategoryProviderExtensionPoint; 073import org.ametys.plugins.repository.metadata.ModifiableCompositeMetadata; 074import org.ametys.plugins.repository.metadata.ModifiableRichText; 075import org.ametys.web.repository.site.Site; 076import org.ametys.web.workflow.CreateContentFunction; 077 078import com.opensymphony.workflow.WorkflowException; 079 080/** 081 * OSWorkflow function for creating a content. 082 */ 083public class CreateNewsletterFunction extends CreateContentFunction implements Initializable, Contextualizable 084{ 085 086 /** Newsletter category key. */ 087 public static final String NEWSLETTER_CATEGORY_KEY = CreateNewsletterFunction.class.getName() + "$category"; 088 /** Newsletter number key. */ 089 public static final String NEWSLETTER_NUMBER_KEY = CreateNewsletterFunction.class.getName() + "$number"; 090 /** Newsletter date key. */ 091 public static final String NEWSLETTER_DATE_KEY = CreateNewsletterFunction.class.getName() + "$date"; 092 /** Newsletter automatic property key. */ 093 public static final String NEWSLETTER_IS_AUTOMATIC_KEY = CreateNewsletterFunction.class.getName() + "$isAutomatic"; 094 /** Key for "process auto sections". */ 095 public static final String NEWSLETTER_PROCESS_AUTO_SECTIONS_KEY = CreateNewsletterFunction.class.getName() + "$processAutoSections"; 096 /** Key for content ID map when processing auto sections. */ 097 public static final String NEWSLETTER_CONTENT_ID_MAP_KEY = CreateNewsletterFunction.class.getName() + "$contentIds"; 098 099 /** The date format. */ 100 public static final DateFormat NEWSLETTER_DATE_FORMAT = new SimpleDateFormat("yyyyMMdd"); 101 102 /** The list of attributes to ignore. */ 103 protected static final Set<String> _IGNORE_ATTRS = new HashSet<>(); 104 static 105 { 106 _IGNORE_ATTRS.add("auto-newsletter-ignore"); 107 _IGNORE_ATTRS.add("auto-newsletter-ignore-if-empty"); 108 _IGNORE_ATTRS.add("auto-newsletter-insert-filter"); 109 _IGNORE_ATTRS.add("auto-newsletter-insert-level"); 110 } 111 112 /** The defaut content insertion level. */ 113 protected static final String _DEFAULT_LEVEL = "3"; 114 115 /** 116 * The Avalon context 117 */ 118 protected Context _context; 119 120 private SourceResolver _sourceResolver; 121 private ContentTypeExtensionPoint _cTypeExtPt; 122 private CategoryProviderExtensionPoint _categoryProviderEP; 123 124 // Transformation objects. 125 private TransformerFactory _transformerFactory; 126 private Properties _transformerProperties; 127 private SAXParserFactory _saxParserFactory; 128 129 @Override 130 public void contextualize(Context context) throws ContextException 131 { 132 _context = context; 133 } 134 135 @Override 136 public void service(ServiceManager manager) throws ServiceException 137 { 138 super.service(manager); 139 _sourceResolver = (SourceResolver) manager.lookup(SourceResolver.ROLE); 140 _cTypeExtPt = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE); 141 _categoryProviderEP = (CategoryProviderExtensionPoint) manager.lookup(CategoryProviderExtensionPoint.ROLE); 142 } 143 144 @Override 145 public void initialize() throws Exception 146 { 147 // Initialize transformation objects. 148 _transformerFactory = TransformerFactory.newInstance(); 149 150 _transformerProperties = new Properties(); 151 _transformerProperties.put(OutputKeys.METHOD, "xml"); 152 _transformerProperties.put(OutputKeys.ENCODING, "UTF-8"); 153 _transformerProperties.put(OutputKeys.OMIT_XML_DECLARATION, "yes"); 154 155 _saxParserFactory = SAXParserFactory.newInstance(); 156 _saxParserFactory.setNamespaceAware(true); 157 } 158 159 @Override 160 protected void _populateContent(Map transientVars, ModifiableContent content) throws WorkflowException 161 { 162 super._populateContent(transientVars, content); 163 164 String category = (String) transientVars.get(NEWSLETTER_CATEGORY_KEY); 165 if (category == null) 166 { 167 throw new WorkflowException("Missing category"); 168 } 169 170 Long number = (Long) transientVars.get(NEWSLETTER_NUMBER_KEY); 171 Date date = (Date) transientVars.get(NEWSLETTER_DATE_KEY); 172 boolean isAutomatic = "true".equals(transientVars.get(NEWSLETTER_IS_AUTOMATIC_KEY)); 173 boolean processAutoSections = "true".equals(transientVars.get(NEWSLETTER_PROCESS_AUTO_SECTIONS_KEY)); 174 @SuppressWarnings("unchecked") 175 Map<String, AutomaticNewsletterFilterResult> filterResults = (Map<String, AutomaticNewsletterFilterResult>) transientVars.get(NEWSLETTER_CONTENT_ID_MAP_KEY); 176 177 if (processAutoSections && filterResults == null) 178 { 179 throw new WorkflowException("Content ID map must not be null if processing automatic sections."); 180 } 181 182 ModifiableCompositeMetadata meta = content.getMetadataHolder(); 183 184 meta.setMetadata("category", category); 185 meta.setMetadata("automatic", String.valueOf(isAutomatic)); 186 187 if (number != null) 188 { 189 meta.setMetadata("newsletter-number", number); 190 } 191 if (date != null) 192 { 193 meta.setMetadata("newsletter-date", date); 194 } 195 196 String siteName = (String) transientVars.get(SITE_KEY); 197 Site site = _siteManager.getSite(siteName); 198 199 // Initialize the content from the model 200 _initContentRichText (content, site.getSkinId(), category, processAutoSections, filterResults); 201 } 202 203 private void _initContentRichText (ModifiableContent content, String skinId, String categoryID, boolean processAutoSections, Map<String, AutomaticNewsletterFilterResult> filterResults) throws WorkflowException 204 { 205 Set<String> ids = _categoryProviderEP.getExtensionsIds(); 206 for (String id : ids) 207 { 208 CategoryProvider provider = _categoryProviderEP.getExtension(id); 209 if (provider.hasCategory(categoryID)) 210 { 211 Category category = provider.getCategory(categoryID); 212 String templateId = category.getTemplate(); 213 214 if (templateId == null) 215 { 216 throw new WorkflowException ("The template can not be null"); 217 } 218 219 try 220 { 221 String text = _getContent (skinId, templateId); 222 223 // Process automatic newsletter tags. 224 String processedText = _processAutoTags(text, processAutoSections, filterResults, content.getId()); 225 226 ModifiableRichText richText = content.getMetadataHolder().getRichText("content", true); 227 228 ContentType cType = _cTypeExtPt.getExtension(content.getTypes()[0]); 229 MetadataDefinition metadataDefinition = cType.getMetadataDefinition("content"); 230 231 RichTextTransformer richTextTransformer = metadataDefinition.getRichTextTransformer(); 232 richTextTransformer.transform(processedText, richText); 233 234 content.saveChanges(); 235 } 236 catch (IOException e) 237 { 238 throw new WorkflowException("Unable to transform rich text", e); 239 } 240 } 241 } 242 } 243 244 private String _processAutoTags(String text, boolean processAutoSections, Map<String, AutomaticNewsletterFilterResult> filterResults, String contentId) throws WorkflowException 245 { 246 StringReader in = new StringReader(text); 247 ByteArrayOutputStream baos = new ByteArrayOutputStream(1024); 248 249 try 250 { 251 Transformer transformer = _transformerFactory.newTransformer(); 252 transformer.setOutputProperties(_transformerProperties); 253 XMLReader xmlReader = _saxParserFactory.newSAXParser().getXMLReader(); 254 255 NewsletterFilter newsletterFilter = new NewsletterFilter(xmlReader, _sourceResolver, processAutoSections, filterResults, contentId); 256 SAXSource transformSource = new SAXSource(newsletterFilter, new InputSource(in)); 257 258 transformer.transform(transformSource, new StreamResult(baos)); 259 260 return baos.toString("UTF-8"); 261 } 262 catch (TransformerException e) 263 { 264 throw new WorkflowException("Transformer exception.", e); 265 } 266 catch (SAXException e) 267 { 268 throw new WorkflowException("SAX exception.", e); 269 } 270 catch (ParserConfigurationException e) 271 { 272 throw new WorkflowException("SAX exception.", e); 273 } 274 catch (UnsupportedEncodingException e) 275 { 276 throw new WorkflowException("Unsupported encoding.", e); 277 } 278 } 279 280 private String _getContent (String skinId, String templateId) throws IOException, WorkflowException 281 { 282 SitemapSource src = null; 283 Request request = ContextHelper.getRequest(_context); 284 if (request == null) 285 { 286 throw new WorkflowException("Unable to get the request"); 287 } 288 289 try 290 { 291 request.setAttribute("skin", skinId); 292 src = (SitemapSource) _sourceResolver.resolveURI("cocoon://_plugins/newsletter/" + templateId + "/model.xml"); 293 Reader reader = new InputStreamReader(src.getInputStream(), "UTF-8"); 294 return IOUtils.toString(reader); 295 } 296 finally 297 { 298 _sourceResolver.release(src); 299 } 300 } 301 302 /** 303 * Automatic newsletter filter. 304 */ 305 protected class NewsletterFilter extends XMLFilterImpl 306 { 307 308 private SourceResolver _srcResolver; 309 310 private String _contentId; 311 312 private boolean _processAutoSections; 313 314 private Map<String, AutomaticNewsletterFilterResult> _filterResults; 315 316 private boolean _ignore; 317 318 private boolean _ignoreNextLevel; 319 320 private int _ignoreDepth; 321 322 /** 323 * Constructor. 324 * @param xmlReader the parent XML reader. 325 * @param sourceResolver the source resolver. 326 * @param filterResults the filter results. 327 * @param processAutoSections true to process auto sections, false to ignore them. 328 * @param contentId the newsletter content ID. 329 */ 330 public NewsletterFilter(XMLReader xmlReader, SourceResolver sourceResolver, boolean processAutoSections, Map<String, AutomaticNewsletterFilterResult> filterResults, String contentId) 331 { 332 super(xmlReader); 333 _srcResolver = sourceResolver; 334 _contentId = contentId; 335 _processAutoSections = processAutoSections; 336 _filterResults = filterResults; 337 } 338 339 @Override 340 public void startDocument() throws SAXException 341 { 342 super.startDocument(); 343 _ignore = false; 344 _ignoreDepth = 0; 345 } 346 347 @Override 348 public void startElement(String uri, String localName, String qName, Attributes atts) throws SAXException 349 { 350 String ignore = atts.getValue("auto-newsletter-ignore"); 351 String ignoreIfEmpty = atts.getValue("auto-newsletter-ignore-if-empty"); 352 String insertFilter = atts.getValue("auto-newsletter-insert-filter"); 353 String insertFilterLevel = atts.getValue("auto-newsletter-insert-level"); 354 355 if (_ignoreNextLevel) 356 { 357 _ignoreNextLevel = false; 358 _ignore = true; 359 _ignoreDepth = 0; 360 } 361 362 if (StringUtils.isNotEmpty(ignore) || StringUtils.isNotEmpty(ignoreIfEmpty) || StringUtils.isNotEmpty(insertFilter) || StringUtils.isNotEmpty(insertFilterLevel)) 363 { 364 AttributesImpl newAtts = new AttributesImpl(); 365 366 // Copy all attributes except auto newsletter ones. 367 _copyStartElementAttributes(atts, newAtts); 368 369 SaxBuffer saxBuffer = null; 370 371 if (_processAutoSections && !_ignore) 372 { 373 if ("true".equals(ignore)) 374 { 375 _ignore = true; 376 _ignoreDepth = 0; 377 } 378 else if (StringUtils.isNotEmpty(ignoreIfEmpty)) 379 { 380 _handleIgnoreIfEmpty(ignoreIfEmpty); 381 } 382 else if (StringUtils.isNotEmpty(insertFilter)) 383 { 384 saxBuffer = _handleInsertFilter(insertFilter, insertFilterLevel, saxBuffer); 385 } 386 } 387 388 if (!_ignore) 389 { 390 super.startElement(uri, localName, qName, newAtts); 391 392 if (saxBuffer != null) 393 { 394 _ignoreNextLevel = true; 395 396 saxBuffer.toSAX(getContentHandler()); 397 } 398 } 399 else 400 { 401 _ignoreDepth++; 402 } 403 } 404 else 405 { 406 // No attribute found, no need to transform anything. 407 if (!_ignore) 408 { 409 super.startElement(uri, localName, qName, atts); 410 } 411 else 412 { 413 _ignoreDepth++; 414 } 415 } 416 } 417 418 private void _handleIgnoreIfEmpty(String ignoreIfEmpty) 419 { 420 if (!_filterResults.containsKey(ignoreIfEmpty) || !_filterResults.get(ignoreIfEmpty).hasResults()) 421 { 422 _ignore = true; 423 _ignoreDepth = 0; 424 } 425 } 426 427 private SaxBuffer _handleInsertFilter(String insertFilter, String insertFilterLevel, SaxBuffer saxBuffer) throws SAXException 428 { 429 SaxBuffer modifiedSaxBuffer = saxBuffer; 430 431 if (_filterResults.containsKey(insertFilter) && _filterResults.get(insertFilter).hasResults()) 432 { 433 AutomaticNewsletterFilterResult result = _filterResults.get(insertFilter); 434 List<String> contentIds = result.getContentIds(); 435 436 String metadataSetName = result.getMetadataSetName(); 437 String level = StringUtils.defaultIfEmpty(insertFilterLevel, _DEFAULT_LEVEL); 438 439 modifiedSaxBuffer = _getFilterContent(contentIds, level, metadataSetName); 440 } 441 else 442 { 443 _ignore = true; 444 _ignoreDepth = 0; 445 } 446 447 return modifiedSaxBuffer; 448 } 449 450 private void _copyStartElementAttributes(Attributes atts, AttributesImpl newAtts) 451 { 452 for (int i = 0; i < atts.getLength(); i++) 453 { 454 String attrName = atts.getLocalName(i); 455 if (!_IGNORE_ATTRS.contains(attrName)) 456 { 457 newAtts.addAttribute(atts.getURI(i), attrName, atts.getQName(i), atts.getType(i), atts.getValue(i)); 458 } 459 } 460 } 461 462 @Override 463 public void endElement(String uri, String localName, String qName) throws SAXException 464 { 465 if (_ignoreNextLevel) 466 { 467 _ignoreNextLevel = false; 468 } 469 470 if (!_ignore) 471 { 472 super.endElement(uri, localName, qName); 473 } 474 else 475 { 476 _ignoreDepth--; 477 478 if (_ignoreDepth < 1) 479 { 480 _ignore = false; 481 } 482 } 483 } 484 485 @Override 486 public void characters(char[] ch, int start, int length) throws SAXException 487 { 488 if (!_ignore && !_ignoreNextLevel) 489 { 490 super.characters(ch, start, length); 491 } 492 } 493 494 private SaxBuffer _getFilterContent(List<String> contentIds, String level, String metadataSetName) throws SAXException 495 { 496 SitemapSource src = null; 497 Request request = ContextHelper.getRequest(_context); 498 if (request == null) 499 { 500 throw new SAXException("Unable to get the request"); 501 } 502 503 try 504 { 505 StringBuilder url = new StringBuilder("cocoon://_plugins/web/contents/last-published"); 506 url.append("?metadataSetName=").append(metadataSetName).append("&level=").append(level); 507 for (String id : contentIds) 508 { 509 url.append("&contentId=").append(id); 510 } 511 512 src = (SitemapSource) _srcResolver.resolveURI(url.toString()); 513 514 SaxBuffer buffer = new SaxBuffer(); 515 516 // Ignore the root tag and inject data-ametys-src attribute on uploaded image tag 517 // so that the image is properly handled by UploadedDataHTMLEditionHandler. 518 src.toSAX(new IgnoreRootTagHandler(new LocalImageHandler(buffer, _contentId))); 519 520 return buffer; 521 } 522 catch (IOException e) 523 { 524 throw new SAXException("Error resolving the contents.", e); 525 } 526 finally 527 { 528 _srcResolver.release(src); 529 } 530 } 531 532 } 533 534 /** 535 * Ignore the root tag. 536 */ 537 protected class IgnoreRootTagHandler extends ContentHandlerProxy 538 { 539 540 private int _depth; 541 542 /** 543 * Constructor 544 * @param contentHandler the contentHandler to pass SAX events to. 545 */ 546 public IgnoreRootTagHandler(ContentHandler contentHandler) 547 { 548 super(contentHandler); 549 } 550 551 @Override 552 public void startDocument() throws SAXException 553 { 554 _depth = 0; 555 } 556 557 @Override 558 public void endDocument() throws SAXException 559 { 560 // empty method 561 } 562 563 @Override 564 public void startElement(String uri, String loc, String raw, Attributes a) throws SAXException 565 { 566 _depth++; 567 568 if (_depth > 1) 569 { 570 super.startElement(uri, loc, raw, a); 571 } 572 } 573 574 @Override 575 public void endElement(String uri, String loc, String raw) throws SAXException 576 { 577 if (_depth > 1) 578 { 579 super.endElement(uri, loc, raw); 580 } 581 582 _depth--; 583 } 584 585 } 586 587 /** 588 * Local image handler: injects attributes so that the image will be properly handled. 589 */ 590 protected class LocalImageHandler extends ContentHandlerProxy 591 { 592 593 private String _contentId; 594 595 /** 596 * Constructor 597 * @param contentHandler the contentHandler to pass SAX events to. 598 * @param contentId The id of content 599 */ 600 public LocalImageHandler(ContentHandler contentHandler, String contentId) 601 { 602 super(contentHandler); 603 _contentId = contentId; 604 } 605 606 @Override 607 public void startElement(String uri, String loc, String raw, Attributes a) throws SAXException 608 { 609 String ametysType = a.getValue("data-ametys-type"); 610 String ametysSrc = a.getValue("data-ametys-src"); 611 612 if ("img".equals(raw) && StringUtils.isEmpty(ametysType) && StringUtils.isEmpty(ametysSrc)) 613 { 614 AttributesImpl newAttributes = new AttributesImpl(a); 615 616 // "content" rich text metadata is hardcoded. 617 newAttributes.addCDATAAttribute("data-ametys-src", _contentId + "@content"); 618 619 super.startElement(uri, loc, raw, newAttributes); 620 } 621 else 622 { 623 super.startElement(uri, loc, raw, a); 624 } 625 } 626 627 } 628 629}