001/*
002 *  Copyright 2019 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.cms.data.type;
017
018import java.io.IOException;
019import java.io.InputStream;
020import java.io.OutputStream;
021import java.nio.charset.StandardCharsets;
022import java.time.ZonedDateTime;
023import java.time.format.DateTimeFormatter;
024import java.util.LinkedHashMap;
025import java.util.Map;
026import java.util.Optional;
027import java.util.Properties;
028import java.util.stream.Stream;
029
030import javax.xml.transform.OutputKeys;
031import javax.xml.transform.Transformer;
032import javax.xml.transform.TransformerException;
033import javax.xml.transform.TransformerFactory;
034import javax.xml.transform.dom.DOMSource;
035import javax.xml.transform.sax.SAXResult;
036import javax.xml.transform.sax.SAXTransformerFactory;
037import javax.xml.transform.sax.TransformerHandler;
038import javax.xml.transform.stream.StreamResult;
039
040import org.apache.avalon.framework.service.ServiceException;
041import org.apache.avalon.framework.service.ServiceManager;
042import org.apache.cocoon.xml.AttributesImpl;
043import org.apache.cocoon.xml.XMLUtils;
044import org.apache.commons.io.IOUtils;
045import org.apache.commons.lang3.StringUtils;
046import org.apache.commons.lang3.tuple.Triple;
047import org.w3c.dom.Element;
048import org.xml.sax.ContentHandler;
049import org.xml.sax.SAXException;
050
051import org.ametys.cms.data.LocalMediaObjectHandler;
052import org.ametys.cms.data.RichText;
053import org.ametys.cms.data.RichTextImportHandlerFactory;
054import org.ametys.cms.data.RichTextImportHandlerFactory.RichTextImportHandler;
055import org.ametys.cms.transformation.RichTextTransformer;
056import org.ametys.cms.transformation.docbook.DocbookTransformer;
057import org.ametys.core.model.type.AbstractElementType;
058import org.ametys.core.model.type.ModelItemTypeHelper;
059import org.ametys.core.util.dom.DOMUtils;
060import org.ametys.plugins.repository.AmetysObjectResolver;
061import org.ametys.plugins.repository.AmetysRepositoryException;
062import org.ametys.plugins.repository.data.ametysobject.DataAwareAmetysObject;
063import org.ametys.plugins.repository.data.ametysobject.ModelAwareDataAwareAmetysObject;
064import org.ametys.plugins.repository.data.ametysobject.ModelLessDataAwareAmetysObject;
065import org.ametys.runtime.model.ViewItem;
066import org.ametys.runtime.model.compare.DataChangeType;
067import org.ametys.runtime.model.compare.DataChangeTypeDetail;
068import org.ametys.runtime.model.type.DataContext;
069
070import com.google.common.base.CharMatcher;
071
072/**
073 * Abstract class for rich text type of elements
074 */
075public abstract class AbstractRichTextElementType extends AbstractElementType<RichText>
076{
077    /** Rich text transformer */
078    protected RichTextTransformer _richTextTransformer;
079    /** Rich text metadata handler */
080    protected RichTextImportHandlerFactory _richTextHandlerFactory;
081    /** Ametys object resolver */
082    protected AmetysObjectResolver _resolver;
083    /** Avalon service manager */
084    protected ServiceManager _manager;
085
086    @Override
087    public void service(ServiceManager manager) throws ServiceException
088    {
089        super.service(manager);
090        _richTextTransformer = (RichTextTransformer) manager.lookup(DocbookTransformer.ROLE);
091        _richTextHandlerFactory = (RichTextImportHandlerFactory) manager.lookup(RichTextImportHandlerFactory.ROLE);
092        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
093        _manager = manager;
094    }
095    
096    @Override
097    public RichText convertValue(Object value)
098    {
099        if (value instanceof byte[])
100        {
101            return _fromByteArray((byte[]) value, DataContext.newInstance());
102        }
103        else if (value instanceof String)
104        {
105            return _fromString((String) value, DataContext.newInstance());
106        }
107        
108        return super.convertValue(value);
109    }
110    
111    @Override
112    public String toString(RichText value)
113    {
114        throw new UnsupportedOperationException("Unable to convert a rich text to a string value");
115    }
116    
117    public Object fromJSONForClient(Object json, DataContext context)
118    {
119        String content = (String) json;
120        
121        if (StringUtils.isEmpty(content))
122        {
123            return null;
124        }
125        
126        return _fromHTML(content, context);
127    }
128    
129    private RichText _fromByteArray(byte[] content, DataContext context)
130    {
131        String contentAsString = StringUtils.EMPTY;
132        try
133        {
134            String strValue = IOUtils.toString(content, "UTF-8");
135            contentAsString = CharMatcher.javaIsoControl().and(CharMatcher.anyOf("\r\n\t").negate()).removeFrom(strValue);
136        }
137        catch (IOException e)
138        {
139            if (getLogger().isErrorEnabled())
140            {
141                getLogger().error("Unable to transform rich text", e);
142            }
143        }
144
145        return _fromString(contentAsString, context);
146    }
147    
148    private RichText _fromString(String content, DataContext context)
149    {
150        String contentAsHTML = "<div>" + content.replaceAll("\r?\n", "<br />") + "</div>";
151        return _fromHTML(contentAsHTML, context);
152    }
153    
154    private RichText _fromHTML(String content, DataContext context)
155    {
156        RichText richText = new RichText();
157        if (StringUtils.isNotEmpty(context.getDataPath()) && context.getObjectId().isPresent())
158        {
159            DataAwareAmetysObject object = _resolver.resolveById(context.getObjectId().get());
160            String dataPath = context.getDataPath();
161            if (object.hasValue(dataPath))
162            {
163                if (object instanceof ModelAwareDataAwareAmetysObject)
164                {
165                    richText = ((ModelAwareDataAwareAmetysObject) object).getValue(dataPath);
166                }
167                else
168                {
169                    richText = ((ModelLessDataAwareAmetysObject) object).getValueOfType(dataPath, getId());
170                }
171            }
172        }
173        
174        try
175        {
176            _richTextTransformer.transform(content, richText);
177        }
178        catch (AmetysRepositoryException | IOException e)
179        {
180            if (getLogger().isErrorEnabled())
181            {
182                getLogger().error("Unable to transform rich text", e);
183            }
184        }
185        
186        return richText;
187    }
188    
189    @Override
190    protected Object _singleValueToJSON(RichText value, DataContext context)
191    {
192        Map<String, Object> richTextInfos = new LinkedHashMap<>();
193        
194        Optional.ofNullable(value.getMimeType()).ifPresent(mimeType -> richTextInfos.put("mime-type", mimeType));
195        Optional.ofNullable(value.getLastModificationDate()).ifPresent(lastModificationDate -> richTextInfos.put("lastModified", lastModificationDate.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)));
196        
197        try (InputStream is = value.getInputStream())
198        {
199            String encoding = value.getEncoding();
200            richTextInfos.put("value", IOUtils.toString(is, encoding));
201        }
202        catch (IOException e)
203        {
204            throw new RuntimeException("Unable to sax the rich text value", e);
205        }
206        
207        return richTextInfos;
208    }
209    
210    @Override
211    protected Object _singleValueToJSONForEdition(RichText value, DataContext context)
212    {
213        try
214        {
215            StringBuilder result = new StringBuilder(2048);
216            _richTextTransformer.transformForEditing(value, context, result);
217            return result.toString();
218        }
219        catch (IOException e)
220        {
221            throw new AmetysRepositoryException("Unable to transform the rich text valueinto a string", e);
222        }
223    }
224    
225    @Override
226    protected RichText _singleValueFromXML(Element element, Optional<Object> additionalData)
227    {
228        if (element != null)
229        {
230            RichText richText = new RichText();
231            
232            String mimeType = StringUtils.defaultIfBlank(element.getAttribute("mime-type"), "text/xml");
233            String encoding = StringUtils.defaultIfBlank(element.getAttribute("encoding"), StandardCharsets.UTF_8.name());
234            
235            richText.setMimeType(mimeType);
236            richText.setEncoding(encoding);
237            richText.setLastModificationDate(ZonedDateTime.now());
238            
239            if (mimeType.equals("text/xml") || mimeType.equals("application/xml"))
240            {
241                try (OutputStream os = richText.getOutputStream())
242                {
243                    Element childElement = DOMUtils.getFirstChildElement(element);
244                    if (childElement != null)
245                    {
246                        Properties format = new Properties();
247                        format.put(OutputKeys.METHOD, "xml");
248                        format.put(OutputKeys.ENCODING, encoding);
249                        format.put(OutputKeys.INDENT, "no");
250                        
251                        Transformer transformer = TransformerFactory.newInstance().newTransformer();
252                        transformer.setOutputProperties(format);
253                        
254                        DOMSource domSource = new DOMSource(childElement);
255                        
256                        TransformerHandler transformerhandler = ((SAXTransformerFactory) TransformerFactory.newInstance()).newTransformerHandler();
257                        transformerhandler.getTransformer().setOutputProperties(format);
258                        
259                        StreamResult result = new StreamResult(os);
260                        transformerhandler.setResult(result);
261                        
262                        @SuppressWarnings("unchecked")
263                        Map<String, InputStream> files = additionalData.isPresent() ? (Map<String, InputStream>) additionalData.get() : Map.of();
264                        RichTextImportHandler handler = _richTextHandlerFactory.createHandlerProxy(transformerhandler, richText, files);
265                        SAXResult saxResult = new SAXResult(handler);
266                        
267                        transformer.transform(domSource, saxResult);
268                    }
269                    else
270                    {
271                        return null;
272                    }
273                }
274                catch (TransformerException | IOException e)
275                {
276                    throw new IllegalArgumentException("Unable to get the rich text value from the given XML", e);
277                }
278            }
279            else
280            {
281                String data = element.getTextContent();
282                if (StringUtils.isNotBlank(data))
283                {
284                    try (OutputStream os = richText.getOutputStream())
285                    {
286                        os.write(data.getBytes(encoding));
287                    }
288                    catch (IOException e)
289                    {
290                        throw new IllegalArgumentException("Unable to get the rich text value from the given XML", e);
291                    }
292                }
293                else
294                {
295                    return null;
296                }
297            }
298            
299            return richText;
300        }
301        else
302        {
303            return null;
304        }
305    }
306    
307    @Override
308    protected void _valueToSAXForEdition(ContentHandler contentHandler, String tagName, Object value, Optional<ViewItem> viewItem, DataContext context, AttributesImpl attributes) throws SAXException
309    {
310        if (value instanceof RichText)
311        {
312            _singleValueToSAXForEdition(contentHandler, tagName, (RichText) value, context, attributes);
313        }
314        else if (value instanceof RichText[])
315        {
316            for (RichText richText : (RichText[]) value)
317            {
318                _singleValueToSAXForEdition(contentHandler, tagName, richText, context, attributes);
319            }
320        }
321    }
322    
323    private void _singleValueToSAXForEdition(ContentHandler contentHandler, String tagName, RichText richText, DataContext context, AttributesImpl attributes) throws SAXException
324    {
325        AttributesImpl localAttributes = new AttributesImpl(attributes);
326        _addAttributesToSAX(richText, localAttributes);
327        XMLUtils.startElement(contentHandler, tagName, localAttributes);
328        
329        StringBuilder result = new StringBuilder(2048);
330        
331        try
332        {
333            _richTextTransformer.transformForEditing(richText, context, result);
334        }
335        catch (IOException e)
336        {
337            throw new SAXException("Unable to transform a rich text into a string", e);
338        }
339        
340        XMLUtils.data(contentHandler, result.toString());
341        
342        XMLUtils.endElement(contentHandler, tagName);
343    }
344
345    @Override
346    protected void _singleValueToSAX(ContentHandler contentHandler, String tagName, RichText richText, Optional<ViewItem> viewItem, DataContext context, AttributesImpl attributes) throws SAXException
347    {
348        AttributesImpl localAttributes = new AttributesImpl(attributes);
349        _addAttributesToSAX(richText, localAttributes);
350        ContentHandler proxiedHandler = _getLocalMediaObjectContentHandler(contentHandler, context);
351        try
352        {
353            XMLUtils.startElement(contentHandler, tagName, localAttributes);
354
355            String mimeType = richText.getMimeType();
356            if (mimeType.equals("text/xml") || mimeType.equals("application/xml"))
357            {
358                _richTextTransformer.transformForRendering(richText, proxiedHandler);
359            }
360            else if (mimeType.equals("text/plain"))
361            {
362                try (InputStream is = richText.getInputStream())
363                {
364                    XMLUtils.data(proxiedHandler, IOUtils.toString(is, richText.getEncoding()));
365                }
366            }
367            else
368            {
369                throw new SAXException("Mime-type " + mimeType + " is not supported for the sax rich text");
370            }
371
372            XMLUtils.endElement(contentHandler, tagName);
373        }
374        catch (IOException e)
375        {
376            throw new SAXException("Unable to generate SAX events for the given rich text due to an I/O error", e);
377        }
378    }
379    
380    /**
381     * Retrieves the content handler to use to transform local media objects
382     * @param contentHandler the initial {@link ContentHandler}
383     * @param context The context of the data to SAX
384     * @return the content handler
385     */
386    protected ContentHandler _getLocalMediaObjectContentHandler(ContentHandler contentHandler, DataContext context)
387    {
388        return new LocalMediaObjectHandler(contentHandler, context);
389    }
390    
391    private void _addAttributesToSAX(RichText richText, AttributesImpl attributes)
392    {
393        String mimeType = richText.getMimeType();
394        attributes.addCDATAAttribute("mime-type", mimeType);
395        Optional.ofNullable(richText.getLastModificationDate()).ifPresent(lastModificationDate -> attributes.addCDATAAttribute("lastModified", lastModificationDate.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)));
396
397        String encoding = _getNonNullEncoding(richText);
398        attributes.addCDATAAttribute("encoding", encoding);
399    }
400    
401    private String _getNonNullEncoding(RichText richText)
402    {
403        String encoding = richText.getEncoding();
404        if (encoding == null)
405        {
406            encoding = StandardCharsets.UTF_8.name();
407        }
408        return encoding;
409    }
410    
411    @Override
412    protected Stream<Triple<DataChangeType, DataChangeTypeDetail, String>> _compareMultipleValues(RichText[] value1, RichText[] value2)
413    {
414        throw new UnsupportedOperationException("Unable to compare multiple values of type '" + getId() + "'");
415    }
416    
417    @Override
418    protected Stream<Triple<DataChangeType, DataChangeTypeDetail, String>> _compareSingleValues(RichText value1, RichText value2)
419    {
420        if (ModelItemTypeHelper.areSingleObjectsBothNotNullAndDifferents(value1, value2))
421        {
422            return ResourceElementTypeHelper.compareSingleRichTexts(value1, value2);
423        }
424        else
425        {
426            return Stream.of(ModelItemTypeHelper.compareSingleObjects(value1, value2, StringUtils.EMPTY))
427                         .filter(Optional::isPresent)
428                         .map(Optional::get);
429        }
430    }
431
432    public boolean isSimple()
433    {
434        return false;
435    }
436}