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.stream.StreamResult;
036
037import org.apache.avalon.framework.service.ServiceException;
038import org.apache.avalon.framework.service.ServiceManager;
039import org.apache.cocoon.xml.AttributesImpl;
040import org.apache.cocoon.xml.XMLUtils;
041import org.apache.commons.io.IOUtils;
042import org.apache.commons.lang3.StringUtils;
043import org.apache.commons.lang3.tuple.Triple;
044import org.apache.excalibur.xml.sax.SAXParser;
045import org.w3c.dom.Element;
046import org.xml.sax.ContentHandler;
047import org.xml.sax.InputSource;
048import org.xml.sax.SAXException;
049
050import org.ametys.cms.data.RichText;
051import org.ametys.cms.data.RichTextImportMetadataHandlerFactory;
052import org.ametys.cms.data.RichTextImportMetadataHandlerFactory.RichTextImportMetadataHandler;
053import org.ametys.cms.transformation.RichTextTransformer;
054import org.ametys.cms.transformation.docbook.DocbookTransformer;
055import org.ametys.core.model.type.AbstractElementType;
056import org.ametys.core.model.type.ModelItemTypeHelper;
057import org.ametys.core.util.IgnoreRootHandler;
058import org.ametys.core.util.dom.DOMUtils;
059import org.ametys.plugins.repository.AmetysRepositoryException;
060import org.ametys.runtime.model.ViewItem;
061import org.ametys.runtime.model.compare.DataChangeType;
062import org.ametys.runtime.model.compare.DataChangeTypeDetail;
063import org.ametys.runtime.model.type.DataContext;
064
065/**
066 * Abstract class for rich text type of elements
067 */
068public abstract class AbstractRichTextElementType extends AbstractElementType<RichText>
069{
070    /** Rich text transformer */
071    protected RichTextTransformer _richTextTransformer;
072    /** Rich text metadata handler */
073    protected RichTextImportMetadataHandlerFactory _richTextHandlerFactory;
074    /** Avalon service manager */
075    protected ServiceManager _manager;
076
077    @Override
078    public void service(ServiceManager manager) throws ServiceException
079    {
080        super.service(manager);
081        _richTextTransformer = (RichTextTransformer) manager.lookup(DocbookTransformer.ROLE);
082        _richTextHandlerFactory = (RichTextImportMetadataHandlerFactory) manager.lookup(RichTextImportMetadataHandlerFactory.ROLE);
083        _manager = manager;
084    }
085    
086    @Override
087    public RichText convertValue(Object value)
088    {
089        if (value instanceof String)
090        {
091            return _fromString((String) value);
092        }
093        
094        return super.convertValue(value);
095    }
096    
097    @Override
098    public String toString(RichText value)
099    {
100        throw new UnsupportedOperationException("Unable to convert a rich text to a string value");
101    }
102    
103    public Object fromJSONForClient(Object json)
104    {
105        String content = (String) json;
106        
107        if (StringUtils.isEmpty(content))
108        {
109            return null;
110        }
111        
112        return _fromString(content);
113    }
114    
115    private RichText _fromString(String content)
116    {
117        RichText richText = new RichText();
118        
119        try
120        {
121            _richTextTransformer.transform(content, richText);
122        }
123        catch (AmetysRepositoryException | IOException e)
124        {
125            if (getLogger().isErrorEnabled())
126            {
127                getLogger().error("Unable to transform rich text", e);
128            }
129        }
130        
131        return richText;
132    }
133    
134    @Override
135    protected Object _singleValueToJSON(RichText value, DataContext context)
136    {
137        Map<String, Object> richTextInfos = new LinkedHashMap<>();
138        
139        Optional.ofNullable(value.getMimeType()).ifPresent(mimeType -> richTextInfos.put("mime-type", mimeType));
140        Optional.ofNullable(value.getLastModificationDate()).ifPresent(lastModificationDate -> richTextInfos.put("lastModified", lastModificationDate.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)));
141        
142        try (InputStream is = value.getInputStream())
143        {
144            String encoding = value.getEncoding();
145            richTextInfos.put("value", IOUtils.toString(is, encoding));
146        }
147        catch (IOException e)
148        {
149            throw new RuntimeException("Unable to sax the rich text value", e);
150        }
151        
152        return richTextInfos;
153    }
154    
155    @Override
156    protected RichText _singleValueFromXML(Element element, Optional<Object> additionalData) throws TransformerException, IOException
157    {
158        if (element != null)
159        {
160            RichText richText = new RichText();
161            
162            String mimeType = element.getAttribute("mime-type");
163            String encoding = StringUtils.defaultIfBlank(element.getAttribute("encoding"), StandardCharsets.UTF_8.name());
164            
165            richText.setMimeType(mimeType);
166            richText.setEncoding(encoding);
167            richText.setLastModificationDate(ZonedDateTime.now());
168            
169            if (mimeType.equals("text/xml") || mimeType.equals("application/xml"))
170            {
171                try (OutputStream os = richText.getOutputStream())
172                {
173                    Element childElement = DOMUtils.getFirstChildElement(element);
174                    if (childElement != null)
175                    {
176                        Transformer transformer = TransformerFactory.newInstance().newTransformer();
177                        
178                        Properties format = new Properties();
179                        format.put(OutputKeys.METHOD, "xml");
180                        format.put(OutputKeys.ENCODING, encoding);
181                        format.put(OutputKeys.INDENT, "no");
182                        
183                        transformer.setOutputProperties(format);
184                        
185                        DOMSource domSource = new DOMSource(childElement);
186                        StreamResult result = new StreamResult(os);
187                        
188                        transformer.transform(domSource, result);
189                    }
190                }
191            }
192            else
193            {
194                String data = element.getTextContent();
195                try (OutputStream os = richText.getOutputStream())
196                {
197                    os.write(data.getBytes(encoding));
198                }
199            }
200            
201            _parseAdditionalData(richText, additionalData);
202            
203            return richText;
204        }
205        else
206        {
207            return null;
208        }
209    }
210    
211    private void _parseAdditionalData(RichText richText, Optional<Object> additionalData) throws IOException
212    {
213        try
214        {
215            @SuppressWarnings("unchecked")
216            Map<String, InputStream> files = additionalData.isPresent() ? (Map<String, InputStream>) additionalData.get() : Map.of();
217            RichTextImportMetadataHandler handler = _richTextHandlerFactory.createHandler(richText, files);
218
219            SAXParser saxParser = null;
220            try
221            {
222                saxParser = (SAXParser) _manager.lookup(SAXParser.ROLE);
223                saxParser.parse(new InputSource(richText.getInputStream()), handler);
224            }
225            catch (ServiceException e)
226            {
227                throw new RuntimeException("Unable to get a SAX parser", e);
228            }
229            finally
230            {
231                _manager.release(saxParser);
232            }
233        }
234        catch (SAXException e)
235        {
236            getLogger().warn("Unable to get the attachments and semantic annotations from the rich text.", e);
237        }
238    }
239    
240    @Override
241    protected void _valueToSAXForEdition(ContentHandler contentHandler, String tagName, Object value, Optional<ViewItem> viewItem, DataContext context, AttributesImpl attributes) throws SAXException
242    {
243        if (value instanceof RichText)
244        {
245            _singleValueToSAXForEdition(contentHandler, tagName, (RichText) value, attributes);
246        }
247        else if (value instanceof RichText[])
248        {
249            for (RichText richText : (RichText[]) value)
250            {
251                _singleValueToSAXForEdition(contentHandler, tagName, richText, attributes);
252            }
253        }
254    }
255    
256    private void _singleValueToSAXForEdition(ContentHandler contentHandler, String tagName, RichText richText, AttributesImpl attributes) throws SAXException
257    {
258        AttributesImpl localAttributes = new AttributesImpl(attributes);
259        _addAttributesToSAX(richText, localAttributes);
260        XMLUtils.startElement(contentHandler, tagName, localAttributes);
261        
262        StringBuilder result = new StringBuilder(2048);
263        
264        try
265        {
266            _richTextTransformer.transformForEditing(richText, result);
267        }
268        catch (IOException e)
269        {
270            throw new SAXException("Unable to transform a rich text into a string", e);
271        }
272        
273        XMLUtils.data(contentHandler, result.toString());
274        
275        XMLUtils.endElement(contentHandler, tagName);
276    }
277
278    @Override
279    protected void _singleValueToSAX(ContentHandler contentHandler, String tagName, RichText richText, Optional<ViewItem> viewItem, DataContext context, AttributesImpl attributes) throws SAXException, IOException
280    {
281        AttributesImpl localAttributes = new AttributesImpl(attributes);
282        _addAttributesToSAX(richText, localAttributes);
283        XMLUtils.startElement(contentHandler, tagName, localAttributes);
284
285        String mimeType = richText.getMimeType();
286        if (mimeType.equals("text/xml") || mimeType.equals("application/xml"))
287        {
288            SAXParser saxParser = null;
289            try (InputStream is = richText.getInputStream())
290            {
291                saxParser = (SAXParser) _manager.lookup(SAXParser.ROLE);
292                saxParser.parse(new InputSource(is), new IgnoreRootHandler(contentHandler));
293            }
294            catch (ServiceException e)
295            {
296                throw new RuntimeException("Unable to get a SAX parser", e);
297            }
298            finally
299            {
300                _manager.release(saxParser);
301            }
302        }
303        else if (mimeType.equals("text/plain"))
304        {
305            try (InputStream is = richText.getInputStream())
306            {
307                XMLUtils.data(contentHandler, IOUtils.toString(is, richText.getEncoding()));
308            }
309        }
310        else
311        {
312            throw new SAXException("Mime-type " + mimeType + " is not supported for the sax rich text");
313        }
314        
315        XMLUtils.endElement(contentHandler, tagName);
316    }
317    
318    private void _addAttributesToSAX(RichText richText, AttributesImpl attributes)
319    {
320        String mimeType = richText.getMimeType();
321        attributes.addCDATAAttribute("mime-type", mimeType);
322        Optional.ofNullable(richText.getLastModificationDate()).ifPresent(lastModificationDate -> attributes.addCDATAAttribute("lastModified", lastModificationDate.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)));
323
324        String encoding = _getNonNullEncoding(richText);
325        attributes.addCDATAAttribute("encoding", encoding);
326    }
327    
328    private String _getNonNullEncoding(RichText richText)
329    {
330        String encoding = richText.getEncoding();
331        if (encoding == null)
332        {
333            encoding = StandardCharsets.UTF_8.name();
334        }
335        return encoding;
336    }
337    
338    @Override
339    protected Stream<Triple<DataChangeType, DataChangeTypeDetail, String>> _compareMultipleValues(RichText[] value1, RichText[] value2) throws IOException
340    {
341        throw new UnsupportedOperationException("Unable to compare multiple values of type '" + getId() + "'");
342    }
343    
344    @Override
345    protected Stream<Triple<DataChangeType, DataChangeTypeDetail, String>> _compareSingleValues(RichText value1, RichText value2) throws IOException
346    {
347        if (ModelItemTypeHelper.areSingleObjectsBothNotNullAndDifferents(value1, value2))
348        {
349            return ResourceElementTypeHelper.compareSingleRichTexts(value1, value2);
350        }
351        else
352        {
353            return Stream.of(ModelItemTypeHelper.compareSingleObjects(value1, value2, StringUtils.EMPTY))
354                         .filter(Optional::isPresent)
355                         .map(Optional::get);
356        }
357    }
358
359    public boolean isSimple()
360    {
361        return false;
362    }
363}