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}