/**
* Yobi, Project Hosting SW
*
* Copyright 2014 NAVER Corp.
* http://yobi.io
*
* @author Changgun Kim
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package utils;
import controllers.UserApp;
import models.Issue;
import models.Organization;
import models.Project;
import models.User;
import org.apache.commons.lang.ArrayUtils;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.lang3.StringEscapeUtils;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.nodes.TextNode;
import org.jsoup.select.Elements;
import org.tmatesoft.svn.core.SVNException;
import playRepository.Commit;
import playRepository.PlayRepository;
import playRepository.RepositoryService;
import javax.servlet.ServletException;
import java.io.IOException;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* <p>A renderer that makes auto-links from certain references extracted by HTML rendered by marked.js, using pre-defined patterns.</p>
*
* <p>This renderer requires contents of specific objects(issues, comments, etc), and a project containing it.</p>
*
* <p>There are examples of how certain references are changed.</p>
* <pre>
* User/Project#Num: {@code <a href="The link to specific issue in specific project" class="toIssueLink">User/Project#Num</a>}
* User#Num: {@code <a href="The link to specific issue in user's same named project" class="toIssueLink">User#Num</a>}
* #Num: {@code <a href="The link to specific issue in this project" class="toIssueLink">#Num</a>}
* User/Project@SHA: {@code <a href="The link to specific commit in specific project">User/Project@The short id of this commit</a>}
* User{@literal @}SHA: {@code <a href="The link to specific commit in user's same named project">User@The short id of this commit</a>}
* {@literal @}SHA: {@code <a href="The link to specific commit in this project">The short id of this commit</a>}
* {@literal @}User: {@code <a href="The link to specific user">@User</a>}
* {@literal @}User/Project: {@code <a href="The link to specific project">@User/Project</a>}
* </pre>
*/
public class AutoLinkRenderer {
private static final String PATH_PATTERN_STR = "[a-zA-Z0-9-_./]+";
private static final String ISSUE_PATTERN_STR = "\\d+";
private static final String SHA_PATTERN_STR = "[a-f0-9]{7,40}";
private static final Pattern PATH_WITH_ISSUE_PATTERN = Pattern.compile("@?(" + PATH_PATTERN_STR + ")#(" + ISSUE_PATTERN_STR + ")");
private static final Pattern ISSUE_PATTERN = Pattern.compile("#(" + ISSUE_PATTERN_STR + ")");
private static final Pattern PATH_WITH_SHA_PATTERN = Pattern.compile("(" + PATH_PATTERN_STR + ")@?(" + SHA_PATTERN_STR + ")");
private static final Pattern SHA_PATTERN = Pattern.compile("@?(" + SHA_PATTERN_STR + ")");
private static final Pattern LOGIN_ID_PATTERN_ALLOW_FORWARD_SLASH_PATTERN = Pattern.compile("@(" + PATH_PATTERN_STR + ")");
private static final String[] IGNORE_TAGNAME = {"CODE", "A"};
private static final Pattern WORD_PATTERN = Pattern.compile("\\w");
private static class Link {
private static final String DEFAULT_LINK_FORMAT = "<a href='%s' class='%s'>%s</a>";
public static final Link EMPTY_LINK = new Link();
public String href;
public String className;
public String displayName;
private Link() {}
public Link(String href, String displayName) {
this.href = href;
this.displayName = displayName;
}
public Link(String href, String className, String displayName) {
this.href = href;
this.className = className;
this.displayName = displayName;
}
public String toString() {
return String.format(DEFAULT_LINK_FORMAT,
StringUtils.defaultIfEmpty(href, StringUtils.EMPTY),
StringUtils.defaultIfEmpty(className, StringUtils.EMPTY),
StringUtils.defaultIfEmpty(displayName, StringUtils.EMPTY)
);
}
public boolean isValid() {
return this != EMPTY_LINK;
}
}
private static interface ToLink {
public Link toLink(Matcher matcher);
}
public String body;
public Project project;
public AutoLinkRenderer(String body, Project project) {
this.body = body;
this.project = project;
}
public String render() {
return this
.parse(PATH_WITH_ISSUE_PATTERN, new ToLink() {
@Override
public Link toLink(Matcher matcher) {
String path = matcher.group(1);
String issueNumber = matcher.group(2);
Project project = getProjectFromPath(path);
return toValidIssueLink(path, project, issueNumber);
}
})
.parse(ISSUE_PATTERN, new ToLink() {
@Override
public Link toLink(Matcher matcher) {
return toValidIssueLink(project, matcher.group(1));
}
})
.parse(PATH_WITH_SHA_PATTERN, new ToLink() {
@Override
public Link toLink(Matcher matcher) {
String path = matcher.group(1);
String SHA = matcher.group(2);
Project project = getProjectFromPath(path);
return toValidSHALink(path, project, SHA);
}
})
.parse(SHA_PATTERN, new ToLink() {
@Override
public Link toLink(Matcher matcher) {
return toValidSHALink(project, matcher.group(1));
}
})
.parse(LOGIN_ID_PATTERN_ALLOW_FORWARD_SLASH_PATTERN, new ToLink() {
@Override
public Link toLink(Matcher matcher) {
String path = matcher.group(1);
int slashIndex = path.indexOf("/");
if (slashIndex > -1) {
return toValidProjectLink(path.substring(0, slashIndex), path.substring(slashIndex + 1));
} else {
return toValidUserLink(path);
}
}
})
.body;
}
private AutoLinkRenderer parse(Pattern pattern, ToLink toLink) {
Document doc = Jsoup.parse(body);
Document.OutputSettings settings = doc.outputSettings();
settings.prettyPrint(false);
Elements elements = doc.getElementsMatchingOwnText(pattern);
for (Element el : elements) {
if (isIgnoreElement(el)) {
continue;
}
List<TextNode> textNodeList = el.textNodes();
for (TextNode node : textNodeList) {
String result = convertLink(node.text(), pattern, toLink);
node.text(StringUtils.EMPTY);
node.after(result);
}
}
this.body = doc.body().html();
return this;
}
/**
* Using patterns, certain reference into auto-link, using pattern
*
* @param pattern
* @param toLink
* @return
*/
private String convertLink(String text, Pattern pattern, ToLink toLink) {
Matcher matcher = pattern.matcher(text);
StringBuffer sb = new StringBuffer();
while (matcher.find()) {
if (isWrappedNonCharacter(text, matcher)) {
continue;
}
Link link = toLink.toLink(matcher);
if (link.isValid()) {
matcher.appendReplacement(sb, link.toString());
}
}
matcher.appendTail(sb);
return sb.toString();
}
/**
* Get a project from a path consisting of owner and project's name
*
* @param path
* @return
*/
private Project getProjectFromPath(String path) {
int slashIndex = path.indexOf("/");
/**
* If owner has same named project, the project name can be skipped
* See https://help.github.com/articles/writing-on-github/#references
*/
if (slashIndex > -1) {
return Project.findByOwnerAndProjectName(path.substring(0, slashIndex), path.substring(slashIndex + 1));
} else {
return Project.findByOwnerAndProjectName(path, project.name);
}
}
private Link toValidIssueLink(Project project, String issueNumber) {
return toValidIssueLink(StringUtils.EMPTY, project, issueNumber);
}
private Link toValidIssueLink(String prefix, Project project, String issueNumber) {
if (project != null) {
Issue issue = Issue.findByNumber(project, Long.parseLong(issueNumber));
if (issue != null) {
/**
* CSS class name of a link to specific issue is 'issueLink'.
* CSS class name can enable to show the quick view of issue.
*/
if (StringUtils.isEmpty(prefix)) {
return new Link(RouteUtil.getUrl(issue), "issueLink", "#" + issueNumber);
} else {
return new Link(RouteUtil.getUrl(issue), "issueLink", prefix + "#" + issueNumber);
}
}
}
return Link.EMPTY_LINK;
}
private Link toValidSHALink(Project project, String SHA) {
return toValidSHALink(StringUtils.EMPTY, project, SHA);
}
private Link toValidSHALink(String prefix, Project project, String sha) {
if (project != null) {
try {
if (!project.isCodeAvailable() || !project.isGit()) {
return Link.EMPTY_LINK;
}
PlayRepository repository = RepositoryService.getRepository(project);
if (repository != null) {
Commit commit = repository.getCommit(sha);
if (commit != null) {
if (StringUtils.isEmpty(prefix)) {
return new Link(RouteUtil.getUrl(commit, project), commit.getShortId());
} else {
return new Link(RouteUtil.getUrl(commit, project), prefix + "@" + commit.getShortId());
}
}
}
} catch (SVNException svnException) {
return Link.EMPTY_LINK;
} catch (IOException ioException) {
return Link.EMPTY_LINK;
} catch (ServletException servletException) {
return Link.EMPTY_LINK;
}
}
return Link.EMPTY_LINK;
}
private static Link toValidUserLink(String userId) {
User user = User.findByLoginId(userId);
Organization org = Organization.findByName(userId);
if(org != null) {
return new Link(RouteUtil.getUrl(org), "@" + org.name);
}
if (user.isAnonymous() ) {
return Link.EMPTY_LINK;
} else {
String avatarImage;
if( user.avatarUrl().equals(UserApp.DEFAULT_AVATAR_URL) ){
avatarImage = "";
} else {
avatarImage = "<img src='" + user.avatarUrl() + "' class='avatar-wrap smaller no-margin-no-padding vertical-top' alt='@" + user.loginId + "'> ";
}
return new Link(RouteUtil.getUrl(user), "no-text-decoration", "<span data-toggle='popover' data-placement='top' data-trigger='hover' data-html='true' data-content=\"" + StringEscapeUtils.escapeHtml4(avatarImage + user.name) + "\">@" + user.loginId + "</span>");
}
}
private static Link toValidProjectLink(String ownerName, String projectName) {
Project project = Project.findByOwnerAndProjectName(ownerName, projectName);
if (project != null) {
return new Link(RouteUtil.getUrl(project), "@" + project.toString());
} else {
return Link.EMPTY_LINK;
}
}
/**
* * Check whether element is links, code tags.
* @param el
* @return
*/
private boolean isIgnoreElement(Element el) {
return ArrayUtils.contains(IGNORE_TAGNAME, el.tagName().toUpperCase());
}
/**
* Check whether a found matcher is wrapped in non-word character
*
* @param body
* @param matcher
* @return
*/
private static boolean isWrappedNonCharacter(String body, Matcher matcher) {
return (matcher.start() != 0 && WORD_PATTERN.matcher(body.substring(matcher.start() - 1, matcher.start())).find()) ||
(matcher.end() != body.length() && WORD_PATTERN.matcher(body.substring(matcher.end(), matcher.end() + 1)).find());
}
}