doortts doortts 2018-08-22
tasklist: New feature - Task List
@cb59184b6c5b5ed2fba40721bd80473f88c07d30
app/assets/stylesheets/less/_markdown.less
--- app/assets/stylesheets/less/_markdown.less
+++ app/assets/stylesheets/less/_markdown.less
@@ -155,10 +155,9 @@
   }
 
   li > input[type='checkbox'] {
-    vertical-align: baseline;
-    position: relative;
+    vertical-align: text-top;
   }
-  
+
   img{
     max-width:100%;
     margin:10px 0;
app/assets/stylesheets/less/_page.less
--- app/assets/stylesheets/less/_page.less
+++ app/assets/stylesheets/less/_page.less
@@ -7321,3 +7321,49 @@
         color: #e91e63 !important;
     }
 }
+
+@keyframes showNav {
+    from {opacity: 0;}
+    to {opacity: 1;}
+}
+
+.task-list-button {
+    margin-left: 20px;
+    margin-top: 2px;
+}
+
+.tasklist {
+    padding: 10px 20px 0 20px;
+    box-shadow: none;
+    filter: none;
+    display: none;
+
+    &.task-show {
+        display: block;
+        animation: showNav 250ms ease-in-out both;
+    }
+
+    .task-title {
+        font-weight: 500;
+
+        .done-counter {
+            margin-left: 5px;
+        }
+    }
+
+    .task-progress{
+        background-color: #D4D4D4;
+
+        .bar {
+            transition: .2s;
+            height: 2px;
+            width: 100%;
+        }
+        .red {
+            background-color: red;
+        }
+        .green {
+            background-color: #8bc34a;
+        }
+    }
+}
app/controllers/IssueApp.java
--- app/controllers/IssueApp.java
+++ app/controllers/IssueApp.java
@@ -910,6 +910,11 @@
         return redirect(RouteUtil.getUrl(savedComment));
     }
 
+    // Just made for compatibility. No meanings.
+    public static Result updateComment(String ownerName, String projectName, Long number, Long commentId) throws IOException {
+        return newComment(ownerName, projectName, number);
+    }
+
     private static Comment saveComment(Project project, Issue issue, IssueComment comment) {
         Comment savedComment;
         IssueComment existingComment = IssueComment.find.where().eq("id", comment.id).findUnique();
app/controllers/api/IssueApi.java
--- app/controllers/api/IssueApi.java
+++ app/controllers/api/IssueApi.java
@@ -19,7 +19,9 @@
 import controllers.routes;
 import models.*;
 import models.enumeration.*;
+import org.apache.commons.codec.digest.DigestUtils;
 import org.apache.commons.lang3.StringUtils;
+import play.api.mvc.Codec;
 import play.db.ebean.Transactional;
 import play.i18n.Messages;
 import play.libs.F;
@@ -186,12 +188,46 @@
         if (!newIssueState.equals(issue.state)) {
             addNewIssueEvent(issue, user, EventType.ISSUE_STATE_CHANGED, issue.state.state(), newIssueState.state());
         }
-        play.Logger.debug("newIssueState: " + newIssueState);
         issue.state = newIssueState;
         issue.save();
 
         result = ProjectApi.getResult(issue);
         return ok(Json.newObject().set("result", toJson(addIssueEvents(issue, result))));
+    }
+
+    @Transactional
+    @IsAllowed(Operation.UPDATE)
+    public static Result updateIssueContent(String owner, String projectName, Long number) {
+        ObjectNode result = Json.newObject();
+
+        User user = UserApp.currentUser();
+        if (user.isAnonymous()) {
+            return unauthorized(result.put("message", "unauthorized request"));
+        }
+
+        JsonNode json = request().body().asJson();
+        if(json == null) {
+            return badRequest(result.put("message", "Expecting Json data"));
+        }
+
+        Project project = Project.findByOwnerAndProjectName(owner, projectName);
+        final Issue issue = Issue.findByNumber(project, number);
+
+        String content = json.findValue("content").asText();
+        String sha1checksum = json.findValue("sha1").asText();
+
+        String originalSha1 = DigestUtils.sha1Hex(issue.body.trim());
+
+        if (!originalSha1.equals(sha1checksum)) {
+            result.put("message", "Already modified by someone.");
+            return new Status(play.core.j.JavaResults.Conflict(), result, Codec.javaSupported("utf-8"));
+        }
+
+        issue.body = content;
+        issue.update();
+
+        result = ProjectApi.getResult(issue);
+        return ok(result);
     }
 
     private static Result updateIssueNode(JsonNode json, Project project, Issue issue, User user) {
@@ -347,7 +383,8 @@
     public static Result updateIssueComment(String ownerName, String projectName, Long number, Long commentId) {
         ObjectNode result = Json.newObject();
 
-        if (!isAuthored(request())) {
+        User user = UserApp.currentUser();
+        if (user.isAnonymous()) {
             return unauthorized(result.put("message", "unauthorized request"));
         }
 
@@ -356,12 +393,20 @@
             return badRequest(result.put("message", "Expecting Json data"));
         }
 
-        User user = getAuthorizedUser(getAuthorizationToken(request()));
-        String comment = json.findValue("comment").asText();
+        String comment = json.findValue("content").asText();
+        String sha1checksum = json.findValue("sha1").asText();
 
         Project project = Project.findByOwnerAndProjectName(ownerName, projectName);
         final Issue issue = Issue.findByNumber(project, number);
         IssueComment issueComment = issue.findCommentByCommentId(commentId);
+
+        String originalSha1 = DigestUtils.sha1Hex(issueComment.contents.trim());
+
+        if (!originalSha1.equals(sha1checksum)) {
+            result.put("message", "Already modified by someone.");
+            result.put("text", issueComment.contents);
+            return new Status(play.core.j.JavaResults.Conflict(), result, Codec.javaSupported("utf-8"));
+        }
 
         issueComment.contents = comment;
         issueComment.save();
@@ -372,7 +417,7 @@
         commentNode.set("author", toJson(authorNode));
         result.set("result", commentNode);
 
-        return created(result);
+        return ok(result);
     }
 
     private static Result createCommentByUser(Project project, Issue issue, JsonNode json) {
app/utils/Markdown.java
--- app/utils/Markdown.java
+++ app/utils/Markdown.java
@@ -42,6 +42,10 @@
                     .allowUrlProtocols("http", "https", "mailto", "file").allowElements("a")
                     .allowAttributes("href", "name", "target").onElements("a")
                     .toFactory())
+            .and(new HtmlPolicyBuilder()
+                    .allowElements("input")
+                    .allowAttributes("type", "disabled", "checked").onElements("input")
+                    .toFactory())
             .and(new HtmlPolicyBuilder().allowElements("pre").toFactory())
             .and(new HtmlPolicyBuilder()
                     .allowAttributes("class", "id", "style", "width", "height").globally().toFactory());
app/views/board/partial_comments.scala.html
--- app/views/board/partial_comments.scala.html
+++ app/views/board/partial_comments.scala.html
@@ -25,6 +25,7 @@
 
 <ul class="comments">
 @for(comment <-post.comments){
+    @defining(isAllowed(UserApp.currentUser(), comment.asResource(), Operation.UPDATE)) { isAllowedUpdate =>
     <li class="comment @isAuthorComment(comment.authorLoginId)" id="comment-@comment.id">
         <div class="comment-avatar">
             <a href="@routes.UserApp.userInfo(comment.authorLoginId)" class="avatar-wrap" data-toggle="tooltip" data-placement="top" title="@comment.authorName">
@@ -58,7 +59,7 @@
                 </span>
             </div>
 
-            @common.commentUpdateForm(comment, routes.BoardApp.newComment(project.owner, project.name, post.getNumber).toString(), comment.contents)
+            @common.commentUpdateForm(comment, routes.BoardApp.newComment(project.owner, project.name, post.getNumber).toString(), comment.contents, isAllowedUpdate)
 
             <div id="comment-body-@comment.id">
                 <div class="comment-body markdown-wrap" data-via-email="@OriginalEmail.exists(comment.asResource)">@Html(Markdown.render(comment.contents, project))</div>
@@ -66,5 +67,6 @@
             </div>
         </div>
     </li>
+    }
 }
 </ul>
app/views/common/commentUpdateForm.scala.html
--- app/views/common/commentUpdateForm.scala.html
+++ app/views/common/commentUpdateForm.scala.html
@@ -4,11 +4,11 @@
 * Copyright Yona & Yobi Authors & NAVER Corp.
 * https://yona.io
 **@
-@(comment:Comment, action:String, contents:String)
+@(comment:Comment, action:String, contents:String, isAllowedUpdate:Boolean)
 @import utils.AccessControl._
 
 <div id="comment-editform-@comment.id" class="comment-update-form">
-    <form action="@action" method="post">
+    <form action="@action/@comment.id" method="post">
         <input type="hidden" name="id" value="@comment.id">
 
         <div class="write-comment-box">
@@ -25,7 +25,7 @@
                     </span>
                     }
                     <button type="button" class="ybtn ybtn-cancel" data-comment-id="@comment.id">@Messages("button.cancel")</button>
