Merge pull request #342 from bati11/feature-tasklist

Implement "Task List" in markdown
This commit is contained in:
Naoki Takezoe
2014-11-01 03:09:24 +09:00
12 changed files with 210 additions and 22 deletions

View File

@@ -195,7 +195,7 @@ trait IssuesControllerBase extends ControllerBase {
org.json4s.jackson.Serialization.write( org.json4s.jackson.Serialization.write(
Map("title" -> x.title, Map("title" -> x.title,
"content" -> view.Markdown.toHtml(x.content getOrElse "No description given.", "content" -> view.Markdown.toHtml(x.content getOrElse "No description given.",
repository, false, true) repository, false, true, true, isEditable(x.userName, x.repositoryName, x.openedUserName))
)) ))
} }
} else Unauthorized } else Unauthorized
@@ -212,7 +212,7 @@ trait IssuesControllerBase extends ControllerBase {
contentType = formats("json") contentType = formats("json")
org.json4s.jackson.Serialization.write( org.json4s.jackson.Serialization.write(
Map("content" -> view.Markdown.toHtml(x.content, Map("content" -> view.Markdown.toHtml(x.content,
repository, false, true) repository, false, true, true, isEditable(x.userName, x.repositoryName, x.commentedUserName))
)) ))
} }
} else Unauthorized } else Unauthorized

View File

