001/* 002 * Copyright 2022 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.workspaces.util; 017 018import java.io.IOException; 019import java.io.InputStream; 020import java.time.ZonedDateTime; 021import java.util.ArrayList; 022import java.util.HashMap; 023import java.util.List; 024import java.util.Map; 025import java.util.Optional; 026import java.util.stream.Collectors; 027 028import org.apache.avalon.framework.service.ServiceException; 029import org.apache.avalon.framework.service.ServiceManager; 030import org.apache.avalon.framework.service.Serviceable; 031import org.apache.commons.io.IOUtils; 032import org.apache.commons.lang3.StringUtils; 033import org.apache.commons.text.StringEscapeUtils; 034import org.apache.excalibur.source.Source; 035import org.apache.excalibur.source.SourceResolver; 036import org.apache.excalibur.xml.sax.SAXParser; 037import org.xml.sax.InputSource; 038 039import org.ametys.cms.content.RichTextHandler; 040import org.ametys.cms.data.Binary; 041import org.ametys.cms.data.RichText; 042import org.ametys.cms.repository.Content; 043import org.ametys.cms.repository.ReactionableObject.ReactionType; 044import org.ametys.cms.repository.comment.AbstractComment; 045import org.ametys.cms.repository.comment.Comment; 046import org.ametys.cms.repository.comment.CommentsDAO; 047import org.ametys.cms.repository.comment.RichTextComment; 048import org.ametys.cms.transformation.RichTextTransformer; 049import org.ametys.core.user.CurrentUserProvider; 050import org.ametys.core.user.User; 051import org.ametys.core.user.UserIdentity; 052import org.ametys.core.user.UserManager; 053import org.ametys.core.user.population.PopulationContextHelper; 054import org.ametys.plugins.core.user.UserHelper; 055import org.ametys.plugins.repository.AmetysRepositoryException; 056import org.ametys.plugins.repository.tag.TaggableAmetysObject; 057import org.ametys.plugins.workspaces.members.ProjectMemberManager; 058import org.ametys.plugins.workspaces.tags.ProjectTagProviderExtensionPoint; 059import org.ametys.runtime.model.type.DataContext; 060import org.ametys.runtime.plugin.component.AbstractLogEnabled; 061 062/** 063 * Common helper for workspace objects 064 */ 065public class WorkspaceObjectJSONHelper extends AbstractLogEnabled implements Serviceable 066{ 067 /** Attribute to know if error occurred while handling rich text */ 068 public static final String ATTRIBUTE_FOR_RICHTEXT_ERROR = "contentError"; 069 070 /** The user helper */ 071 protected UserHelper _userHelper; 072 073 /** The tag provider extension point */ 074 protected ProjectTagProviderExtensionPoint _tagProviderExtensionPoint; 075 076 /** The project member manager */ 077 protected ProjectMemberManager _projectMemberManager; 078 079 /** The user manager */ 080 protected UserManager _userManager; 081 082 /** The current user provider */ 083 protected CurrentUserProvider _currentUserProvider; 084 085 /** The population context helper */ 086 protected PopulationContextHelper _populationContextHelper; 087 088 /** Source resolver */ 089 protected SourceResolver _sourceResolver; 090 091 /** Rich text transformer */ 092 protected RichTextTransformer _richTextTransformer; 093 /** The service manager */ 094 protected ServiceManager _smanager; 095 096 /** The comments DAO */ 097 protected CommentsDAO _commentsDAO; 098 099 public void service(ServiceManager manager) throws ServiceException 100 { 101 _smanager = manager; 102 _userHelper = (UserHelper) manager.lookup(UserHelper.ROLE); 103 _tagProviderExtensionPoint = (ProjectTagProviderExtensionPoint) manager.lookup(ProjectTagProviderExtensionPoint.ROLE); 104 _projectMemberManager = (ProjectMemberManager) manager.lookup(ProjectMemberManager.ROLE); 105 _userManager = (UserManager) manager.lookup(UserManager.ROLE); 106 _populationContextHelper = (PopulationContextHelper) manager.lookup(PopulationContextHelper.ROLE); 107 _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE); 108 _richTextTransformer = (RichTextTransformer) manager.lookup(ThreadDocbookTransformer.ROLE); 109 _sourceResolver = (SourceResolver) manager.lookup(SourceResolver.ROLE); 110 _commentsDAO = (CommentsDAO) manager.lookup(CommentsDAO.ROLE); 111 } 112 /** 113 * return a list of comments in JSON format 114 * @param <T> type of the value to retrieve 115 * @param comments the comments to translate as JSON 116 * @param lang the current language 117 * @param siteName The current site name 118 * @return list of comments 119 */ 120 protected <T extends AbstractComment> List<Map<String, Object>> _commentsToJson(List<T> comments, String lang, String siteName) 121 { 122 return _commentsToJson(comments, lang, siteName, null, true); 123 } 124 125 /** 126 * 127 * return a list of comments in JSON format 128 * @param <T> type of the value to retrieve 129 * @param comments the comments to translate as JSON 130 * @param lang the current language 131 * @param siteName The current site name 132 * @param lastReadDate Last read date to check if the comment is unread 133 * @param parseCommentcontent True to parse comment content 134 * @return list of comments 135 */ 136 protected <T extends AbstractComment> List<Map<String, Object>> _commentsToJson(List<T> comments, String lang, String siteName, ZonedDateTime lastReadDate, boolean parseCommentcontent) 137 { 138 List<Map<String, Object>> json = new ArrayList<>(); 139 140 for (T comment : comments) 141 { 142 json.add(_commentToJson(comment, lang, siteName, lastReadDate, parseCommentcontent)); 143 } 144 145 return json; 146 } 147 /** 148 * Transform the rich text as rendering string 149 * @param content the content 150 * @return the rendering string 151 * @throws IOException if I/O error occurred. 152 */ 153 public String richTextToRendering(RichText content) throws IOException 154 { 155 Source src = null; 156 try (InputStream contentIs = content.getInputStream()) 157 { 158 159 Map<String, Object> parameters = new HashMap<>(); 160 parameters.put("source", contentIs); 161 parameters.put("dataContext", DataContext.newInstance()); 162 parameters.put("level", 2); 163 164 src = _sourceResolver.resolveURI("cocoon://plugins/workspaces/convert/docbook2htmlrendering", null, parameters); 165 try (InputStream is = src.getInputStream()) 166 { 167 String renderingText = IOUtils.toString(is, "UTF-8"); 168 169 // FIXME Try to find a better workaround 170 renderingText = StringUtils.replace(renderingText, " xmlns:i18n=\"http://apache.org/cocoon/i18n/2.1\"", ""); 171 renderingText = StringUtils.replace(renderingText, " xmlns:ametys=\"org.ametys.cms.transformation.xslt.AmetysXSLTHelper\"", ""); 172 renderingText = StringUtils.replace(renderingText, " xmlns:docbook=\"http://docbook.org/ns/docbook\"", ""); 173 renderingText = StringUtils.replace(renderingText, " xmlns:project=\"org.ametys.plugins.workspaces.project.helper.ProjectXsltHelper\"", ""); 174 175 renderingText = StringUtils.substringAfter(renderingText, "<xml>"); 176 renderingText = StringUtils.substringBefore(renderingText, "</xml>"); 177 return renderingText; 178 } 179 } 180 finally 181 { 182 _sourceResolver.release(src); 183 } 184 } 185 186 /** 187 * Transform the rich text as simple text (without HTML tag) 188 * @param richText the content 189 * @param maxLength the max length for content excerpt. Set to 0 to not truncate content. 190 * @return the simple text, truncated if maxLength > 0 191 */ 192 public String richTextToSimpleText(RichText richText, int maxLength) 193 { 194 SAXParser saxParser = null; 195 try (InputStream is = richText.getInputStream()) 196 { 197 RichTextHandler txtHandler = new RichTextHandler(maxLength); 198 saxParser = (SAXParser) _smanager.lookup(SAXParser.ROLE); 199 saxParser.parse(new InputSource(is), txtHandler); 200 201 return txtHandler.getValue(); 202 } 203 catch (Exception e) 204 { 205 getLogger().error("Cannot extract simple text from richtext", e); 206 } 207 finally 208 { 209 _smanager.release(saxParser); 210 } 211 212 return ""; 213 } 214 215 /** 216 * return a list of comments in JSON format 217 * @param <T> type of the value to retrieve 218 * @param comment the comment to translate as JSON 219 * @param lang the current language 220 * @param siteName The current site name 221 * @param lastReadDate Last read date to check if the comment is unread 222 * @param parseCommentcontent True to parse comment content 223 * @return list of comments 224 */ 225 protected <T extends AbstractComment> Map<String, Object> _commentToJson(T comment, String lang, String siteName, ZonedDateTime lastReadDate, boolean parseCommentcontent) 226 { 227 Map<String, Object> commentJson = new HashMap<>(); 228 commentJson.put("id", comment.getId()); 229 commentJson.put("subComments", _commentsToJson(comment.getSubComment(true, true), lang, siteName)); 230 commentJson.put("isDeleted", comment.isDeleted()); 231 commentJson.put("creationDate", comment.getCreationDate()); 232 commentJson.put("isReported", comment.getReportsCount() > 0); 233 234 if (comment.isSubComment()) 235 { 236 T parent = comment.getCommentParent(); 237 commentJson.put("parentId", parent.getId()); 238 User author = _userManager.getUser(parent.getAuthor()); 239 commentJson.put("parentAuthor", _authorToJSON(parent, author, lang)); 240 } 241 242 if (!comment.isDeleted()) 243 { 244 commentJson.put("text", _sanitize(comment.getContent())); 245 246 if (comment instanceof Comment mentionComment) 247 { 248 249 List<Map<String, Object>> mentionedUsers2json = mentionComment.extractMentions() 250 .stream() 251 .map(user -> _userHelper.user2json(user)) 252 .toList(); 253 commentJson.put("mentions", mentionedUsers2json); 254 } 255 256 commentJson.put("isEdited", comment.isEdited()); 257 commentJson.put("nbLike", comment.getReactionUsers(ReactionType.LIKE).size()); 258 List<String> userLikes = comment.getReactionUsers(ReactionType.LIKE) 259 .stream() 260 .map(UserIdentity::userIdentityToString) 261 .collect(Collectors.toList()); 262 commentJson.put("userLikes", userLikes); 263 264 commentJson.put("accepted", comment.isAccepted()); 265 266 UserIdentity currentUser = _currentUserProvider.getUser(); 267 commentJson.put("isLiked", userLikes.contains(UserIdentity.userIdentityToString(currentUser))); 268 269 User author = _userManager.getUser(comment.getAuthor()); 270 271 commentJson.put("author", _authorToJSON(comment, author, lang)); 272 commentJson.put("canHandle", author != null ? author.getIdentity().equals(currentUser) : false); 273 } 274 275 if (lastReadDate != null && comment.getCreationDate().isAfter(lastReadDate)) 276 { 277 commentJson.put("unread", true); 278 } 279 280 if (comment instanceof RichTextComment richTextComment && richTextComment.hasRichTextContent() && parseCommentcontent) 281 { 282 283 RichText richText = richTextComment.getRichTextContent(); 284 StringBuilder result = new StringBuilder(2048); 285 try 286 { 287 _richTextTransformer.transformForEditing(richText, DataContext.newInstance(), result); 288 commentJson.put(RichTextComment.ATTRIBUTE_CONTENT_FOR_EDITING, result.toString()); 289 commentJson.put(RichTextComment.ATTRIBUTE_CONTENT_FOR_RENDERING, richTextToRendering(richText)); 290 } 291 catch (AmetysRepositoryException | IOException e) 292 { 293 commentJson.put(ATTRIBUTE_FOR_RICHTEXT_ERROR, true); 294 getLogger().error("Unable to transform the rich text value into a string", e); 295 } 296 297 } 298 return commentJson; 299 } 300 301 private String _sanitize(String content) 302 { 303 return StringEscapeUtils.escapeHtml4(content); 304 } 305 306 /** 307 * Author to JSON 308 * @param <T> type of the value to retrieve 309 * @param comment the comment 310 * @param author the author 311 * @param lang the current language 312 * @return map representing the author 313 */ 314 protected <T extends AbstractComment> Map<String, Object> _authorToJSON(T comment, User author, String lang) 315 { 316 Map<String, Object> jsonAuthor = new HashMap<>(); 317 jsonAuthor.put("name", Optional.ofNullable(author).map(User::getFullName).orElse(comment.getAuthorName())); 318 if (author != null) 319 { 320 UserIdentity userIdentity = author.getIdentity(); 321 jsonAuthor.put("id", UserIdentity.userIdentityToString(userIdentity)); 322 jsonAuthor.put("login", userIdentity.getLogin()); 323 jsonAuthor.put("populationId", userIdentity.getPopulationId()); 324 Content member = _projectMemberManager.getUserContent(lang, author); 325 if (member != null) 326 { 327 if (member.hasValue("function")) 328 { 329 jsonAuthor.put("function", member.getValue("function")); 330 } 331 if (member.hasValue("organisation-accronym")) 332 { 333 jsonAuthor.put("organisationAcronym", member.getValue("organisation-accronym")); 334 } 335 } 336 } 337 return jsonAuthor; 338 } 339 340 /** 341 * return a binary in JSON format 342 * @param binary the binary 343 * @return the binary in JSON format 344 */ 345 protected Map<String, Object> _binaryToJson(Binary binary) 346 { 347 Map<String, Object> json = new HashMap<>(); 348 349 try (InputStream is = binary.getInputStream()) 350 { 351 json.put("id", binary.getFilename()); 352 json.put("name", binary.getFilename()); 353 json.put("type", binary.getMimeType()); 354 json.put("size", binary.getLength()); 355 } 356 catch (Exception e) 357 { 358 getLogger().error("An error occurred reading binary {}", binary.getFilename(), e); 359 } 360 return json; 361 } 362 363 /** 364 * return a list of tags in JSON format 365 * @param taggableAmetysObject the {@link TaggableAmetysObject} 366 * @param siteName The current site name 367 * @return a list of tags 368 */ 369 protected List<String> _getTags(TaggableAmetysObject taggableAmetysObject, String siteName) 370 { 371 return taggableAmetysObject.getTags() 372 .stream() 373 .filter(tag -> _tagProviderExtensionPoint.hasTag(tag, Map.of("siteName", siteName))) 374 .collect(Collectors.toList()); 375 } 376}