-                    @if(isAllowed(UserApp.currentUser(), comment.asResource(), Operation.UPDATE)) {
+                    @if(isAllowedUpdate) {
                         <button type="submit" class="ybtn ybtn-info">@Messages("button.save")</button>
                     }
                 </div>
app/views/common/editor.scala.html
--- app/views/common/editor.scala.html
+++ app/views/common/editor.scala.html
@@ -33,6 +33,9 @@
             <a href="#preview-@wrapId" data-toggle="tab" data-mode="preview">@Messages("common.editor.preview")</a>
         </li>
         <li>
+            <div class="task-list-button"><button type="button" class="add-task-list-button ybtn ybtn-mini ybtn-danger">Add Task List</button></div>
+        </li>
+        <li>
             <div class="editor-notice-label"></div>
         </li>
     </ul>
app/views/common/scripts.scala.html
--- app/views/common/scripts.scala.html
+++ app/views/common/scripts.scala.html
@@ -174,5 +174,26 @@
         });
 
         autosize($('textarea'));
+
+        addTaskListButtonListener();
+        function addTaskListButtonListener() {
+            $(".add-task-list-button").on('click', function () {
+                var tasklistTemplate = "\n- [ ] Todo A\r\n- [ ] Todo B\r\n- [ ] Todo C";
+                var $textarea = $(this).closest("form").find("textarea");
+                var cursorPos = $textarea.prop('selectionStart');
+                var text = $textarea.val();
+
+                if(cursorPos === 0 && text && text.length > 0){
+                    cursorPos = text.length;
+                }
+
+                var textBefore = text.substring(0, cursorPos);
+                var textAfter  = text.substring(cursorPos, text.length);
+
+                $textarea.val(textBefore + tasklistTemplate + textAfter);
+                $textarea.focus();
+            });
+        }
+
     });
 </script>
 
app/views/common/tasklistBar.scala.html (added)
+++ app/views/common/tasklistBar.scala.html
@@ -0,0 +1,12 @@
+@**
+* Yona, 21st Century Project Hosting SW
+*
+* Copyright Yona & Yobi Authors & NAVER Corp. & NAVER LABS Corp.
+* https://yona.io
+**@
+<div class="tasklist">
+    <div class="task-title">Tasks<span class="done-counter"></span></div>
+    <div class="task-progress">
+        <div class="bar red" style="width: 0;" title="Tasklist"></div>
+    </div>
+</div>
app/views/help/markdown.scala.html
--- app/views/help/markdown.scala.html
+++ app/views/help/markdown.scala.html
@@ -1,22 +1,8 @@
 @**
-* Yobi, Project Hosting SW
+* Yona, 21st Century Project Hosting SW
 *
-* Copyright 2013 NAVER Corp.
-* http://yobi.io
-*
-* @author Suwon Chae
-*
-* 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.
+* Copyright Yona & Yobi Authors & NAVER Corp. & NAVER LABS Corp.
+* https://yona.io
 **@
 <div class="markdown-help">
     <ul class="markdown-help-nav">
@@ -28,6 +14,7 @@
         <li class="help-nav" data-toggle="markdown-help" data-target="markdownHeaders">Headers</li>
         <li class="help-nav" data-toggle="markdown-help" data-target="markdownLinks">Links</li>
         <li class="help-nav" data-toggle="markdown-help" data-target="markdownLists">Lists</li>