@@ -77,7 +77,9 @@ trait RepositoryViewerControllerBase extends ControllerBase {
contentType = "text/html" contentType = "text/html"
view.helpers.markdown(params("content"), repository, view.helpers.markdown(params("content"), repository,
params("enableWikiLink").toBoolean, params("enableWikiLink").toBoolean,
params("enableRefsLink").toBoolean) params("enableRefsLink").toBoolean,
params("enableTaskList").toBoolean,
hasWritePermission(repository.owner, repository.name, context.loginAccount))
}) })
/** /**

View File

@@ -9,6 +9,7 @@ import org.pegdown.ast._
import org.pegdown.LinkRenderer.Rendering import org.pegdown.LinkRenderer.Rendering
import java.text.Normalizer import java.text.Normalizer
import java.util.Locale import java.util.Locale
import java.util.regex.Pattern
import scala.collection.JavaConverters._ import scala.collection.JavaConverters._
import service.{RequestCache, WikiService} import service.{RequestCache, WikiService}
@@ -18,17 +19,23 @@ object Markdown {
* Converts Markdown of Wiki pages to HTML. * Converts Markdown of Wiki pages to HTML.
*/ */
def toHtml(markdown: String, repository: service.RepositoryService.RepositoryInfo, def toHtml(markdown: String, repository: service.RepositoryService.RepositoryInfo,
enableWikiLink: Boolean, enableRefsLink: Boolean)(implicit context: app.Context): String = { enableWikiLink: Boolean, enableRefsLink: Boolean,
enableTaskList: Boolean = false, hasWritePermission: Boolean = false)(implicit context: app.Context): String = {
// escape issue id // escape issue id
val source = if(enableRefsLink){ val s = if(enableRefsLink){
markdown.replaceAll("(?<=(\\W|^))#(\\d+)(?=(\\W|$))", "issue:$2") markdown.replaceAll("(?<=(\\W|^))#(\\d+)(?=(\\W|$))", "issue:$2")
} else markdown } else markdown
// escape task list
val source = if(enableTaskList){
GitBucketHtmlSerializer.escapeTaskList(s)
} else s
val rootNode = new PegDownProcessor( val rootNode = new PegDownProcessor(
Extensions.AUTOLINKS | Extensions.WIKILINKS | Extensions.FENCED_CODE_BLOCKS | Extensions.TABLES | Extensions.HARDWRAPS Extensions.AUTOLINKS | Extensions.WIKILINKS | Extensions.FENCED_CODE_BLOCKS | Extensions.TABLES | Extensions.HARDWRAPS
).parseMarkdown(source.toCharArray) ).parseMarkdown(source.toCharArray)
new GitBucketHtmlSerializer(markdown, repository, enableWikiLink, enableRefsLink).toHtml(rootNode) new GitBucketHtmlSerializer(markdown, repository, enableWikiLink, enableRefsLink, enableTaskList, hasWritePermission).toHtml(rootNode)
} }
} }
@@ -82,7 +89,9 @@ class GitBucketHtmlSerializer(
markdown: String, markdown: String,
repository: service.RepositoryService.RepositoryInfo, repository: service.RepositoryService.RepositoryInfo,
enableWikiLink: Boolean, enableWikiLink: Boolean,
enableRefsLink: Boolean enableRefsLink: Boolean,
enableTaskList: Boolean,
hasWritePermission: Boolean
)(implicit val context: app.Context) extends ToHtmlSerializer( )(implicit val context: app.Context) extends ToHtmlSerializer(
new GitBucketLinkRender(context, repository, enableWikiLink), new GitBucketLinkRender(context, repository, enableWikiLink),
Map[String, VerbatimSerializer](VerbatimSerializer.DEFAULT -> new GitBucketVerbatimSerializer).asJava Map[String, VerbatimSerializer](VerbatimSerializer.DEFAULT -> new GitBucketVerbatimSerializer).asJava
@@ -143,7 +152,10 @@ class GitBucketHtmlSerializer(
override def visit(node: TextNode): Unit = { override def visit(node: TextNode): Unit = {
// convert commit id and username to link. // convert commit id and username to link.
val text = if(enableRefsLink) convertRefsLinks(node.getText, repository, "issue:") else node.getText val t = if(enableRefsLink) convertRefsLinks(node.getText, repository, "issue:") else node.getText
// convert task list to checkbox.
val text = if(enableTaskList) GitBucketHtmlSerializer.convertCheckBox(t, hasWritePermission) else t
if (abbreviations.isEmpty) { if (abbreviations.isEmpty) {
printer.print(text) printer.print(text)
@@ -151,6 +163,28 @@ class GitBucketHtmlSerializer(
printWithAbbreviations(text) printWithAbbreviations(text)
} }
} }
override def visit(node: BulletListNode): Unit = {
if (printChildrenToString(node).contains("""class="task-list-item-checkbox" """)) {
printer.println().print("""<ul class="task-list">""").indent(+2)
visitChildren(node)
printer.indent(-2).println().print("</ul>")
} else {
printIndentedTag(node, "ul")
}
}
override def visit(node: ListItemNode): Unit = {
if (printChildrenToString(node).contains("""class="task-list-item-checkbox" """)) {
printer.println()
printer.print("""<li class="task-list-item">""")
visitChildren(node)
printer.print("</li>")
} else {
printer.println()
printTag(node, "li")
}
}
} }
object GitBucketHtmlSerializer { object GitBucketHtmlSerializer {
@@ -163,4 +197,14 @@ object GitBucketHtmlSerializer {
val noSpecialChars = StringUtil.urlEncode(normalized) val noSpecialChars = StringUtil.urlEncode(normalized)
noSpecialChars.toLowerCase(Locale.ENGLISH) noSpecialChars.toLowerCase(Locale.ENGLISH)
} }
def escapeTaskList(text: String): String = {
Pattern.compile("""^( *)- \[([x| ])\] """, Pattern.MULTILINE).matcher(text).replaceAll("$1* task:$2: ")
}
def convertCheckBox(text: String, hasWritePermission: Boolean): String = {
val disabled = if (hasWritePermission) "" else "disabled"
text.replaceAll("task:x:", """<input type="checkbox" class="task-list-item-checkbox" checked="checked" """ + disabled + "/>")
.replaceAll("task: :", """<input type="checkbox" class="task-list-item-checkbox" """ + disabled + "/>")
}
} }

View File

