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