+        <li class="help-nav" data-toggle="markdown-help" data-target="markdownTaskList">TaskList</li>
         <li class="help-nav" data-toggle="markdown-help" data-target="markdownImages">Images</li>
         <li class="help-nav" data-toggle="markdown-help" data-target="markdownBlockquotes">Blockquotes</li>
         <li class="help-nav" data-toggle="markdown-help" data-target="markdownCodes">Codes</li>
@@ -105,6 +92,36 @@
                 </div>
             </div>
         </li>
+        <li class="markdown-help-item markdownTaskList">
+            <div class="row-fluid thead">
+                <div class="span6">Markdown Input</div>
+                <div class="span6">Markdown Output</div>
+            </div>
+            <div class="row-fluid markdwon-syntax-wrap">
+                <div class="span6 markdwon-syntax">
+                    <pre>
+- [ ] Todos
+    - [x] To do A
+    - [ ] To do B
+    - [ ] To do C
+                    </pre>
+                </div>
+                <div class="span6">
+                    <div class="markdown-wrap">
+                        <ul>
+                            <li>
+                                <input type="checkbox"> Todos
+                                <ul>
+                                    <li><input type="checkbox" checked> To do A</li>
+                                    <li><input type="checkbox"> To do B</li>
+                                    <li><input type="checkbox"> To do C</li>
+                                </ul>
+                            </li>
+                        </ul>
+                    </div>
+                </div>
+            </div>
+        </li>
         <li class="markdown-help-item markdownImages" >
             <div class="row-fluid thead">
                 <div class="span6">Markdown Input</div>
app/views/issue/partial_comment.scala.html
--- app/views/issue/partial_comment.scala.html
+++ app/views/issue/partial_comment.scala.html
@@ -22,6 +22,7 @@
 @VOTER_AVATAR_SHOW_LIMIT = @{ 5 }
 
 <li class="comment @isAuthorComment(comment.authorLoginId)" id="comment-@comment.id">
+@defining(isAllowed(UserApp.currentUser(), comment.asResource(), Operation.UPDATE)) { isAllowedUpdate =>
     @common.childCommentsAnchorDiv(issue, comment)
     <div class="comment-avatar">
         <a href="@userInfo(comment.authorLoginId)" class="avatar-wrap" data-toggle="tooltip" data-placement="top" title="@comment.authorLoginId">
@@ -98,12 +99,14 @@
             </span>
         </div>
 
-        @common.commentUpdateForm(comment, routes.IssueApp.newComment(project.owner, project.name, issue.getNumber).toString(), comment.contents)
+        @common.commentUpdateForm(comment, routes.IssueApp.newComment(project.owner, project.name, issue.getNumber).toString(), comment.contents, isAllowedUpdate)
 
         <div id="comment-body-@comment.id">
-            <div class="comment-body markdown-wrap" data-via-email="@OriginalEmail.exists(comment.asResource)">@Html(Markdown.render(comment.contents, project))</div>
+            @common.tasklistBar()
+            <div class="comment-body markdown-wrap" data-allowed-update="@isAllowedUpdate" data-via-email="@OriginalEmail.exists(comment.asResource)">@Html(Markdown.render(comment.contents, project))</div>
             <div class="attachments pull-right" data-attachments="@toJson(AttachmentApp.getFileList(ResourceType.ISSUE_COMMENT.toString(), comment.id.toString()))"></div>
         </div>
     </div>
     @common.childComments(issue, comment, ResourceType.ISSUE_COMMENT)
+}
 </li>
app/views/issue/view.scala.html
--- app/views/issue/view.scala.html
+++ app/views/issue/view.scala.html
@@ -97,6 +97,10 @@
     FavoriteIssue.findByIssueId(UserApp.currentUser().id, issue.id) != null
 }
 
+@isAllowedUpdate = @{
+    isAllowed(UserApp.currentUser(), issue.asResource(), Operation.UPDATE)
+}
+
 @projectLayout(titleForOGTag, project, utils.MenuType.ISSUE){
 @projectMenu(project, utils.MenuType.ISSUE, "main-menu-only")
 <div class="page-wrap-outer">
@@ -158,7 +162,15 @@
                 @if(StringUtils.isEmpty(issue.body)){
                     <div class="content empty-content"></div>
                 } else {
-                    <div class="content markdown-wrap">@Html(Markdown.render(issue.body, issue.project))</div>
+                    <div id="issue-@issue.getNumber" class="hide">
+                        <form action="@api.routes.IssueApi.updateIssueContent(project.owner, project.name, issue.getNumber)">
+                            <textarea>@issue.body</textarea>
+                        </form>
+                    </div>
+                    <div id="issue-body-@issue.getNumber">
+                        @common.tasklistBar()
+                        <div class="content markdown-wrap" data-allowed-update="@isAllowedUpdate">@Html(Markdown.render(issue.body, issue.project))</div>
+                    </div>
                 }
                 <div class="attachments" id="attachments" data-attachments="@toJson(AttachmentApp.getFileList(ResourceType.ISSUE_POST.toString(), issue.id.toString()))"></div>
                 <div class="board-actrow right-txt">
@@ -174,7 +186,7 @@
                                 }
                                 </button>
                             }
-                            @if(isAllowed(UserApp.currentUser(), issue.asResource(), Operation.UPDATE) && !hasSharer) {
+                            @if(isAllowedUpdate && !hasSharer) {
                                 <button id="issue-share-button" type="button" class="ybtn" data-toggle="popover" data-trigger="hover" data-placement="top" data-content="@Messages("issue.sharer.description")">@Messages("button.share.issue")</button>
                             }
 
@@ -206,7 +218,7 @@
                     }
 
                     <span class="act-row">
