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.data.RichText;
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.data.holder.ModifiableModelLessDataHolder;
072import org.ametys.runtime.model.ElementDefinition;
073import org.ametys.runtime.model.type.DataContext;
074import org.ametys.runtime.model.type.ElementType;
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 CategoryProviderExtensionPoint _categoryProviderEP;
122    
123    // Transformation objects.
124    private TransformerFactory _transformerFactory;
125    private Properties _transformerProperties;
126    private SAXParserFactory _saxParserFactory;
127    
128    @Override
129    public void contextualize(Context context) throws ContextException
130    {
131        _context = context;
132    }
133    
134    @Override
135    public void service(ServiceManager manager) throws ServiceException
136    {
137        super.service(manager);
138        _sourceResolver = (SourceResolver) manager.lookup(SourceResolver.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        ModifiableModelLessDataHolder internalDataHolder = content.getInternalDataHolder();
182        internalDataHolder.setValue("category", category);
183        internalDataHolder.setValue("automatic", isAutomatic);
184        
185        if (number != null)
186        {
187            content.setValue("newsletter-number", number);
188        }
189        if (date != null)
190        {
191            content.setValue("newsletter-date", DateUtils.asLocalDate(date));
192        }
193        
194        String siteName = (String) transientVars.get(SITE_KEY);
195        Site site = _siteManager.getSite(siteName);
196        
197        // Initialize the content from the model
198        _initContentRichText (content, site.getSkinId(), category, processAutoSections, filterResults);
199    }
200    
201    private void _initContentRichText (ModifiableContent content, String skinId, String categoryID, boolean processAutoSections, Map<String, AutomaticNewsletterFilterResult> filterResults) throws WorkflowException
202    {
203        Set<String> ids = _categoryProviderEP.getExtensionsIds();
204        for (String id : ids)
205        {
206            CategoryProvider provider = _categoryProviderEP.getExtension(id);
207            if (provider.hasCategory(categoryID))
208            {
209                Category category = provider.getCategory(categoryID);
210                String templateId = category.getTemplate();
211                
212                if (templateId == null)
213                {
214                    throw new WorkflowException ("The template can not be null");
215                }
216                
217                try
218                {
219                    String text = _getContent (skinId, templateId);
220                    
221                    // Process automatic newsletter tags.
222                    String processedText = _processAutoTags(text, processAutoSections, filterResults);
223
224                    ElementType<RichText> richTextType = ((ElementDefinition) content.getDefinition("content")).getType();
225                    DataContext dataContext = DataContext.newInstance().withObjectId(content.getId()).withDataPath("content");
226                    content.setValue("content", richTextType.fromJSONForClient(processedText, dataContext));
227                    content.saveChanges();
228                }
229                catch (IOException e)
230                {
231                    throw new WorkflowException("Unable to transform rich text", e);
232                }
233            }
234        }
235    }
236    
237    private String _processAutoTags(String text, boolean processAutoSections, Map<String, AutomaticNewsletterFilterResult> filterResults) throws WorkflowException
238    {
239        StringReader in = new StringReader(text);
240        ByteArrayOutputStream baos = new ByteArrayOutputStream(1024);
241        
242        try
243        {
244            Transformer transformer = _transformerFactory.newTransformer();
245            transformer.setOutputProperties(_transformerProperties);
246            XMLReader xmlReader = _saxParserFactory.newSAXParser().getXMLReader();
247            
248            NewsletterFilter newsletterFilter = new NewsletterFilter(xmlReader, _sourceResolver, processAutoSections, filterResults);
249            SAXSource transformSource = new SAXSource(newsletterFilter, new InputSource(in));
250            
251            transformer.transform(transformSource, new StreamResult(baos));
252            
253            return baos.toString("UTF-8");
254        }
255        catch (TransformerException e)
256        {
257            throw new WorkflowException("Transformer exception.", e);
258        }
259        catch (SAXException e)
260        {
261            throw new WorkflowException("SAX exception.", e);
262        }
263        catch (ParserConfigurationException e)
264        {
265            throw new WorkflowException("SAX exception.", e);
266        }
267        catch (UnsupportedEncodingException e)
268        {
269            throw new WorkflowException("Unsupported encoding.", e);
270        }
271    }
272    
273    private String _getContent (String skinId, String templateId) throws IOException, WorkflowException
274    {
275        SitemapSource src = null;
276        Request request = ContextHelper.getRequest(_context);
277        if (request == null)
278        {
279            throw new WorkflowException("Unable to get the request");
280        }
281        
282        try
283        {
284            request.setAttribute("skin", skinId);
285            src = (SitemapSource) _sourceResolver.resolveURI("cocoon://_plugins/newsletter/" + templateId + "/model.xml");
286            Reader reader = new InputStreamReader(src.getInputStream(), "UTF-8");
287            return IOUtils.toString(reader);
288        }
289        finally
290        {
291            _sourceResolver.release(src);
292        }
293    }
294    
295    /**
296     * Automatic newsletter filter.
297     */
298    protected class NewsletterFilter extends XMLFilterImpl
299    {
300        
301        private SourceResolver _srcResolver;
302        
303        private boolean _processAutoSections;
304        
305        private Map<String, AutomaticNewsletterFilterResult> _filterResults;
306        
307        private boolean _ignore;
308        
309        private boolean _ignoreNextLevel;
310        
311        private int _ignoreDepth;
312        
313        /**
314         * Constructor.
315         * @param xmlReader the parent XML reader.
316         * @param sourceResolver the source resolver.
317         * @param filterResults the filter results.
318         * @param processAutoSections true to process auto sections, false to ignore them.
319         */
320        public NewsletterFilter(XMLReader xmlReader, SourceResolver sourceResolver, boolean processAutoSections, Map<String, AutomaticNewsletterFilterResult> filterResults)
321        {
322            super(xmlReader);
323            _srcResolver = sourceResolver;
324            _processAutoSections = processAutoSections;
325            _filterResults = filterResults;
326        }
327        
328        @Override
329        public void startDocument() throws SAXException
330        {
331            super.startDocument();
332            _ignore = false;
333            _ignoreDepth = 0;
334        }
335        
336        @Override
337        public void startElement(String uri, String localName, String qName, Attributes atts) throws SAXException
338        {
339            String ignore = atts.getValue("auto-newsletter-ignore");
340            String ignoreIfEmpty = atts.getValue("auto-newsletter-ignore-if-empty");
341            String insertFilter = atts.getValue("auto-newsletter-insert-filter");
342            String insertFilterLevel = atts.getValue("auto-newsletter-insert-level");
343            
344            if (_ignoreNextLevel)
345            {
346                _ignoreNextLevel = false;
347                _ignore = true;
348                _ignoreDepth = 0;
349            }
350            
351            if (StringUtils.isNotEmpty(ignore) || StringUtils.isNotEmpty(ignoreIfEmpty) || StringUtils.isNotEmpty(insertFilter) || StringUtils.isNotEmpty(insertFilterLevel))
352            {
353                AttributesImpl newAtts = new AttributesImpl();
354                
355                // Copy all attributes except auto newsletter ones.
356                _copyStartElementAttributes(atts, newAtts);
357                
358                SaxBuffer saxBuffer = null;
359                
360                if (_processAutoSections && !_ignore)
361                {
362                    if ("true".equals(ignore))
363                    {
364                        _ignore = true;
365                        _ignoreDepth = 0;
366                    }
367                    else if (StringUtils.isNotEmpty(ignoreIfEmpty))
368                    {
369                        _handleIgnoreIfEmpty(ignoreIfEmpty);
370                    }
371                    else if (StringUtils.isNotEmpty(insertFilter))
372                    {
373                        saxBuffer = _handleInsertFilter(insertFilter, insertFilterLevel, saxBuffer);
374                    }
375                }
376                
377                if (!_ignore)
378                {
379                    super.startElement(uri, localName, qName, newAtts);
380                    
381                    if (saxBuffer != null)
382                    {
383                        _ignoreNextLevel = true;
384                        
385                        saxBuffer.toSAX(getContentHandler());
386                    }
387                }
388                else
389                {
390                    _ignoreDepth++;
391                }
392            }
393            else
394            {
395                // No attribute found, no need to transform anything.
396                if (!_ignore)
397                {
398                    super.startElement(uri, localName, qName, atts);
399                }
400                else
401                {
402                    _ignoreDepth++;
403                }
404            }
405        }
406
407        private void _handleIgnoreIfEmpty(String ignoreIfEmpty)
408        {
409            if (!_filterResults.containsKey(ignoreIfEmpty) || !_filterResults.get(ignoreIfEmpty).hasResults())
410            {
411                _ignore = true;
412                _ignoreDepth = 0;
413            }
414        }
415
416        private SaxBuffer _handleInsertFilter(String insertFilter, String insertFilterLevel, SaxBuffer saxBuffer) throws SAXException
417        {
418            SaxBuffer modifiedSaxBuffer = saxBuffer;
419            
420            if (_filterResults.containsKey(insertFilter) && _filterResults.get(insertFilter).hasResults())
421            {
422                AutomaticNewsletterFilterResult result = _filterResults.get(insertFilter);
423                List<String> contentIds = result.getContentIds();
424                
425                String viewName = result.getViewName();
426                String level = StringUtils.defaultIfEmpty(insertFilterLevel, _DEFAULT_LEVEL);
427                
428                modifiedSaxBuffer = _getFilterContent(contentIds, level, viewName);
429            }
430            else
431            {
432                _ignore = true;
433                _ignoreDepth = 0;
434            }
435            
436            return modifiedSaxBuffer;
437        }
438
439        private void _copyStartElementAttributes(Attributes atts, AttributesImpl newAtts)
440        {
441            for (int i = 0; i < atts.getLength(); i++)
442            {
443                String attrName = atts.getLocalName(i);
444                if (!_IGNORE_ATTRS.contains(attrName))
445                {
446                    newAtts.addAttribute(atts.getURI(i), attrName, atts.getQName(i), atts.getType(i), atts.getValue(i));
447                }
448            }
449        }
450        
451        @Override
452        public void endElement(String uri, String localName, String qName) throws SAXException
453        {
454            if (_ignoreNextLevel)
455            {
456                _ignoreNextLevel = false;
457            }
458            
459            if (!_ignore)
460            {
461                super.endElement(uri, localName, qName);
462            }
463            else
464            {
465                _ignoreDepth--;
466                
467                if (_ignoreDepth < 1)
468                {
469                    _ignore = false;
470                }
471            }
472        }
473        
474        @Override
475        public void characters(char[] ch, int start, int length) throws SAXException
476        {
477            if (!_ignore && !_ignoreNextLevel)
478            {
479                super.characters(ch, start, length);
480            }
481        }
482        
483        private SaxBuffer _getFilterContent(List<String> contentIds, String level, String viewName) throws SAXException
484        {
485            SitemapSource src = null;
486            Request request = ContextHelper.getRequest(_context);
487            if (request == null)
488            {
489                throw new SAXException("Unable to get the request");
490            }
491            
492            try
493            {
494                StringBuilder url = new StringBuilder("cocoon://_plugins/web/contents/last-published");
495                url.append("?viewName=").append(viewName).append("&level=").append(level);
496                for (String id : contentIds)
497                {
498                    url.append("&contentId=").append(id);
499                }
500                
501                src = (SitemapSource) _srcResolver.resolveURI(url.toString());
502                
503                SaxBuffer buffer = new SaxBuffer();
504                
505                // Ignore the root tag
506                src.toSAX(new IgnoreRootTagHandler(buffer));
507                
508                return buffer;
509            }
510            catch (IOException e)
511            {
512                throw new SAXException("Error resolving the contents.", e);
513            }
514            finally
515            {
516                _srcResolver.release(src);
517            }
518        }
519        
520    }
521    
522    /**
523     * Ignore the root tag.
524     */
525    protected class IgnoreRootTagHandler extends ContentHandlerProxy
526    {
527        private int _depth;
528        
529        /**
530         * Constructor
531         * @param contentHandler the contentHandler to pass SAX events to.
532         */
533        public IgnoreRootTagHandler(ContentHandler contentHandler)
534        {
535            super(contentHandler);
536        }
537        
538        @Override
539        public void startDocument() throws SAXException
540        {
541            _depth = 0;
542        }
543        
544        @Override
545        public void endDocument() throws SAXException
546        {
547            // empty method
548        }
549        
550        @Override
551        public void startElement(String uri, String loc, String raw, Attributes a) throws SAXException
552        {
553            _depth++;
554            
555            if (_depth > 1)
556            {
557                super.startElement(uri, loc, raw, a);
558            }
559        }
560        
561        @Override
562        public void endElement(String uri, String loc, String raw) throws SAXException
563        {
564            if (_depth > 1)
565            {
566                super.endElement(uri, loc, raw);
567            }
568            
569            _depth--;
570        }
571    }
572}