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