-                    @if(isAllowed(UserApp.currentUser(), issue.asResource(), Operation.UPDATE)) {
+                    @if(isAllowedUpdate) {
                         <button type="button" class="icon btn-transparent-with-fontsize-lineheight ml10 pt5px" data-toggle="tooltip" title="@Messages("button.edit")" onclick="window.location='@routes.IssueApp.editIssueForm(project.owner, project.name, issue.getNumber)'"><i class="yobicon-edit-2"></i></button>
                     } else {
                         <a href="@routes.IssueApp.editIssueForm(project.owner, project.name, issue.getNumber)"><button type="button" class="icon btn-transparent-with-fontsize-lineheight ml10 pt5px" data-toggle="tooltip" title="@Messages("button.show.original")"><i class="yobicon-edit-2"></i></button></a>
@@ -226,7 +238,7 @@
                         @Messages("issue.sharer") <span class="num issue-sharer-count">@if(issue.sharers.size > 0) { @issue.sharers.size }</span>
                     </dt>
                     <dd id="sharer-list" class="@if(!hasSharer){hideFromDisplayOnly}">
-                    @if(isAllowed(UserApp.currentUser(), issue.asResource(), Operation.UPDATE)) {
+                    @if(isAllowedUpdate) {
                         <input type="hidden" class="bigdrop width100p" id="issueSharer" name="issueSharer" placeholder="@Messages("issue.sharer.select")" value="@sharers" title="">
                     } else {
                         @for(sharer <- issue.getSortedSharer){
@@ -276,7 +288,7 @@
                             <dt>@Messages("issue.assignee")</dt>
 
                             <dd>
-                            @if(isAllowed(UserApp.currentUser(), issue.asResource(), Operation.UPDATE)) {
+                            @if(isAllowedUpdate) {
                                 @partial_assignee(project, issue)
                             } else {
                                 @if(hasAssignee){
@@ -309,7 +321,7 @@
                                 </a>
                             } else {
                                 @defining(issue.milestone != null) { hasMilestone =>
-                                    @if(isAllowed(UserApp.currentUser(), issue.asResource(), Operation.UPDATE)) {
+                                    @if(isAllowedUpdate) {
                                     <select id="milestone" name="milestone.id"
                                             data-toggle="select2" data-format="milestone" data-container-css-class="fullsize">
                                         <option value="@Milestone.NULL_MILESTONE_ID" @if(!hasMilestone){ selected }>
@@ -366,7 +378,7 @@
                                 </span>
                             </dt>
                             <dd>
-                                @if(isAllowed(UserApp.currentUser(), issue.asResource(), Operation.UPDATE)) {
+                                @if(isAllowedUpdate) {
                                 <div class="search search-bar">
                                     <input type="text" name="dueDate" value="@issue.getDueDateString" class="textbox full" autocomplete="off" data-toggle="calendar">
                                     <button type="button" class="search-btn btn-calendar"><i class="yobicon-calendar2"></i></button>
@@ -383,14 +395,14 @@
 
                         @**<!-- labels -->**@
                         @if(!IssueLabel.findByProject(project).isEmpty){
-                            @if(isAllowed(UserApp.currentUser(), issue.asResource(), Operation.UPDATE)){
+                            @if(isAllowedUpdate){
                                 @partial_select_label(IssueLabel.findByProject(project), issue.getLabelIds, "", "", project)
                             } else {
                                 @partial_show_selected_label(issue.labels.toList, routes.IssueApp.issues(project.owner, project.name, issue.state.state(), "html", 1).toString)
                             }
                         }
                         <div class="act-row right-menu-icons">
-                            @if(isAllowed(UserApp.currentUser(), issue.asResource(), Operation.UPDATE)) {
+                            @if(isAllowedUpdate) {
                                 <button type="button" class="icon btn-transparent-with-fontsize-lineheight ml10 pt5px" data-toggle="tooltip" title="@Messages("button.edit")" onclick="window.location='@routes.IssueApp.editIssueForm(project.owner, project.name, issue.getNumber)'"><i class="yobicon-edit-2"></i></button>
                             } else {
                                 <a href="@routes.IssueApp.editIssueForm(project.owner, project.name, issue.getNumber)"><button type="button" class="icon btn-transparent-with-fontsize-lineheight ml10 pt5px" data-toggle="tooltip" title="@Messages("button.show.original")"><i class="yobicon-edit-2"></i></button></a>
@@ -450,6 +462,8 @@
 <script type="text/javascript" src="@routes.Assets.at("javascripts/lib/elevator/jquery.elevator.js")"></script>
 <script type="text/javascript" src="@routes.Assets.at("javascripts/service/yona.issue.Assginee.js")"></script>
 <script type="text/javascript" src="@routes.Assets.at("javascripts/service/yona.issue.Sharer.js")"></script>
+<script type="text/javascript" src="@routes.Assets.at("javascripts/common/yona.Sha1.js")"></script>
+<script type="text/javascript" src="@routes.Assets.at("javascripts/common/yona.Tasklist.js")"></script>
 <script type="text/javascript">
     $(function(){
         // yobi.issue.View
@@ -471,7 +485,7 @@
             @if(project.menuSetting.issue) {
             ,"N": "@routes.IssueApp.newIssueForm(project.owner, project.name)"
             }
-            @if(isAllowed(UserApp.currentUser(), issue.asResource(), Operation.UPDATE)) {
+            @if(isAllowedUpdate) {
            ,"E": "@routes.IssueApp.editIssueForm(project.owner, project.name, issue.getNumber)"
             }
         });
conf/routes
--- conf/routes
+++ conf/routes
@@ -65,6 +65,7 @@
 GET            /-_-api/v1/owners/:owner/projects/:projectName/issues/:number                    controllers.api.IssueApi.getIssue(owner: String, projectName: String, number: Long)
 PUT            /-_-api/v1/owners/:owner/projects/:projectName/issues/:number                    controllers.api.IssueApi.updateIssue(owner: String, projectName: String, number: Long)
 PATCH          /-_-api/v1/owners/:owner/projects/:projectName/issues/:number                    controllers.api.IssueApi.updateIssueState(owner: String, projectName: String, number: Long)
+PATCH          /-_-api/v1/owners/:owner/projects/:projectName/issues/:number/content            controllers.api.IssueApi.updateIssueContent(owner: String, projectName: String, number: Long)
 GET            /-_-api/v1/users                                                        controllers.UserApp.users(query: String ?= "")
 POST           /-_-api/v1/users                                                        controllers.api.UserApi.newUser()
 POST           /-_-api/v1/users/token                                                  controllers.api.UserApi.newToken()
@@ -255,6 +256,8 @@
 GET            /:user/:project/issue/$number<[0-9]+>/nextstate                        controllers.IssueApp.nextState(user, project, number:Long)
 DELETE         /:user/:project/issue/$number<[0-9]+>/delete                           controllers.IssueApp.deleteIssue(user, project, number:Long)
 POST           /:user/:project/issue/$number<[0-9]+>/comments                         controllers.IssueApp.newComment(user, project, number:Long)
+POST           /:user/:project/issue/$number<[0-9]+>/comments/:commentId              controllers.IssueApp.updateComment(user, project, number:Long, commentId:Long)
+PATCH          /:user/:project/issue/$number<[0-9]+>/comments/:commentId              controllers.api.IssueApi.updateIssueComment(user, project, number:Long, commentId:Long)
 DELETE         /:user/:project/issue/$number<[0-9]+>/comment/:commentId/delete        controllers.IssueApp.deleteComment(user, project, number:Long, commentId:Long)
 GET            /:user/:project/issue/$number<[0-9]+>/timeline                         controllers.IssueApp.timeline(user, project, number:Long)
 
 
public/javascripts/common/yona.Sha1.js (added)
+++ public/javascripts/common/yona.Sha1.js
@@ -0,0 +1,9 @@
+/*
+ * [js-sha1]{@link https://github.com/emn178/js-sha1}
+ *
+ * @version 0.6.0
+ * @author Chen, Yi-Cyuan [emn178@gmail.com]
+ * @copyright Chen, Yi-Cyuan 2014-2017
+ * @license MIT
+ */
+!function(){"use strict";function t(t){t?(f[0]=f[16]=f[1]=f[2]=f[3]=f[4]=f[5]=f[6]=f[7]=f[8]=f[9]=f[10]=f[11]=f[12]=f[13]=f[14]=f[15]=0,this.blocks=f):this.blocks=[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],this.h0=1732584193,this.h1=4023233417,this.h2=2562383102,this.h3=271733878,this.h4=3285377520,this.block=this.start=this.bytes=this.hBytes=0,this.finalized=this.hashed=!1,this.first=!0}var h="object"==typeof window?window:{},s=!h.JS_SHA1_NO_NODE_JS&&"object"==typeof process&&process.versions&&process.versions.node;s&&(h=global);var i=!h.JS_SHA1_NO_COMMON_JS&&"object"==typeof module&&module.exports,e="function"==typeof define&&define.amd,r="0123456789abcdef".split(""),o=[-2147483648,8388608,32768,128],n=[24,16,8,0],a=["hex","array","digest","arrayBuffer"],f=[],u=function(h){return function(s){return new t(!0).update(s)[h]()}},c=function(){var h=u("hex");s&&(h=p(h)),h.create=function(){return new t},h.update=function(t){return h.create().update(t)};for(var i=0;i<a.length;++i){var e=a[i];h[e]=u(e)}return h},p=function(t){var h=eval("require('crypto')"),s=eval("require('buffer').Buffer"),i=function(i){if("string"==typeof i)return h.createHash("sha1").update(i,"utf8").digest("hex");if(i.constructor===ArrayBuffer)i=new Uint8Array(i);else if(void 0===i.length)return t(i);return h.createHash("sha1").update(new s(i)).digest("hex")};return i};t.prototype.update=function(t){if(!this.finalized){var s="string"!=typeof t;s&&t.constructor===h.ArrayBuffer&&(t=new Uint8Array(t));for(var i,e,r=0,o=t.length||0,a=this.blocks;r<o;){if(this.hashed&&(this.hashed=!1,a[0]=this.block,a[16]=a[1]=a[2]=a[3]=a[4]=a[5]=a[6]=a[7]=a[8]=a[9]=a[10]=a[11]=a[12]=a[13]=a[14]=a[15]=0),s)for(e=this.start;r<o&&e<64;++r)a[e>>2]|=t[r]<<n[3&e++];else for(e=this.start;r<o&&e<64;++r)(i=t.charCodeAt(r))<128?a[e>>2]|=i<<n[3&e++]:i<2048?(a[e>>2]|=(192|i>>6)<<n[3&e++],a[e>>2]|=(128|63&i)<<n[3&e++]):i<55296||i>=57344?(a[e>>2]|=(224|i>>12)<<n[3&e++],a[e>>2]|=(128|i>>6&63)<<n[3&e++],a[e>>2]|=(128|63&i)<<n[3&e++]):(i=65536+((1023&i)<<10|1023&t.charCodeAt(++r)),a[e>>2]|=(240|i>>18)<<n[3&e++],a[e>>2]|=(128|i>>12&63)<<n[3&e++],a[e>>2]|=(128|i>>6&63)<<n[3&e++],a[e>>2]|=(128|63&i)<<n[3&e++]);this.lastByteIndex=e,this.bytes+=e-this.start,e>=64?(this.block=a[16],this.start=e-64,this.hash(),this.hashed=!0):this.start=e}return this.bytes>4294967295&&(this.hBytes+=this.bytes/4294967296<<0,this.bytes=this.bytes%4294967296),this}},t.prototype.finalize=function(){if(!this.finalized){this.finalized=!0;var t=this.blocks,h=this.lastByteIndex;t[16]=this.block,t[h>>2]|=o[3&h],this.block=t[16],h>=56&&(this.hashed||this.hash(),t[0]=this.block,t[16]=t[1]=t[2]=t[3]=t[4]=t[5]=t[6]=t[7]=t[8]=t[9]=t[10]=t[11]=t[12]=t[13]=t[14]=t[15]=0),t[14]=this.hBytes<<3|this.bytes>>>29,t[15]=this.bytes<<3,this.hash()}},t.prototype.hash=function(){var t,h,s=this.h0,i=this.h1,e=this.h2,r=this.h3,o=this.h4,n=this.blocks;for(t=16;t<80;++t)h=n[t-3]^n[t-8]^n[t-14]^n[t-16],n[t]=h<<1|h>>>31;for(t=0;t<20;t+=5)s=(h=(i=(h=(e=(h=(r=(h=(o=(h=s<<5|s>>>27)+(i&e|~i&r)+o+1518500249+n[t]<<0)<<5|o>>>27)+(s&(i=i<<30|i>>>2)|~s&e)+r+1518500249+n[t+1]<<0)<<5|r>>>27)+(o&(s=s<<30|s>>>2)|~o&i)+e+1518500249+n[t+2]<<0)<<5|e>>>27)+(r&(o=o<<30|o>>>2)|~r&s)+i+1518500249+n[t+3]<<0)<<5|i>>>27)+(e&(r=r<<30|r>>>2)|~e&o)+s+1518500249+n[t+4]<<0,e=e<<30|e>>>2;for(;t<40;t+=5)s=(h=(i=(h=(e=(h=(r=(h=(o=(h=s<<5|s>>>27)+(i^e^r)+o+1859775393+n[t]<<0)<<5|o>>>27)+(s^(i=i<<30|i>>>2)^e)+r+1859775393+n[t+1]<<0)<<5|r>>>27)+(o^(s=s<<30|s>>>2)^i)+e+1859775393+n[t+2]<<0)<<5|e>>>27)+(r^(o=o<<30|o>>>2)^s)+i+1859775393+n[t+3]<<0)<<5|i>>>27)+(e^(r=r<<30|r>>>2)^o)+s+1859775393+n[t+4]<<0,e=e<<30|e>>>2;for(;t<60;t+=5)s=(h=(i=(h=(e=(h=(r=(h=(o=(h=s<<5|s>>>27)+(i&e|i&r|e&r)+o-1894007588+n[t]<<0)<<5|o>>>27)+(s&(i=i<<30|i>>>2)|s&e|i&e)+r-1894007588+n[t+1]<<0)<<5|r>>>27)+(o&(s=s<<30|s>>>2)|o&i|s&i)+e-1894007588+n[t+2]<<0)<<5|e>>>27)+(r&(o=o<<30|o>>>2)|r&s|o&s)+i-1894007588+n[t+3]<<0)<<5|i>>>27)+(e&(r=r<<30|r>>>2)|e&o|r&o)+s-1894007588+n[t+4]<<0,e=e<<30|e>>>2;for(;t<80;t+=5)s=(h=(i=(h=(e=(h=(r=(h=(o=(h=s<<5|s>>>27)+(i^e^r)+o-899497514+n[t]<<0)<<5|o>>>27)+(s^(i=i<<30|i>>>2)^e)+r-899497514+n[t+1]<<0)<<5|r>>>27)+(o^(s=s<<30|s>>>2)^i)+e-899497514+n[t+2]<<0)<<5|e>>>27)+(r^(o=o<<30|o>>>2)^s)+i-899497514+n[t+3]<<0)<<5|i>>>27)+(e^(r=r<<30|r>>>2)^o)+s-899497514+n[t+4]<<0,e=e<<30|e>>>2;this.h0=this.h0+s<<0,this.h1=this.h1+i<<0,this.h2=this.h2+e<<0,this.h3=this.h3+r<<0,this.h4=this.h4+o<<0},t.prototype.hex=function(){this.finalize();var t=this.h0,h=this.h1,s=this.h2,i=this.h3,e=this.h4;return r[t>>28&15]+r[t>>24&15]+r[t>>20&15]+r[t>>16&15]+r[t>>12&15]+r[t>>8&15]+r[t>>4&15]+r[15&t]+r[h>>28&15]+r[h>>24&15]+r[h>>20&15]+r[h>>16&15]+r[h>>12&15]+r[h>>8&15]+r[h>>4&15]+r[15&h]+r[s>>28&15]+r[s>>24&15]+r[s>>20&15]+r[s>>16&15]+r[s>>12&15]+r[s>>8&15]+r[s>>4&15]+r[15&s]+r[i>>28&15]+r[i>>24&15]+r[i>>20&15]+r[i>>16&15]+r[i>>12&15]+r[i>>8&15]+r[i>>4&15]+r[15&i]+r[e>>28&15]+r[e>>24&15]+r[e>>20&15]+r[e>>16&15]+r[e>>12&15]+r[e>>8&15]+r[e>>4&15]+r[15&e]},t.prototype.toString=t.prototype.hex,t.prototype.digest=function(){this.finalize();var t=this.h0,h=this.h1,s=this.h2,i=this.h3,e=this.h4;return[t>>24&255,t>>16&255,t>>8&255,255&t,h>>24&255,h>>16&255,h>>8&255,255&h,s>>24&255,s>>16&255,s>>8&255,255&s,i>>24&255,i>>16&255,i>>8&255,255&i,e>>24&255,e>>16&255,e>>8&255,255&e]},t.prototype.array=t.prototype.digest,t.prototype.arrayBuffer=function(){this.finalize();var t=new ArrayBuffer(20),h=new DataView(t);return h.setUint32(0,this.h0),h.setUint32(4,this.h1),h.setUint32(8,this.h2),h.setUint32(12,this.h3),h.setUint32(16,this.h4),t};var y=c();i?module.exports=y:(h.sha1=y,e&&define(function(){return y}))}();(No newline at end of file)
 
public/javascripts/common/yona.Tasklist.js (added)
+++ public/javascripts/common/yona.Tasklist.js
@@ -0,0 +1,145 @@
+/**
+ * Yona, 21st Century Project Hosting SW
+ * <p>
+ * Copyright Yona & Yobi Authors & NAVER Corp. & NAVER LABS Corp.
+ * https://yona.io
+ **/
+
+// At present, using .val() on textarea elements strips carriage return characters
+// https://stackoverflow.com/a/8601601/1450196
+$.valHooks.textarea = {
+    get: function( elem ) {
+        return elem.value.replace( /\r?\n/g, "\r\n" );
+    }
+};
+
+$(function () {
+    var $markdownWrap = $(".markdown-wrap");
+    var inputCheckBox = "input[type='checkbox']";
+
+    checkTasklistDoneCount($markdownWrap);
+    disableCheckboxIfNeeds($markdownWrap);
+
+    $markdownWrap.find(inputCheckBox).each(function () {
+        var $this = $(this);
+        var $parent = $this.closest();
+
+        $parent
+            .click(function () {
+                $this.trigger('click');
+            })
+            .hover(function(){
+                $(this).css({ cursor: 'pointer' });
+            });
+    });
+
+    $markdownWrap.find(inputCheckBox).on("click", function () {
+        var $this = $(this);
+        var $form = $this.closest("div[id]").prev().find("form");
+        var url = $form.attr("action");
+        var originalText = $form.find("textarea").val();
+        checkTask($this);
+
+        var text = $form.find("textarea").val();
+
+        console.log(sha1(originalText), originalText.length);
+
+        $.ajax({
+            method: "PATCH",
+            url: url,
+            contentType: "application/json",
+            data: JSON.stringify({ content: text, sha1: sha1(originalText.trim()) }),
+            beforeSend: function() {
+                NProgress.start();
+            }
+        })
+        .done(function (msg) {
+            NProgress.done();
+            checkTasklistDoneCount($markdownWrap);
+        })
+        .fail(function(jqXHR, textStatus){
+            var response = JSON.parse(jqXHR.responseText);
+            var message = '[' + jqXHR.statusText + '] ' + response.message + '\n\nRefresh the page!';
+            $yobi.showAlert(message);
+            NProgress.done();
+        });
+
+    });
+
+    function checkTask(that, checked) {
+        var $this = that;
+        var isChecked;
+        if(checked === undefined) {
+            isChecked = $this.prop("checked");
+        } else {
+            isChecked = checked;
+        }
+
+        $this.prop('checked', isChecked);
+
+        var $parent = $this.closest(".markdown-wrap");
+        var index = $parent.find(inputCheckBox).index($this);
+        var $form = $this.closest("div[id]").prev().find("form");
+        var $textarea = $form.find("textarea");
+        var text = $textarea.val();
+
+        var counter = 0;
+        // See: https://regex101.com/r/uIC2RM/2
+        text = text.replace(/^([ ]*[-+*] \[[ xX]?])([ ]?.+)/gm, function replacer(match, checkbox, text){
+            var composedText = checkbox + text;
+            if(index === counter) {
+                if(isChecked) {
+                    composedText = checkbox.replace(/\[[ ]?]/, "[x]") + text
+                } else {
+                    composedText = checkbox.replace(/\[[xX]?]/, "[ ]") + text
+                }
+            }
+            counter++;
+            return composedText;
+        });
+
+        $textarea.val(text);
+        $this.next().find(inputCheckBox).each(function () {
+            checkTask($(this), isChecked);
+        });
+    }
+
+    function checkTasklistDoneCount($target) {
+        $target.each(function( index ) {
+            var $this = $(this);
+            var total = 0;
+            var checked = 0;
+            $this.find(inputCheckBox).each(function () {
+                total++;
+                if($(this).prop("checked")) {
+                    checked++;
+                }
+            });
+            var $tasklist = $this.prev();
+            var percentage = checked / total * 100;
+            $tasklist.find(".done-counter").html("(" + checked + "/" + total + ")");
+            $tasklist.find(".bar").width(percentage + "%");
+            $tasklist.find(".task-title").width(percentage + "%");
+            if(total > 0) {
+                $tasklist.addClass("task-show");
+            }
+            if(percentage === 100) {
+                $tasklist.find(".bar").removeClass("red").addClass("green");
+            } else {
+                $tasklist.find(".bar").removeClass("green").addClass("red");
+            }
+        });
+    }
+
+    function disableCheckboxIfNeeds($target){
+        $target.each(function() {
+            var $this = $(this);
+            if($this.data("allowedUpdate") !== true) {
+                $this.find(inputCheckBox).each(function () {
+                    $(this).prop("disabled", true);
+                });
+            }
+        });
+    }
+
+});
public/javascripts/lib/marked.js
--- public/javascripts/lib/marked.js
+++ public/javascripts/lib/marked.js
@@ -377,11 +377,11 @@
         }
 
         // Check for task list items
-        istask = /^\[[ xX]\] /.test(item);
+        istask = /^\[[ xX]?\] /.test(item);
         ischecked = undefined;
         if (istask) {
-          ischecked = item[1] !== ' ';
-          item = item.replace(/^\[[ xX]\] +/, '');
+          ischecked = item[1] !== ' ' && item[1] !== ']';
+          item = item.replace(/^\[[ xX]?\] +/, '');
         }
 
         t = {
@@ -965,13 +965,13 @@
 };
 
 Renderer.prototype.listitem = function(text) {
-  return '<li>' + text + '</li>\n';
+  return '<li>' + text.replace(/<[/]?p[^>]*>/g,"") + '</li>\n';
 };
 
 Renderer.prototype.checkbox = function(checked) {
   return '<input '
     + (checked ? 'checked="" ' : '')
-    + 'disabled="" type="checkbox"'
+    + ' type="checkbox"'
     + (this.options.xhtml ? ' /' : '')
     + '> ';
 }
 
public/javascripts/lib/tasklist/gfm-task-list.css (added)
+++ public/javascripts/lib/tasklist/gfm-task-list.css
@@ -0,0 +1,2 @@
+.contains-task-list{list-style-type:disc;margin-top:10px;padding-left:0}.contains-task-list input[type=checkbox]{margin-right:5px}.contains-task-list .task-list-item{list-style-type:none;padding-right:15px;padding-left:42px;margin-right:-15px;margin-left:-15px;border:1px solid transparent}.contains-task-list .task-list-item .contains-task-list{margin-top:4px}.contains-task-list .task-list-item .task-list-item-checkbox{margin:0 .2em .25em -1.6em;vertical-align:middle}
+/*# sourceMappingURL=gfm-task-list.min.css.map*/(No newline at end of file)
 
public/javascripts/lib/tasklist/gfm-task-list.js (added)
+++ public/javascripts/lib/tasklist/gfm-task-list.js
@@ -0,0 +1,182 @@
+/******/ (function(modules) { // webpackBootstrap
+    /******/ 	// The module cache
+    /******/ 	var installedModules = {};
+    /******/
+    /******/ 	// The require function
+    /******/ 	function __webpack_require__(moduleId) {
+        /******/
+        /******/ 		// Check if module is in cache
+        /******/ 		if(installedModules[moduleId])
+        /******/ 			return installedModules[moduleId].exports;
+        /******/
+        /******/ 		// Create a new module (and put it into the cache)
+        /******/ 		var module = installedModules[moduleId] = {
+            /******/ 			exports: {},
+            /******/ 			id: moduleId,
+            /******/ 			loaded: false
+            /******/ 		};
+        /******/
+        /******/ 		// Execute the module function
+        /******/ 		modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
+        /******/
+        /******/ 		// Flag the module as loaded
+        /******/ 		module.loaded = true;
+        /******/
+        /******/ 		// Return the exports of the module
+        /******/ 		return module.exports;
+        /******/ 	}
+    /******/
+    /******/
+    /******/ 	// expose the modules object (__webpack_modules__)
+    /******/ 	__webpack_require__.m = modules;
+    /******/
+    /******/ 	// expose the module cache
+    /******/ 	__webpack_require__.c = installedModules;
+    /******/
+    /******/ 	// __webpack_public_path__
+    /******/ 	__webpack_require__.p = "";
+    /******/
+    /******/ 	// Load entry module and return exports
+    /******/ 	return __webpack_require__(0);
+    /******/ })
+/************************************************************************/
+/******/ ([
+    /* 0 */
+    /***/ function(module, exports, __webpack_require__) {
+
+        /* WEBPACK VAR INJECTION */(function($) {"use strict";
+            __webpack_require__(2);
+            var GFMTaskList = (function () {
+                function GFMTaskList($element, settings) {
+                    var _this = this;
+                    this.incomplete = "[ ]";
+                    this.complete = "[x]";
+                    this.incompletePattern = RegExp(this.escapePattern(this.incomplete));
+                    this.completePattern = RegExp(this.escapePattern(this.complete));
+                    this.itemPattern = RegExp("^(?:\\s*[-+*]|(?:\\d+\\.))?\\s*(" + this.escapePattern(this.complete) + "|" + this.escapePattern(this.incomplete) + ")(?=\\s)");
+                    this.codeFencesPattern = /^`{3}(?:\s*\w+)?[\S\s].*[\S\s]^`{3}$/mg;
+                    this.itemsInParasPattern = RegExp("^(" + this.escapePattern(this.complete) + "|" + this.escapePattern(this.incomplete) + ").+$", 'g');
+                    this.$element = $element;
+                    this.$markdownContainer = this.$element.find(settings.markdownContainer);
+                    this.$renderedContainer = this.$element.find(settings.renderedContainer);
+                    this.onUpdate = function (event) {
+                        var update = _this.updateTaskList($(event.target));
+                        if (update)
+                            settings.onUpdate(update);
+                    };
+                    this.$renderedContainer.on('change', '.task-list-item-checkbox', this.onUpdate);
+                    this.enable();
+                }
+                GFMTaskList.prototype.destroy = function () {
+                    this.$renderedContainer.off('change', '.task-list-item-checkbox', this.onUpdate);
+                };
+                GFMTaskList.prototype.enable = function () {
+                    this.$renderedContainer
+                        .find('.task-list-item').addClass('enabled')
+                        .find('.task-list-item-checkbox').attr('disabled', null);
+                    this.$element.trigger('tasklist:enabled');
+                };
+                GFMTaskList.prototype.disable = function () {
+                    this.$renderedContainer
+                        .find('.task-list-item').removeClass('enabled')
+                        .find('.task-list-item-checkbox').attr('disabled', 'disabled');
+                    this.$element.trigger('tasklist:disabled');
+                };
+                GFMTaskList.prototype.updateTaskListItem = function (source, itemIndex, checked) {
+                    var clean = source
+                        .replace(/\r/g, '')
+                        .replace(this.codeFencesPattern, '')
+                        .replace(this.itemsInParasPattern, '')
+                        .split("\n");
+                    var index = 0;
+                    var updatedMarkdown = [];
+                    for (var _i = 0, _a = source.split('\n'); _i < _a.length; _i++) {
+                        var line = _a[_i];
+                        if (clean.indexOf(line) >= 0 && this.itemPattern.test(line)) {
+                            index++;
+                            if (index === itemIndex) {
+                                if (checked) {
+                                    line = line.replace(this.incompletePattern, this.complete);
+                                }
+                                else {
+                                    line = line.replace(this.completePattern, this.incomplete);
+                                }
+                            }
+                        }
+                        updatedMarkdown.push(line);
+                    }
+                    return updatedMarkdown.join('\n');
+                };
+                ;
+                GFMTaskList.prototype.updateTaskList = function ($item) {
+                    var index = 1 + this.$renderedContainer.find('.task-list-item-checkbox').index($item);
+                    var checked = $item.prop('checked');
+                    var event = $.Event('tasklist:change');
+                    this.$element.trigger(event, [index, checked]);
+                    if (event.isDefaultPrevented())
+                        return;
+                    var updatedMarkdown = this.updateTaskListItem(this.$markdownContainer.val(), index, checked);
+                    this.$markdownContainer.val(updatedMarkdown);
+                    this.$markdownContainer.trigger('change');
+                    this.$markdownContainer.trigger('tasklist:changed', [index, checked]);
+                    return updatedMarkdown;
+                };
+                ;
+                GFMTaskList.prototype.escapePattern = function (str) {
+                    return str
+                        .replace(/([\[\]])/g, '\\$1')
+                        .replace(/\s/, '\\s')
+                        .replace('x', '[xX]');
+                };
+                return GFMTaskList;
+            }());
+            var jQuery;
+            (function (jQuery) {
+                $.fn.gfmTaskList = function (action) {
+                    var instance = $.data(this, GFMTaskList.name);
+                    if (typeof action === 'string') {
+                        if (!instance) {
+                            throw new Error("Must construct gfmTaskList before calling methods on it.");
+                        }
+                        instance[action]();
+                        return this;
+                    }
+                    var settings;
+                    if (typeof action === 'object') {
+                        settings = action;
+                        var requiredKeys = ['renderedContainer', 'markdownContainer'];
+                        var keys_1 = Object.keys(settings);
+                        requiredKeys.forEach(function (requiredKey) {
+                            if (keys_1.indexOf(requiredKey) === -1) {
+                                throw new Error("Missing key '" + requiredKey + "'");
+                            }
+                        });
+                        action = undefined;
+                    }
+                    else {
+                        throw new Error("Must pass an object to $.fn.gfmTaskList().");
+                    }
+                    if (instance)
+                        instance.destroy();
+                    instance = $.data(this, GFMTaskList.name, new GFMTaskList(this, settings));
+                    return this;
+                };
+            })(jQuery || (jQuery = {}));
+
+            /* WEBPACK VAR INJECTION */}.call(exports, __webpack_require__(1)))
+
+        /***/ },
+    /* 1 */
+    /***/ function(module, exports) {
+
+        module.exports = jQuery;
+
+        /***/ },
+    /* 2 */
+    /***/ function(module, exports) {
+
+        // removed by extract-text-webpack-plugin
+
+        /***/ }
+    /******/ ]);
+//# sourceMappingURL=gfm-task-list.js.map(No newline at end of file)
Add a comment
List