Skip to content

Commit

Permalink
Communication: Fix announcement emails not rendering correctly (#9850)
Browse files Browse the repository at this point in the history
  • Loading branch information
PaRangger authored Nov 26, 2024
1 parent 0851344 commit e8ecd40
Show file tree
Hide file tree
Showing 10 changed files with 409 additions and 6 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE;

import java.net.URL;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Set;

Expand Down Expand Up @@ -64,6 +66,8 @@ public class MailService implements InstantNotificationService {

private final MailSendingService mailSendingService;

private final List<MarkdownCustomRendererService> markdownCustomRendererServices;

// notification related variables

private static final String NOTIFICATION = "notification";
Expand All @@ -89,11 +93,16 @@ public class MailService implements InstantNotificationService {

private static final String WEEKLY_SUMMARY_NEW_EXERCISES = "weeklySummaryNewExercises";

public MailService(MessageSource messageSource, SpringTemplateEngine templateEngine, TimeService timeService, MailSendingService mailSendingService) {
private final HashMap<Long, String> renderedPosts;

public MailService(MessageSource messageSource, SpringTemplateEngine templateEngine, TimeService timeService, MailSendingService mailSendingService,
MarkdownCustomLinkRendererService markdownCustomLinkRendererService, MarkdownCustomReferenceRendererService markdownCustomReferenceRendererService) {
this.messageSource = messageSource;
this.templateEngine = templateEngine;
this.timeService = timeService;
this.mailSendingService = mailSendingService;
markdownCustomRendererServices = List.of(markdownCustomLinkRendererService, markdownCustomReferenceRendererService);
renderedPosts = new HashMap<>();
}

/**
Expand Down Expand Up @@ -266,14 +275,30 @@ public void sendNotification(Notification notification, User user, Object notifi

// Render markdown content of post to html
try {
Parser parser = Parser.builder().build();
HtmlRenderer renderer = HtmlRenderer.builder().build();
String postContent = post.getContent();
String renderedPostContent = renderer.render(parser.parse(postContent));
String renderedPostContent;

// To avoid having to re-render the same post multiple times we store it in a hash map
if (renderedPosts.containsKey(post.getId())) {
renderedPostContent = renderedPosts.get(post.getId());
}
else {
Parser parser = Parser.builder().build();
HtmlRenderer renderer = HtmlRenderer.builder()
.attributeProviderFactory(attributeContext -> new MarkdownRelativeToAbsolutePathAttributeProvider(artemisServerUrl.toString()))
.nodeRendererFactory(new MarkdownImageBlockRendererFactory(artemisServerUrl.toString())).build();
String postContent = post.getContent();
renderedPostContent = markdownCustomRendererServices.stream().reduce(renderer.render(parser.parse(postContent)), (s, service) -> service.render(s),
(s1, s2) -> s2);
if (post.getId() != null) {
renderedPosts.put(post.getId(), renderedPostContent);
}
}

post.setContent(renderedPostContent);
}
catch (Exception e) {
// In case something goes wrong, leave content of post as-is
log.error("Error while parsing post content", e);
}
}
else {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package de.tum.cit.aet.artemis.communication.service.notifications;

import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE;

import java.net.URL;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import org.springframework.web.util.UriComponentsBuilder;

/**
* This service implements the rendering of markdown tags that represent a link.
* It takes the tag, transforms it into an <a></a> tag, and sets the corresponding href.
*/
@Profile(PROFILE_CORE)
@Service
public class MarkdownCustomLinkRendererService implements MarkdownCustomRendererService {

private static final Logger log = LoggerFactory.getLogger(MarkdownCustomLinkRendererService.class);

private final Set<String> supportedTags;

@Value("${server.url}")
private URL artemisServerUrl;

public MarkdownCustomLinkRendererService() {
this.supportedTags = Set.of("programming", "modeling", "quiz", "text", "file-upload", "lecture", "attachment", "lecture-unit", "slide", "faq");
}

/**
* Takes a string and replaces all occurrences of custom markdown tags (e.g. [programming], [faq], etc.) with a link
*
* @param content string to render
*
* @return the newly rendered string.
*/
public String render(String content) {
String tagPattern = String.join("|", supportedTags);
// The pattern checks for the occurrence of any tag and then extracts the link from it
Pattern pattern = Pattern.compile("\\[(" + tagPattern + ")\\](.*?)\\((.*?)\\)(.*?)\\[/\\1\\]");
Matcher matcher = pattern.matcher(content);
String parsedContent = content;

while (matcher.find()) {
try {
String textStart = matcher.group(2);
String link = matcher.group(3);
String textEnd = matcher.group(4);
String text = (textStart + " " + textEnd).trim();

String absoluteUrl = UriComponentsBuilder.fromUri(artemisServerUrl.toURI()).path(link).build().toUriString();

parsedContent = parsedContent.substring(0, matcher.start()) + "<a href=\"" + absoluteUrl + "\">" + text + "</a>" + parsedContent.substring(matcher.end());
}
catch (Exception e) {
log.error("Not able to render tag. Replacing with empty.", e);
parsedContent = parsedContent.substring(0, matcher.start()) + parsedContent.substring(matcher.end());
}

matcher = pattern.matcher(parsedContent);
}

return parsedContent;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package de.tum.cit.aet.artemis.communication.service.notifications;

import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE;

import java.util.HashMap;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;

/**
* This service implements the rendering of markdown tags that represent a reference (to e.g. a user).
* These references cannot directly represent a link, so they are rendered as their text only.
*/
@Profile(PROFILE_CORE)
@Service
public class MarkdownCustomReferenceRendererService implements MarkdownCustomRendererService {

private static final Logger log = LoggerFactory.getLogger(MarkdownCustomReferenceRendererService.class);

private final Set<String> supportedTags;

private final HashMap<String, String> startingCharacters;

public MarkdownCustomReferenceRendererService() {
supportedTags = Set.of("user", "channel");
startingCharacters = new HashMap<>();
startingCharacters.put("user", "@");
startingCharacters.put("channel", "#");
}

/**
* Takes a string and replaces all occurrences of custom markdown tags (e.g. [user], [channel], etc.) with text.
* To make it better readable, it prepends an appropriate character. (e.g. for users an @, for channels a #)
*
* @param content string to render
*
* @return the newly rendered string.
*/
@Override
public String render(String content) {
String tagPattern = String.join("|", supportedTags);
Pattern pattern = Pattern.compile("\\[(" + tagPattern + ")\\](.*?)\\((.*?)\\)(.*?)\\[/\\1\\]");
Matcher matcher = pattern.matcher(content);
String parsedContent = content;

while (matcher.find()) {
try {
String tag = matcher.group(1);
String startingCharacter = startingCharacters.get(tag);
startingCharacter = startingCharacter == null ? "" : startingCharacter;
String textStart = matcher.group(2);
String textEnd = matcher.group(4);
String text = startingCharacter + (textStart + " " + textEnd).trim();

parsedContent = parsedContent.substring(0, matcher.start()) + text + parsedContent.substring(matcher.end());
}
catch (Exception e) {
log.error("Not able to render tag. Replacing with empty.", e);
parsedContent = parsedContent.substring(0, matcher.start()) + parsedContent.substring(matcher.end());
}

matcher = pattern.matcher(parsedContent);
}

return parsedContent;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package de.tum.cit.aet.artemis.communication.service.notifications;

public interface MarkdownCustomRendererService {

String render(String content);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package de.tum.cit.aet.artemis.communication.service.notifications;

import java.util.Map;
import java.util.Set;

import org.commonmark.node.Image;
import org.commonmark.node.Node;
import org.commonmark.node.Text;
import org.commonmark.renderer.NodeRenderer;
import org.commonmark.renderer.html.HtmlNodeRendererContext;
import org.commonmark.renderer.html.HtmlWriter;

public class MarkdownImageBlockRenderer implements NodeRenderer {

private final String baseUrl;

private final HtmlWriter html;

MarkdownImageBlockRenderer(HtmlNodeRendererContext context, String baseUrl) {
html = context.getWriter();
this.baseUrl = baseUrl;
}

@Override
public Set<Class<? extends Node>> getNodeTypes() {
return Set.of(Image.class);
}

@Override
public void render(Node node) {
Image image = (Image) node;

html.tag("a", Map.of("href", baseUrl + image.getDestination()));

try {
html.text(((Text) image.getFirstChild()).getLiteral());
}
catch (Exception e) {
html.text(image.getDestination());
}

html.tag("/a");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package de.tum.cit.aet.artemis.communication.service.notifications;

import org.commonmark.renderer.NodeRenderer;
import org.commonmark.renderer.html.HtmlNodeRendererContext;
import org.commonmark.renderer.html.HtmlNodeRendererFactory;

public class MarkdownImageBlockRendererFactory implements HtmlNodeRendererFactory {

private final String baseUrl;

public MarkdownImageBlockRendererFactory(String baseUrl) {
this.baseUrl = baseUrl;
}

@Override
public NodeRenderer create(HtmlNodeRendererContext context) {
return new MarkdownImageBlockRenderer(context, baseUrl);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package de.tum.cit.aet.artemis.communication.service.notifications;

import java.util.Map;

import org.commonmark.node.Node;
import org.commonmark.renderer.html.AttributeProvider;

public class MarkdownRelativeToAbsolutePathAttributeProvider implements AttributeProvider {

private final String baseUrl;

public MarkdownRelativeToAbsolutePathAttributeProvider(String baseUrl) {
this.baseUrl = baseUrl;
}

/**
* We store images and attachments with relative urls, so when rendering we need to replace them with absolute ones
*
* @param node rendered Node, if Image or Link we try to replace the source
* @param attributes of the Node
* @param tagName of the html element
*/
@Override
public void setAttributes(Node node, String tagName, Map<String, String> attributes) {
if ("a".equals(tagName)) {
String href = attributes.get("href");
if (href != null && href.startsWith("/")) {
attributes.put("href", baseUrl + href);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
import de.tum.cit.aet.artemis.communication.domain.notification.NotificationConstants;
import de.tum.cit.aet.artemis.communication.service.notifications.MailSendingService;
import de.tum.cit.aet.artemis.communication.service.notifications.MailService;
import de.tum.cit.aet.artemis.communication.service.notifications.MarkdownCustomLinkRendererService;
import de.tum.cit.aet.artemis.communication.service.notifications.MarkdownCustomReferenceRendererService;
import de.tum.cit.aet.artemis.core.domain.Course;
import de.tum.cit.aet.artemis.core.domain.User;
import de.tum.cit.aet.artemis.core.service.TimeService;
Expand Down Expand Up @@ -115,7 +117,8 @@ void setUp() throws MalformedURLException, URISyntaxException {

mailSendingService = new MailSendingService(jHipsterProperties, javaMailSender);

mailService = new MailService(messageSource, templateEngine, timeService, mailSendingService);
mailService = new MailService(messageSource, templateEngine, timeService, mailSendingService, new MarkdownCustomLinkRendererService(),
new MarkdownCustomReferenceRendererService());
ReflectionTestUtils.setField(mailService, "artemisServerUrl", new URI("http://localhost:8080").toURL());
}

Expand Down
Loading

0 comments on commit e8ecd40

Please sign in to comment.