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}