@@ -48,8 +48,8 @@ object helpers extends AvatarImageProvider with LinkConverter with RequestCache
* Converts Markdown of Wiki pages to HTML. * Converts Markdown of Wiki pages to HTML.
*/ */
def markdown(value: String, repository: service.RepositoryService.RepositoryInfo, def markdown(value: String, repository: service.RepositoryService.RepositoryInfo,
enableWikiLink: Boolean, enableRefsLink: Boolean)(implicit context: app.Context): Html = enableWikiLink: Boolean, enableRefsLink: Boolean, enableTaskList: Boolean = false, hasWritePermission: Boolean = false)(implicit context: app.Context): Html =
Html(Markdown.toHtml(value, repository, enableWikiLink, enableRefsLink)) Html(Markdown.toHtml(value, repository, enableWikiLink, enableRefsLink, enableTaskList, hasWritePermission))
def renderMarkup(filePath: List[String], fileContent: String, branch: String, def renderMarkup(filePath: List[String], fileContent: String, branch: String,
repository: service.RepositoryService.RepositoryInfo, repository: service.RepositoryService.RepositoryInfo,

View File

@@ -1,4 +1,4 @@
@(repository: service.RepositoryService.RepositoryInfo, content: String, enableWikiLink: Boolean, enableRefsLink: Boolean, @(repository: service.RepositoryService.RepositoryInfo, content: String, enableWikiLink: Boolean, enableRefsLink: Boolean, enableTaskList: Boolean, hasWritePermission: Boolean,
style: String = "", placeholder: String = "Leave a comment", elastic: Boolean = false)(implicit context: app.Context) style: String = "", placeholder: String = "Leave a comment", elastic: Boolean = false)(implicit context: app.Context)
@import context._ @import context._
@import view.helpers._ @import view.helpers._
@@ -38,7 +38,8 @@ $(function(){
$.post('@url(repository)/_preview', { $.post('@url(repository)/_preview', {
content : $('#content').val(), content : $('#content').val(),
enableWikiLink : @enableWikiLink, enableWikiLink : @enableWikiLink,
enableRefsLink : @enableRefsLink enableRefsLink : @enableRefsLink,
enableTaskList : @enableTaskList
}, function(data){ }, function(data){
$('#preview-area').html(data); $('#preview-area').html(data);
prettyPrint(); prettyPrint();

View File

@@ -10,7 +10,7 @@
<div class="issue-avatar-image">@avatar(loginAccount.get.userName, 48)</div> <div class="issue-avatar-image">@avatar(loginAccount.get.userName, 48)</div>
<div class="box issue-comment-box"> <div class="box issue-comment-box">
<div class="box-content"> <div class="box-content">
@helper.html.preview(repository, "", false, true, "width: 635px; height: 100px; max-height: 150px;", elastic = true) @helper.html.preview(repository, "", false, true, true, hasWritePermission, "width: 635px; height: 100px; max-height: 150px;", elastic = true)
</div> </div>
</div> </div>
<div class="pull-right"> <div class="pull-right">

View File

@@ -16,7 +16,7 @@
</span> </span>
</div> </div>
<div class="box-content issue-content" id="issueContent"> <div class="box-content issue-content" id="issueContent">
@markdown(issue.content getOrElse "No description provided.", repository, false, true) @markdown(issue.content getOrElse "No description provided.", repository, false, true, true, hasWritePermission)
</div> </div>
</div> </div>
@@ -46,7 +46,7 @@
@if(comment.action == "commit" && comment.content.split(" ").last.matches("[a-f0-9]{40}")){ @if(comment.action == "commit" && comment.content.split(" ").last.matches("[a-f0-9]{40}")){
@defining(comment.content.substring(comment.content.length - 40)){ id => @defining(comment.content.substring(comment.content.length - 40)){ id =>
<div class="pull-right"><a href="@path/@repository.owner/@repository.name/commit/@id" class="monospace">@id.substring(0, 7)</a></div> <div class="pull-right"><a href="@path/@repository.owner/@repository.name/commit/@id" class="monospace">@id.substring(0, 7)</a></div>
@markdown(comment.content.substring(0, comment.content.length - 41), repository, false, true) @markdown(comment.content.substring(0, comment.content.length - 41), repository, false, true, true, hasWritePermission)
} }
} else { } else {
@if(comment.action == "refer"){ @if(comment.action == "refer"){
@@ -54,7 +54,7 @@
<strong><a href="@path/@repository.owner/@repository.name/issues/@issueId">Issue #@issueId</a>: @rest.mkString(":")</strong> <strong><a href="@path/@repository.owner/@repository.name/issues/@issueId">Issue #@issueId</a>: @rest.mkString(":")</strong>
} }
} else { } else {
@markdown(comment.content, repository, false, true) @markdown(comment.content, repository, false, true, true, hasWritePermission)
} }
} }
</div> </div>
@@ -134,5 +134,67 @@ $(function(){
} }
return false; return false;
}); });
var extractMarkdown = function(data){
$('body').append('<div id="tmp"></div>');
$('#tmp').html(data);
var markdown = $('#tmp textarea').val();
$('#tmp').remove();
return markdown;
};
var replaceTaskList = function(issueContentHtml, checkboxes) {
var ss = [],
markdown = extractMarkdown(issueContentHtml),
xs = markdown.split(/- \[[x| ]\]/g);
for (var i=0; i<xs.length; i++) {
ss.push(xs[i]);
if (checkboxes.eq(i).prop('checked')) ss.push('- [x]');
else ss.push('- [ ]');
}
ss.pop();
return ss.join('');
};
$('#issueContent').on('click', ':checkbox', function(ev){
var checkboxes = $('#issueContent :checkbox');
$.get('@url(repository)/issues/_data/@issue.issueId',
{
dataType : 'html'
},
function(responseContent){
$.ajax({
url: '@url(repository)/issues/edit/@issue.issueId',
type: 'POST',
data: {
title : $('#issueTitle').text(),
content : replaceTaskList(responseContent, checkboxes)
}
});
}
);
});
$('div[id^=commentContent-]').on('click', ':checkbox', function(ev){
var $commentContent = $(ev.target).parents('div[id^=commentContent-]'),
commentId = $commentContent.attr('id').replace(/commentContent-/, ''),
checkboxes = $commentContent.find(':checkbox');
$.get('@url(repository)/issue_comments/_data/' + commentId,
{
dataType : 'html'
},
function(responseContent){
$.ajax({
url: '@url(repository)/issue_comments/edit/' + commentId,
type: 'POST',
data: {
issueId : 0,
content : replaceTaskList(responseContent, checkboxes)
}
});
}
);
});
}); });
</script> </script>

