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}