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}