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}