View File

@@ -57,7 +57,7 @@
</div> </div>
</div> </div>
<hr> <hr>
@helper.html.preview(repository, "", false, true, "width: 565px; height: 200px; max-height: 250px;", elastic = true) @helper.html.preview(repository, "", false, true, true, hasWritePermission, "width: 565px; height: 200px; max-height: 250px;", elastic = true)
</div> </div>
</div> </div>
<div class="pull-right"> <div class="pull-right">

View File

@@ -58,7 +58,7 @@
<div style="width: 600px; border-right: 1px solid #d4d4d4;"> <div style="width: 600px; border-right: 1px solid #d4d4d4;">
<span class="error" id="error-title"></span> <span class="error" id="error-title"></span>
<input type="text" name="title" style="width: 580px" placeholder="Title"/> <input type="text" name="title" style="width: 580px" placeholder="Title"/>
@helper.html.preview(repository, "", false, true, "width: 580px; height: 200px;") @helper.html.preview(repository, "", false, true, true, hasWritePermission, "width: 580px; height: 200px;")
<input type="hidden" name="targetUserName" value="@originRepository.owner"/> <input type="hidden" name="targetUserName" value="@originRepository.owner"/>
<input type="hidden" name="targetBranch" value="@originId"/> <input type="hidden" name="targetBranch" value="@originId"/>
<input type="hidden" name="requestUserName" value="@forkedRepository.owner"/> <input type="hidden" name="requestUserName" value="@forkedRepository.owner"/>

View File

@@ -22,7 +22,7 @@
<form action="@url(repository)/wiki/@if(page.isEmpty){_new} else {_edit}" method="POST" validate="true"> <form action="@url(repository)/wiki/@if(page.isEmpty){_new} else {_edit}" method="POST" validate="true">
<span id="error-pageName" class="error"></span> <span id="error-pageName" class="error"></span>
<input type="text" name="pageName" value="@pageName" style="width: 850px; font-weight: bold;" placeholder="Input a page name."/> <input type="text" name="pageName" value="@pageName" style="width: 850px; font-weight: bold;" placeholder="Input a page name."/>
@helper.html.preview(repository, page.map(_.content).getOrElse(""), true, false, "width: 850px; height: 400px;", "") @helper.html.preview(repository, page.map(_.content).getOrElse(""), true, false, false, false, "width: 850px; height: 400px;", "")
<input type="text" name="message" value="" style="width: 850px;" placeholder="Write a small message here explaining this change. (Optional)"/> <input type="text" name="message" value="" style="width: 850px;" placeholder="Write a small message here explaining this change. (Optional)"/>
<input type="hidden" name="currentPageName" value="@pageName"/> <input type="hidden" name="currentPageName" value="@pageName"/>
<input type="hidden" name="id" value="@page.map(_.id)"/> <input type="hidden" name="id" value="@page.map(_.id)"/>

View File

@@ -877,6 +877,20 @@ div.attachable div.clickable {
background-color: white; background-color: white;
} }
ul.task-list {
padding-left: 2em;
margin-left: 0;
}
li.task-list-item {
list-style-type: none;
}
li.task-list-item input.task-list-item-checkbox {
margin: 0 4px 0.25em -20px;
vertical-align: middle;
}
/****************************************************************************/ /****************************************************************************/
/* Pull Request */ /* Pull Request */
/****************************************************************************/ /****************************************************************************/

View File

@@ -25,4 +25,69 @@ class GitBucketHtmlSerializerSpec extends Specification {
after mustEqual "foo%21bar%40baz%3e9000" after mustEqual "foo%21bar%40baz%3e9000"
} }
} }
"escapeTaskList" should {
"convert '- [ ] ' to '* task: :'" in {
val before = "- [ ] aaaa"
val after = escapeTaskList(before)
after mustEqual "* task: : aaaa"
}
"convert ' - [ ] ' to ' * task: :'" in {
val before = " - [ ] aaaa"
val after = escapeTaskList(before)
after mustEqual " * task: : aaaa"
}
"convert only first '- [ ] '" in {
val before = " - [ ] aaaa - [ ] bbb"
val after = escapeTaskList(before)
after mustEqual " * task: : aaaa - [ ] bbb"
}
"convert '- [x] ' to '* task:x:'" in {
val before = " - [x] aaaa"
val after = escapeTaskList(before)
after mustEqual " * task:x: aaaa"
}
"convert multi lines" in {
val before = """
tasks
- [x] aaaa
- [ ] bbb
"""
val after = escapeTaskList(before)
after mustEqual """
tasks
* task:x: aaaa
* task: : bbb
"""
}
"no convert if inserted before '- [ ] '" in {
val before = " a - [ ] aaaa"
val after = escapeTaskList(before)
after mustEqual " a - [ ] aaaa"
}
"no convert '- [] '" in {
val before = " - [] aaaa"
val after = escapeTaskList(before)
after mustEqual " - [] aaaa"
}
"no convert '- [ ]a'" in {
val before = " - [ ]a aaaa"
val after = escapeTaskList(before)
after mustEqual " - [ ]a aaaa"
}
"no convert '-[ ] '" in {
val before = " -[ ] aaaa"
val after = escapeTaskList(before)
after mustEqual " -[ ] aaaa"
}
}
} }