[Notice] Announcing the End of Demo Server [Read me]
Yi EungJun 2012-10-25
markdown: Add File Uploader and improve somewhat.
* Now markdown editor has file uploader.
* The way to specify html elements for markdown changed; Set 'markdown'
  attribute on the elements instead of pass parameters to
  markdown.scala.html.
@22901be04357dcea25db4ce6df59c1edea462894
 
app/controllers/AttachmentApp.java (added)
+++ app/controllers/AttachmentApp.java
@@ -0,0 +1,228 @@
+package controllers;
+
+import static play.libs.Json.toJson;
+
+import java.io.BufferedInputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.net.URLEncoder;
+import java.security.DigestInputStream;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.Formatter;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import javax.mail.internet.MimeUtility;
+
+import models.Attachment;
+import models.enumeration.Operation;
+import models.enumeration.Resource;
+
+import org.apache.tika.Tika;
+import org.codehaus.jackson.JsonNode;
+
+import play.Logger;
+import play.mvc.Controller;
+import play.mvc.Http.MultipartFormData;
+import play.mvc.Http.MultipartFormData.FilePart;
+import play.mvc.Http.Request;
+import play.mvc.Result;
+import utils.AccessControl;
+import utils.RequestUtil;
+
+public class AttachmentApp extends Controller {
+
+    public static Result newFile() throws NoSuchAlgorithmException, IOException {
+        FilePart filePart = request().body().asMultipartFormData().getFile("filePath");
+
+        // Currently, anonymous cannot upload a file.
+        if (UserApp.currentUser() == UserApp.anonymous) {
+            return forbidden();
+        }
+
+        // Keep the file in the user's temporary area.
+        Attachment attach = new Attachment();
+        attach.projectId = 0L;
+        attach.containerType = Resource.USER;
+        attach.containerId = UserApp.currentUser().id;
+        attach.mimeType = new Tika().detect(filePart.getFile());
+        attach.name = filePart.getFilename();
+
+        // Store the file in the filesystem.
+        String hash = AttachmentApp.saveFile(request());
+        attach.hash = hash;
+
+        Attachment sameAttach = Attachment.findBy(attach);
+
+        if (sameAttach == null) {
+            attach.save();
+        } else {
+            attach = sameAttach;
+        }
+
+        // The request has been fulfilled and resulted in a new resource being
+        // created. The newly created resource can be referenced by the URI(s)
+        // returned in the entity of the response, with the most specific URI
+        // for the resource given by a Location header field.
+        //     -- RFC 2616, 10.2.2. 201 Created
+        String url = routes.AttachmentApp.getFile(attach.id, URLEncoder.encode(attach.name, "UTF-8")).url();
+        response().setHeader("Location", url);
+
+        // The response SHOULD include an entity containing a list of resource
+        // characteristics and location(s) from which the user or user agent can
+        // choose the one most appropriate. -- RFC 2616, 10.2.2. 201 Created
+        Map<String, String> file = new HashMap<String, String>();
+        file.put("id", attach.id.toString());
+        file.put("mimeType", attach.mimeType);
+        file.put("name", attach.name);
+        file.put("url", url);
+        JsonNode responseBody = toJson(file);
+
+        // The entity format is specified by the media type given in the
+        // Content-Type header field. -- RFC 2616, 10.2.2. 201 Created
+        response().setHeader("Content-Type", "application/json");
+
+        if (sameAttach == null) {
+            // If an attachment has been created -- it does NOT mean that
+            // a file is created in the filesystem -- return 201 Created.
+            return created(responseBody);
+        } else {
+            // If the attachment already exists, return 200 OK.
+            // Why not 204? -- Because 204 doesn't allow that response has body,
+            // we cannot tell what is same with the file you try to add.
+            return ok(responseBody);
+        }
+    }
+
+    public static Result getFile(Long id, String filename)
+            throws NoSuchAlgorithmException, IOException {
+        Attachment attachment = Attachment.findById(id);
+
+        if (attachment == null) {
+            return notFound();
+        }
+
+        if (!AccessControl.isAllowed(UserApp.currentUser().id, attachment.projectId, attachment.containerType, Operation.READ, attachment.containerId)) {
+            return forbidden();
+        }
+
+        File file = new File("public/uploadFiles/" + attachment.hash);
+
+        // RFC 2231; IE 8 or less, and Safari 5 or less are not supported.
+        filename = attachment.name.replaceAll("[:\\x5c\\/{?]", "_");
+        filename = "filename*=UTF-8''" + URLEncoder.encode(filename, "UTF-8");
+
+        response().setHeader("Content-Length", Long.toString(file.length()));
+        response().setHeader("Content-Type", attachment.mimeType);
+        response().setHeader("Content-Disposition", "attachment; " + filename);
+
+        return ok(file);
+    }
+
+    public static Result deleteFile(Long id, String filename) throws NoSuchAlgorithmException, IOException {
+        // _method must be 'delete'
+        Map<String, String[]> data = request().body().asMultipartFormData().asFormUrlEncoded();
+        if (!RequestUtil.getFirstValueFromQuery(data, "_method").toLowerCase().equals("delete")) {
+            return badRequest("_method must be 'delete'.");
+        }
+
+        // Remove the attachment.
+        Attachment attach = Attachment.findById(id);
+        if (attach == null) {
+            return notFound();
+        }
+        attach.delete();
+
+        // Delete the file matched with the attachment,
+        // if and only if no attachment refer the file.
+        if (Attachment.exists(attach.hash)) {
+            return ok("The attachment is removed successfully, but the origin file still exists because it is referred by somewhere.");
+        }
+
+        boolean result = new File("public/uploadFiles/" + attach.hash).delete();
+
+        if (result) {
+            return ok("The both of attachment and origin file is removed successfully.");
+        } else {
+            return status(202, "The attachment is removed successfully, but the origin file still exists abnormally even if it is referred by nowhere.");
+        }
+    }
+
+    /**
+     * From BoardApp
+     *
+     * @param request
+     * @return
+     * @throws NoSuchAlgorithmException
+     * @throws IOException
+     */
+    static String saveFile(Request request) throws NoSuchAlgorithmException, IOException {
+        MultipartFormData body = request.body().asMultipartFormData();
+        FilePart filePart = body.getFile("filePath");
+
+        if (filePart != null) {
+            MessageDigest algorithm = MessageDigest.getInstance("SHA1");
+            DigestInputStream dis = new DigestInputStream(new BufferedInputStream(new FileInputStream(filePart.getFile())), algorithm);
+            while(dis.read() != -1);
+            Formatter formatter = new Formatter();
+            for (byte b : algorithm.digest()) {
+                formatter.format("%02x", b);
+            }
+            File saveFile = new File("public/uploadFiles/" + formatter.toString());
+            filePart.getFile().renameTo(saveFile);
+            String hash = formatter.toString();
+
+            formatter.close();
+            dis.close();
+
+            return hash;
+        }
+        return null;
+    }
+
+    public static Map<String, String> fileAsMap(Attachment attach) {
+        Map<String, String> file = new HashMap<String, String>();
+
+        file.put("id", attach.id.toString());
+        file.put("mimeType", attach.mimeType);
+        file.put("name", attach.name);
+        file.put("url", routes.AttachmentApp.getFile(attach.id, attach.name).url());
+
+        return file;
+    }
+
+    public static Result getFileList() {
+        Map<String, List<Map<String, String>>> files = new HashMap<String, List<Map<String, String>>>();
+
+        List<Map<String, String>> tempFiles = new ArrayList<Map<String, String>>();
+        for (Attachment attach : Attachment.findTempFiles(UserApp.currentUser().id)) {
+            tempFiles.add(fileAsMap(attach));
+        }
+        files.put("tempFiles", tempFiles);
+
+        Map<String, String[]> query = request().queryString();
+        String containerType = RequestUtil.getFirstValueFromQuery(query, "containerType");
+        String containerId = RequestUtil.getFirstValueFromQuery(query, "containerId");
+
+        if (containerType != null && containerId != null) {
+            List<Map<String, String>> attachments = new ArrayList<Map<String, String>>();
+            for (Attachment attach : Attachment.findByContainer(Resource.valueOf(containerType), Long.parseLong(containerId))) {
+                if (!AccessControl.isAllowed(UserApp.currentUser().id, attach.projectId, attach.containerType, Operation.READ, attach.containerId)) {
+                    return forbidden();
+                }
+                attachments.add(fileAsMap(attach));
+            }
+            files.put("attachments", attachments);
+        }
+
+        JsonNode responseBody = toJson(files);
+
+        response().setHeader("Content-Type", "application/json");
+
+        return ok(responseBody);
+    }
+}(파일 끝에 줄바꿈 문자 없음)
app/controllers/IssueApp.java
--- app/controllers/IssueApp.java
+++ app/controllers/IssueApp.java
@@ -10,8 +10,6 @@
 import models.support.*;
 import play.data.*;
 import play.mvc.*;
-import play.mvc.Http.*;
-import play.mvc.Http.MultipartFormData.*;
 import utils.*;
 import views.html.issue.*;
 
@@ -35,7 +33,7 @@
         Page<Issue> issues = Issue.find(project.name, issueParam.pageNum,
                 StateType.getValue(stateType), issueParam.sortBy,
                 Direction.getValue(issueParam.orderBy), issueParam.filter, issueParam.milestone,
-                issueParam.commentedCheck, issueParam.fileAttachedCheck);
+                issueParam.commentedCheck);
         return ok(issueList.render("title.issueList", issues, issueParam, project));
     }
 
@@ -57,9 +55,9 @@
         return ok(newIssue.render("title.newIssue", new Form<Issue>(Issue.class), project));
     }
 
-    public static Result saveIssue(String userName, String projectName) {
+    public static Result saveIssue(String ownerName, String projectName) throws IOException {
         Form<Issue> issueForm = new Form<Issue>(Issue.class).bindFromRequest();
-        Project project = ProjectApp.getProject(userName, projectName);
+        Project project = ProjectApp.getProject(ownerName, projectName);
         if (issueForm.hasErrors()) {
             return badRequest(newIssue.render(issueForm.errors().toString(), issueForm, project));
         } else {
@@ -69,9 +67,10 @@
             newIssue.project = project;
             newIssue.state = IssueState.ENROLLED;
             newIssue.updateStateType(newIssue);
-            newIssue.filePath = saveFile(request());
-            Issue.create(newIssue);
+            Long issueId = Issue.create(newIssue);
 
+            // Attach all of the files in the current user's temporary storage.
+            Attachment.moveTempFiles(UserApp.currentUser().id, project.id, Resource.ISSUE_POST, issueId);
         }
         return redirect(routes.IssueApp.issues(project.owner, project.name,
                 StateType.ALL.stateType()));
@@ -84,7 +83,7 @@
         return ok(editIssue.render("title.editIssue", editForm, id, project));
     }
 
-    public static Result updateIssue(String userName, String projectName, Long id) {
+    public static Result updateIssue(String userName, String projectName, Long id) throws IOException {
         Form<Issue> issueForm = new Form<Issue>(Issue.class).bindFromRequest();
         Project project = ProjectApp.getProject(userName, projectName);
         if (issueForm.hasErrors()) {
@@ -93,10 +92,12 @@
             Issue issue = issueForm.get();
             issue.id = id;
             issue.date = Issue.findById(id).date;
-            issue.filePath = saveFile(request());
             issue.project = project;
             issue.updateState(issue);
             Issue.edit(issue);
+
+            // Attach the files in the current user's temporary storage.
+            Attachment.moveTempFiles(UserApp.currentUser().id, project.id, Resource.ISSUE_POST, id);
         }
         return redirect(routes.IssueApp.issues(project.owner, project.name, StateType.ALL.name()));
     }
@@ -109,7 +110,7 @@
                 StateType.ALL.stateType()));
     }
 
-    public static Result saveComment(String userName, String projectName, Long issueId) {
+    public static Result saveComment(String userName, String projectName, Long issueId) throws IOException {
         Form<IssueComment> commentForm = new Form<IssueComment>(IssueComment.class)
                 .bindFromRequest();
         Project project = ProjectApp.getProject(userName, projectName);
@@ -121,9 +122,12 @@
             comment.issue = Issue.findById(issueId);
             comment.authorId = UserApp.currentUser().id;
             comment.authorName = UserApp.currentUser().name;
-            comment.filePath = saveFile(request());
-            IssueComment.create(comment);
+            Long commentId = IssueComment.create(comment);
             Issue.updateNumOfComments(issueId);
+
+            // Attach all of the files in the current user's temporary storage.
+            Attachment.moveTempFiles(UserApp.currentUser().id, project.id, Resource.ISSUE_COMMENT, commentId);
+
             return redirect(routes.IssueApp.issue(project.owner, project.name, issueId));
         }
     }
@@ -144,7 +148,7 @@
         Page<Issue> issues = Issue.find(project.name, issueParam.pageNum,
                 StateType.getValue(stateType), issueParam.sortBy,
                 Direction.getValue(issueParam.orderBy), issueParam.filter, issueParam.milestone,
-                issueParam.commentedCheck, issueParam.fileAttachedCheck);
+                issueParam.commentedCheck);
         Issue.excelSave(issues.getList(), project.name + "_" + stateType + "_filter_"
                 + issueParam.filter + "_milestone_" + issueParam.milestone);
         return ok(issueList.render("title.issueList", issues, issueParam, project));
@@ -155,26 +159,7 @@
         return TODO;
     }
 
-    /**
-     * From BoardApp
-     *
-     * @param request
-     * @return
-     */
-    private static String saveFile(Request request) {
-        MultipartFormData body = request.body().asMultipartFormData();
-
-        FilePart filePart = body.getFile("filePath");
-
-        if (filePart != null) {
-            File saveFile = new File("public/uploadFiles/" + filePart.getFilename());
-            filePart.getFile().renameTo(saveFile);
-            return filePart.getFilename();
-        }
-        return null;
-    }
-
     public static Result getIssueDatil(){
     	return TODO;
     }
-}
+}
(파일 끝에 줄바꿈 문자 없음)
 
app/models/Attachment.java (added)
+++ app/models/Attachment.java
@@ -0,0 +1,89 @@
+package models;
+
+import java.util.List;
+
+import javax.persistence.Entity;
+import javax.persistence.EnumType;
+import javax.persistence.Enumerated;
+import javax.persistence.Id;
+
+import models.enumeration.Resource;
+
+import play.data.validation.*;
+
+import play.db.ebean.Model;
+
+@Entity
+public class Attachment extends Model {
+    /**
+     *
+     */
+    private static final long serialVersionUID = 7856282252495067924L;
+    private static Finder<Long, Attachment> find = new Finder<Long, Attachment>(
+        Long.class, Attachment.class);
+    @Id
+    public Long id;
+
+    @Constraints.Required
+    public String name;
+
+    @Constraints.Required
+    public String hash;
+
+    public Long projectId;
+
+    @Enumerated(EnumType.STRING)
+    public Resource containerType;
+
+    public String mimeType;
+
+    public Long containerId;
+
+    public static List<Attachment> findTempFiles(Long userId) {
+        return find.where()
+                .eq("containerType", Resource.USER)
+                .eq("containerId", userId).findList();
+    }
+
+    public static Attachment findBy(Attachment attach) {
+        return find.where()
+                .eq("name", attach.name)
+                .eq("hash", attach.hash)
+                .eq("projectId", attach.projectId)
+                .eq("containerType",attach.containerType)
+                .eq("containerId", attach.containerId).findUnique();
+    }
+
+    public static Attachment findBy(String name, String hash, Long projectId, Resource containerType, Long containerId) {
+        return find.where()
+                .eq("name", name)
+                .eq("hash", hash)
+                .eq("projectId", projectId)
+                .eq("containerType",containerType)
+                .eq("containerId", containerId).findUnique();
+    }
+
+    public static boolean exists(String hash) {
+        return find.where().eq("hash", hash).findRowCount() > 0;
+    }
+
+    public static Attachment findById(Long id) {
+        return find.byId(id);
+    }
+   public static List<Attachment> findByContainer(Resource containerType, Long containerId) {
+       return find.where()
+               .eq("containerType", containerType)
+               .eq("containerId", containerId).findList();
+   }
+
+    public static void moveTempFiles(Long userId, Long projectId, Resource containerType, Long containerId) {
+        // Move the attached files in the temporary area to the issue area.
+        List<Attachment> attachments = Attachment.findTempFiles(userId);
+        for(Attachment attachment : attachments) {
+            attachment.projectId = projectId;
+            attachment.containerType = containerType;
+            attachment.containerId = containerId;
+            attachment.save();
+        }
+    }
+}(파일 끝에 줄바꿈 문자 없음)
app/models/Issue.java
--- app/models/Issue.java
+++ app/models/Issue.java
@@ -38,7 +38,6 @@
  * @param milestone       이슈가 등록된 마일스톤
  * @param importance      이슈 상세정보의 중요도
  * @param diagnosisResult 이슈 상세정보의 진단유형
- * @param filePath        이슈에 첨부된 파일 주소
  * @param osType          이슈 상세정보의 OS 유형
  * @param browserType     이슈 상세정보의 브라우저 유형
  * @param dbmsType        이슈 상세정보의 DBMS 유형
@@ -79,8 +78,6 @@
     public StateType stateType;
     public String issueType;
     public String componentName;
-    // TODO 첨부 파일이 여러개인경우는?
-    public String filePath;
     public String osType;
     public String browserType;
     public String dbmsType;
@@ -355,10 +352,6 @@
      * @param issue
      */
     public static void edit(Issue issue) {
-        Issue previousIssue = findById(issue.id);
-        if (issue.filePath == null) {
-            issue.filePath = previousIssue.filePath;
-        }
         issue.updateStateType(issue);
         issue.update();
     }
@@ -392,7 +385,7 @@
      */
     public static Page<Issue> findIssues(String projectName, StateType state) {
         return find(projectName, FIRST_PAGE_NUMBER, state, DEFAULT_SORTER, Direction.DESC,
-                "", null, false, false);
+                "", null, false);
     }
 
     /**
@@ -402,13 +395,12 @@
      * @param filter
      * @param state
      * @param commentedCheck
-     * @param fileAttachedCheck
      * @return
      */
     public static Page<Issue> findFilteredIssues(String projectName, String filter,
-                                                 StateType state, boolean commentedCheck, boolean fileAttachedCheck) {
+                                                 StateType state, boolean commentedCheck) {
         return find(projectName, FIRST_PAGE_NUMBER, state, DEFAULT_SORTER, Direction.DESC,
-                filter, null, commentedCheck, fileAttachedCheck);
+                filter, null, commentedCheck);
     }
 
     /**
@@ -420,20 +412,7 @@
      */
     public static Page<Issue> findCommentedIssues(String projectName, String filter) {
         return find(projectName, FIRST_PAGE_NUMBER, StateType.ALL, DEFAULT_SORTER,
-                Direction.DESC, filter, null, true, false);
-    }
-
-    /**
-     * 파일이 첨부된 이슈들만 찾아준다.
-     *
-     * @param projectName
-     * @param filter
-     * @return
-     */
-
-    public static Page<Issue> findFileAttachedIssues(String projectName, String filter) {
-        return find(projectName, FIRST_PAGE_NUMBER, StateType.ALL, DEFAULT_SORTER,
-                Direction.DESC, filter, null, false, true);
+                Direction.DESC, filter, null, true);
     }
 
     /**
@@ -445,7 +424,7 @@
      */
     public static Page<Issue> findIssuesByMilestoneId(String projectName, Long milestoneId) {
         return find(projectName, FIRST_PAGE_NUMBER, StateType.ALL, DEFAULT_SORTER,
-                Direction.DESC, "", milestoneId, false, false);
+                Direction.DESC, "", milestoneId, false);
     }
 
     /**
@@ -459,13 +438,11 @@
      * @param order             Sort order(either asc or desc)
      * @param filter            filter applied on the title column
      * @param commentedCheck    filter applied on the commetedCheck column, 댓글이 존재하는 이슈만 필터링
-     * @param fileAttachedCheck filter applied on the fileAttachedCheck column, 파일이 업로드된 이슈만
-     *                          필터링
      * @return 위의 조건에 따라 필터링된 이슈들을 Page로 반환.
      */
     public static Page<Issue> find(String projectName, int pageNumber, StateType state,
                                    String sortBy, Direction order, String filter, Long milestoneId,
-                                   boolean commentedCheck, boolean fileAttachedCheck) {
+                                   boolean commentedCheck) {
         OrderParams orderParams = new OrderParams().add(sortBy, order);
         SearchParams searchParams = new SearchParams().add("project.name", projectName,
                 Matching.EQUALS);
@@ -479,9 +456,7 @@
         if (commentedCheck) {
             searchParams.add("numOfComments", NUMBER_OF_ONE_MORE_COMMENTS, Matching.GE);
         }
-        if (fileAttachedCheck) {
-            searchParams.add("filePath", "", Matching.NOT_EQUALS);
-        }
+
         if (state == null) {
             state = StateType.ALL;
         }
@@ -566,7 +541,7 @@
                 sheet.setColumnView(i, 20);
             }
             for (int i = 1; i < resultList.size() + 1; i++) {
-                Issue issue = (Issue) resultList.get(i - 1);
+                Issue issue = resultList.get(i - 1);
                 int colcnt = 0;
                 sheet.addCell(new Label(colcnt++, i, issue.id.toString(), cf2));
                 sheet.addCell(new Label(colcnt++, i, issue.state.toString(), cf2));
app/models/enumeration/Resource.java
--- app/models/enumeration/Resource.java
+++ app/models/enumeration/Resource.java
@@ -1,27 +1,28 @@
 package models.enumeration;
 
 public enum Resource {
-    ISSUE_POST("issue_post"), 
-    ISSUE_COMMENT("issue_comment"), 
+    ISSUE_POST("issue_post"),
+    ISSUE_COMMENT("issue_comment"),
     ISSUE_ENVIRONMENT("issue_environment"),
     ISSUE_ASSIGNEE("issue_assignee"),
-    ISSUE_STATE("issue_state"), 
-    ISSUE_IMPORTANCE("issue_importance"), 
-    ISSUE_CATEGORY("issue_category"), 
-    ISSUE_MILESTONE("issue_milestone"), 
-    ISSUE_COMPONENT("issue_component"), 
-    ISSUE_DIAGNOSISRESULT("issue_diagnosisResult"), 
-    ISSUE_NOTICE("issue_notice"), 
+    ISSUE_STATE("issue_state"),
+    ISSUE_IMPORTANCE("issue_importance"),
+    ISSUE_CATEGORY("issue_category"),
+    ISSUE_MILESTONE("issue_milestone"),
+    ISSUE_COMPONENT("issue_component"),
+    ISSUE_DIAGNOSISRESULT("issue_diagnosisResult"),
+    ISSUE_NOTICE("issue_notice"),
     BOARD_POST("board_post"),
-    BOARD_COMMENT("board_comment"), 
-    BOARD_CATEGORY("board_category"), 
-    BOARD_NOTICE("board_notice"), 
-    CODE("code"), 
-    MILESTONE("milestone"), 
-    WIKI_PAGE("wiki_page"), 
-    PROJECT_SETTING("project_setting"), 
-    SITE_SETTING("site_setting");
-    
+    BOARD_COMMENT("board_comment"),
+    BOARD_CATEGORY("board_category"),
+    BOARD_NOTICE("board_notice"),
+    CODE("code"),
+    MILESTONE("milestone"),
+    WIKI_PAGE("wiki_page"),
+    PROJECT_SETTING("project_setting"),
+    SITE_SETTING("site_setting"),
+    USER("user");
+
     private String resource;
     
     Resource(String resource) {
app/views/issue/editIssue.scala.html
--- app/views/issue/editIssue.scala.html
+++ app/views/issue/editIssue.scala.html
@@ -2,6 +2,7 @@
 @import helper._
 @import scala.collection.mutable.Map
 @implicitFieldConstructor = @{ FieldConstructor(twitterBootstrapInput.render) } 
+@import models.enumeration.Resource
 
 @isVisible(resource: models.enumeration.Resource)(content: => Html) = @{
   roleCheck(session.get("userId"), project.id, resource, models.enumeration.Operation.EDIT){
@@ -30,11 +31,11 @@
     	   '_showConstraints -> false, 
            '_label-> Messages("post.new.contents"),
            'rows -> 16,
-           'class -> "input-xxlarge textbody")
+           'class -> "input-xxlarge textbody",
+           'resourceType -> Resource.ISSUE_POST,
+           'markdown -> true,
+           'resourceId -> issueId)
 
-    	@inputFile(
-    	   issueForm("filePath"),
-    	   '_label -> Messages("post.new.filePath"))
     </fieldset>
     	</br></br>
     	
@@ -139,7 +140,7 @@
 
     @board.postVaildate()
 
-@views.html.markdown(Map("#body" -> "edit"))
+@views.html.markdown()
 <script type="text/javascript">
   nforge.require('shortcut.submit');
 </script>
app/views/issue/issue.scala.html
--- app/views/issue/issue.scala.html
+++ app/views/issue/issue.scala.html
@@ -1,6 +1,8 @@
 @(title:String, issue:Issue, issueForm:Form[Issue], commentForm:Form[IssueComment],project:Project) 
 @import helper._ 
 @import scala.collection.mutable.Map
+@import models.enumeration.Resource
+
 @implicitFieldConstructor = @{ FieldConstructor(twitterBootstrapInput.render) } 
 
 @isVisible(resource: models.enumeration.Resource)(content: => Html) = @{
@@ -43,13 +45,7 @@
 		</div>
 		<div class="span11">
 			<div>
-				<div id="body">@issue.body</div>
-				@if(issue.filePath != null){
-				<p>
-					<a href='@routes.Assets.at("uploadFiles/" + issue.filePath)'><i
-						class="icon-download"></i>@issue.filePath</a>
-				</p>
-				}
+				<div id="body" markdown resourceType=@Resource.ISSUE_POST resourceId="@issue.id">@issue.body</div>
 			</div>
 		</div>
 		<a class="btn pull-right" href=""><i class="icon-ok"></i>@Messages("button.autoNotification")</a>
@@ -167,13 +163,7 @@
             </div>
         </div>
 		}
-		<pre>@comment.contents</pre>
-		@if(comment.filePath != null){
-		<p>
-			<a href='@routes.Assets.at("uploadFiles/" + comment.filePath)'><i
-				class="icon-download"> </i> @comment.filePath</a>
-		</p>
-		  }
+		<div markdown resourceType=@Resource.ISSUE_COMMENT resourceId=@comment.id>@comment.contents</div>
 	</div>
 </div>
 }
@@ -184,14 +174,12 @@
 	   'enctype -> "multipart/form-data",
 	   'class -> "form-horizontal"){
 		<div class="span12">
-		      @textarea(commentForm("contents"),
-		                    '_label-> Messages("post.new.contents")
-		                           )
+        @textarea(commentForm("contents"),
+            '_label-> Messages("post.new.contents"),
+            'rows -> 5,
+            'markdown -> true,
+            'class -> "input-xxlarge textbody")
 		</div>
-		<div class="span6">
-		      @inputFile(commentForm("filePath"),
-	                       '_label -> Messages("post.new.filePath"))
-	    </div>
 		<div class="span6">
 			<input class="btn pull-right" type="submit" value=@Messages( "button.comment.new") />
 		</div>
@@ -214,5 +202,5 @@
 	</div>
 </div>
 
-@views.html.markdown(Map("#body" -> "render"))
+@views.html.markdown()
 }
app/views/issue/issueList.scala.html
--- app/views/issue/issueList.scala.html
+++ app/views/issue/issueList.scala.html
@@ -58,7 +58,6 @@
 				<input type="hidden" name="stateType" value="@param.stateType">
 		        <div class="control-group">
 		            <label class="control-label" for="inlineCheckboxes"></label>
-		            
 		            <div class="controls inline">
 		            	<fieldset>
 <!--FIXME view 전문가님이 아래의 if-else문 안의 중복된 코드를 업그레이드 해주시길 바랍니다. -->
@@ -73,7 +72,6 @@
 		        </div>
 		    </form>
 		</div>
-
 			<table class="table">
 				<thead>
 					<tr>
@@ -85,12 +83,12 @@
 					</tr>
 				</thead>
 				<tbody>
-				
 					@for(issue <- currentPage.getList()){
 						<tr>
 							<td>@issue.id</td>
 							<td>@Messages(issue.state.state)</td>
-							<td><a href="@routes.IssueApp.issue(project.owner, project.name, issue.id)">@issue.title @if(issue.comments.size > 0){[@issue.comments.size]} @if(issue.filePath != null){<i class="icon-file"></i>}</a></td>
+							<td><a href="@routes.IssueApp.issue(project.owner, project.name, issue.id)">@issue.title</a></td>
+							<td><a href="@routes.IssueApp.issue(project.owner, project.name, issue.id)"></a></td>
 							<td>
 								@if(issue.assigneeId == null){
 								<em>@Messages("issue.noAssignee")</em>						
app/views/issue/newIssue.scala.html
--- app/views/issue/newIssue.scala.html
+++ app/views/issue/newIssue.scala.html
@@ -21,29 +21,16 @@
     	@inputText(
     		issueForm("title"), 
     		'_showConstraints -> false, 
-    		'_label-> Messages("post.new.title"))
+            '_label-> Messages("post.new.title"),
+            'class -> "input-xxlarge")
     	@textarea(
     		issueForm("body"), 
     		'_showConstraints -> false,
-    		'_label-> Messages("post.new.contents"))
+            '_label-> Messages("post.new.contents"),
+            'rows -> 16,
+            'class -> "input-xxlarge textbody",
+            'markdown -> true)
     	</fieldset>
-
-    	<i class = "icon-file"></i>@Messages("post.new.fileAttach") <a id="fileUpload" data-toggle="modal" href="#askFilePath" class="btn">@Messages("button.selectFile")</a>
-	       <div class="modal hide" id="askFilePath">
-		    	<div class="modal-header">
-			        <button type="button" class="close" data-dismiss="modal">@Messages("button.popup.exit")</button>
-			            <h3>@Messages("post.popup.fileAttach.title")</h3>
-			    </div>
-			    <div class="modal-body">
-			        <p> @Messages("post.popup.fileAttach.contents")</p>
-			        @inputFile(issueForm("filePath"))
-			    </div>
-			    <div class="modal-footer">
-			        <a href="#" class="btn" data-dismiss="modal">@Messages("button.cancel")</a>
-			        <a href="#" class="btn btn-primary" data-dismiss="modal">@Messages("button.confirm")</a>
-			    </div>
-	       </div>
-		</br></br>
 
 		<fieldset>
             <div class="well">
@@ -145,7 +132,7 @@
     </div>
  }
 
-@views.html.markdown(Map("#body" -> "edit"))
+@views.html.markdown()
 <script type="text/javascript">
   nforge.require('shortcut.submit');
 </script>
app/views/markdown.scala.html
--- app/views/markdown.scala.html
+++ app/views/markdown.scala.html
@@ -1,4 +1,3 @@
-@(targets: Map[String, String])
 @import utils.TemplateHelper._
 <style>
 @@IMPORT url(@getCSSLink("hljsstyles/googlecode"));
@@ -6,9 +5,7 @@
 <script src="@getJSLink("showdown")" type="text/javascript"></script>
 <script src="@getJSLink("hljs")" type="text/javascript"></script>
 <script src="@getJSLink("languages/allinone")" type="text/javascript"></script>
+<script src="@getJSLink("jquery.form")" type="text/javascript"></script>
 <script type="text/javascript">
-  @applyMarkdown(key: String) = {
-    nforge.require("markdown.@targets.get(key)", "@key");
-  }
-  @targets.keys.map(applyMarkdown)
+  nforge.require("markdown.enable", $("[markdown]"), "@routes.AttachmentApp.newFile");
 </script>
app/views/search/issueContentsSearch.scala.html
--- app/views/search/issueContentsSearch.scala.html
+++ app/views/search/issueContentsSearch.scala.html
@@ -5,8 +5,8 @@
   <tr>
       <td>@issue.id</td>
       <td>@Messages(issue.state.state)</td>
-      <td><a href="@routes.IssueApp.issue(project.owner, project.name, issue.id)">@issue.title @if(issue.comments.size >
-        0){[@issue.comments.size]} @if(issue.filePath != null){<i class="icon-file"></i>}</a></td>
+      <td><a href="@routes.IssueApp.issue(project.owner, project.name, issue.id)">
+              @issue.title @if(issue.comments.size > 0){[@issue.comments.size]}</a></td>
       <td>
         @if(issue.assigneeId == null){
         <em>@Messages("issue.noAssignee")</em>
conf/initial-data.yml
--- conf/initial-data.yml
+++ conf/initial-data.yml
@@ -103,7 +103,6 @@
         milestoneId:    1
         project:        !!models.Project
                             id: 1
-        filePath:       FakeFilePath/To/Check/FileIcon
     - !!models.Issue
         authorId:     3
         assigneeId:     2     
conf/routes
--- conf/routes
+++ conf/routes
@@ -75,6 +75,12 @@
 POST    /:userName/:projectName/issues/:id/edit         controllers.IssueApp.updateIssue(userName:String, projectName:String, id:Long)
 GET		/issuedetail									controllers.IssueApp.getIssueDatil()
 
+# Attachments
+GET     /files 				    controllers.AttachmentApp.getFileList()
+POST    /files 				    controllers.AttachmentApp.newFile()
+GET     /files/:id/:filename	controllers.AttachmentApp.getFile(id: Long, filename: String)
+POST    /files/:id/:filename	controllers.AttachmentApp.deleteFile(id: Long, filename: String)
+
 # Git
 GET     /:ownerName/:projectName/info/refs                                        controllers.GitApp.advertise(ownerName:String, projectName:String)
 POST    /:ownerName/:projectName/$service<git-upload-pack|git-receive-pack>       controllers.GitApp.serviceRpc(ownerName:String, projectName:String, service:String)
project/Build.scala
--- project/Build.scala
+++ project/Build.scala
@@ -28,7 +28,8 @@
       "commons-codec" % "commons-codec" % "1.2",
       // apache-mails
       "org.apache.commons" % "commons-email" % "1.2",
-      "commons-lang" % "commons-lang" % "2.6"
+      "commons-lang" % "commons-lang" % "2.6",
+      "org.apache.tika" % "tika-core" % "1.2"
     )
 
     val main = PlayProject(appName, appVersion, appDependencies, mainLang = JAVA).settings(
 
public/javascripts/jquery.form.js (added)
+++ public/javascripts/jquery.form.js
@@ -0,0 +1,1076 @@
+/*!
+ * jQuery Form Plugin
+ * version: 3.09 (16-APR-2012)
+ * @requires jQuery v1.3.2 or later
+ *
+ * Examples and documentation at: http://malsup.com/jquery/form/
+ * Project repository: https://github.com/malsup/form
+ * Dual licensed under the MIT and GPL licenses:
+ *    http://malsup.github.com/mit-license.txt
+ *    http://malsup.github.com/gpl-license-v2.txt
+ */
+/*global ActiveXObject alert */
+;(function($) {
+"use strict";
+
+/*
+    Usage Note:
+    -----------
+    Do not use both ajaxSubmit and ajaxForm on the same form.  These
+    functions are mutually exclusive.  Use ajaxSubmit if you want
+    to bind your own submit handler to the form.  For example,
+
+    $(document).ready(function() {
+        $('#myForm').on('submit', function(e) {
+            e.preventDefault(); // <-- important
+            $(this).ajaxSubmit({
+                target: '#output'
+            });
+        });
+    });
+
+    Use ajaxForm when you want the plugin to manage all the event binding
+    for you.  For example,
+
+    $(document).ready(function() {
+        $('#myForm').ajaxForm({
+            target: '#output'
+        });
+    });
+    
+    You can also use ajaxForm with delegation (requires jQuery v1.7+), so the
+    form does not have to exist when you invoke ajaxForm:
+
+    $('#myForm').ajaxForm({
+        delegation: true,
+        target: '#output'
+    });
+    
+    When using ajaxForm, the ajaxSubmit function will be invoked for you
+    at the appropriate time.
+*/
+
+/**
+ * Feature detection
+ */
+var feature = {};
+feature.fileapi = $("<input type='file'/>").get(0).files !== undefined;
+feature.formdata = window.FormData !== undefined;
+
+/**
+ * ajaxSubmit() provides a mechanism for immediately submitting
+ * an HTML form using AJAX.
+ */
+$.fn.ajaxSubmit = function(options) {
+    /*jshint scripturl:true */
+
+    // fast fail if nothing selected (http://dev.jquery.com/ticket/2752)
+    if (!this.length) {
+        log('ajaxSubmit: skipping submit process - no element selected');
+        return this;
+    }
+    
+    var method, action, url, $form = this;
+
+    if (typeof options == 'function') {
+        options = { success: options };
+    }
+
+    method = this.attr('method');
+    action = this.attr('action');
+    url = (typeof action === 'string') ? $.trim(action) : '';
+    url = url || window.location.href || '';
+    if (url) {
+        // clean url (don't include hash vaue)
+        url = (url.match(/^([^#]+)/)||[])[1];
+    }
+
+    options = $.extend(true, {
+        url:  url,
+        success: $.ajaxSettings.success,
+        type: method || 'GET',
+        iframeSrc: /^https/i.test(window.location.href || '') ? 'javascript:false' : 'about:blank'
+    }, options);
+
+    // hook for manipulating the form data before it is extracted;
+    // convenient for use with rich editors like tinyMCE or FCKEditor
+    var veto = {};
+    this.trigger('form-pre-serialize', [this, options, veto]);
+    if (veto.veto) {
+        log('ajaxSubmit: submit vetoed via form-pre-serialize trigger');
+        return this;
+    }
+
+    // provide opportunity to alter form data before it is serialized
+    if (options.beforeSerialize && options.beforeSerialize(this, options) === false) {
+        log('ajaxSubmit: submit aborted via beforeSerialize callback');
+        return this;
+    }
+
+    var traditional = options.traditional;
+    if ( traditional === undefined ) {
+        traditional = $.ajaxSettings.traditional;
+    }
+    
+    var elements = [];
+    var qx, a = this.formToArray(options.semantic, elements);
+    if (options.data) {
+        options.extraData = options.data;
+        qx = $.param(options.data, traditional);
+    }
+
+    // give pre-submit callback an opportunity to abort the submit
+    if (options.beforeSubmit && options.beforeSubmit(a, this, options) === false) {
+        log('ajaxSubmit: submit aborted via beforeSubmit callback');
+        return this;
+    }
+
+    // fire vetoable 'validate' event
+    this.trigger('form-submit-validate', [a, this, options, veto]);
+    if (veto.veto) {
+        log('ajaxSubmit: submit vetoed via form-submit-validate trigger');
+        return this;
+    }
+
+    var q = $.param(a, traditional);
+    if (qx) {
+        q = ( q ? (q + '&' + qx) : qx );
+    }    
+    if (options.type.toUpperCase() == 'GET') {
+        options.url += (options.url.indexOf('?') >= 0 ? '&' : '?') + q;
+        options.data = null;  // data is null for 'get'
+    }
+    else {
+        options.data = q; // data is the query string for 'post'
+    }
+
+    var callbacks = [];
+    if (options.resetForm) {
+        callbacks.push(function() { $form.resetForm(); });
+    }
+    if (options.clearForm) {
+        callbacks.push(function() { $form.clearForm(options.includeHidden); });
+    }
+
+    // perform a load on the target only if dataType is not provided
+    if (!options.dataType && options.target) {
+        var oldSuccess = options.success || function(){};
+        callbacks.push(function(data) {
+            var fn = options.replaceTarget ? 'replaceWith' : 'html';
+            $(options.target)[fn](data).each(oldSuccess, arguments);
+        });
+    }
+    else if (options.success) {
+        callbacks.push(options.success);
+    }
+
+    options.success = function(data, status, xhr) { // jQuery 1.4+ passes xhr as 3rd arg
+        var context = options.context || options;    // jQuery 1.4+ supports scope context 
+        for (var i=0, max=callbacks.length; i < max; i++) {
+            callbacks[i].apply(context, [data, status, xhr || $form, $form]);
+        }
+    };
+
+    // are there files to upload?
+    var fileInputs = $('input:file:enabled[value]', this); // [value] (issue #113)
+    var hasFileInputs = fileInputs.length > 0;
+    var mp = 'multipart/form-data';
+    var multipart = ($form.attr('enctype') == mp || $form.attr('encoding') == mp);
+
+    var fileAPI = feature.fileapi && feature.formdata;
+    log("fileAPI :" + fileAPI);
+    var shouldUseFrame = (hasFileInputs || multipart) && !fileAPI;
+
+    // options.iframe allows user to force iframe mode
+    // 06-NOV-09: now defaulting to iframe mode if file input is detected
+    if (options.iframe !== false && (options.iframe || shouldUseFrame)) {
+        // hack to fix Safari hang (thanks to Tim Molendijk for this)
+        // see:  http://groups.google.com/group/jquery-dev/browse_thread/thread/36395b7ab510dd5d
+        if (options.closeKeepAlive) {
+            $.get(options.closeKeepAlive, function() {
+                fileUploadIframe(a);
+            });
+        }
+          else {
+            fileUploadIframe(a);
+          }
+    }
+    else if ((hasFileInputs || multipart) && fileAPI) {
+        fileUploadXhr(a);
+    }
+    else {
+        $.ajax(options);
+    }
+
+    // clear element array
+    for (var k=0; k < elements.length; k++)
+        elements[k] = null;
+
+    // fire 'notify' event
+    this.trigger('form-submit-notify', [this, options]);
+    return this;
+
+     // XMLHttpRequest Level 2 file uploads (big hat tip to francois2metz)
+    function fileUploadXhr(a) {
+        var formdata = new FormData();
+
+        for (var i=0; i < a.length; i++) {
+            formdata.append(a[i].name, a[i].value);
+        }
+
+        if (options.extraData) {
+            for (var p in options.extraData)
+                if (options.extraData.hasOwnProperty(p))
+                    formdata.append(p, options.extraData[p]);
+        }
+
+        options.data = null;
+
+        var s = $.extend(true, {}, $.ajaxSettings, options, {
+            contentType: false,
+            processData: false,
+            cache: false,
+            type: 'POST'
+        });
+        
+        if (options.uploadProgress) {
+            // workaround because jqXHR does not expose upload property
+            s.xhr = function() {
+                var xhr = jQuery.ajaxSettings.xhr();
+                if (xhr.upload) {
+                    xhr.upload.onprogress = function(event) {
+                        var percent = 0;
+                        var position = event.loaded || event.position; /*event.position is deprecated*/
+                        var total = event.total;
+                        if (event.lengthComputable) {
+                            percent = Math.ceil(position / total * 100);
+                        }
+                        options.uploadProgress(event, position, total, percent);
+                    };
+                }
+                return xhr;
+            };
+        }
+
+        s.data = null;
+          var beforeSend = s.beforeSend;
+          s.beforeSend = function(xhr, o) {
+              o.data = formdata;
+            if(beforeSend)
+                beforeSend.call(o, xhr, options);
+        };
+        $.ajax(s);
+    }
+
+    // private function for handling file uploads (hat tip to YAHOO!)
+    function fileUploadIframe(a) {
+        var form = $form[0], el, i, s, g, id, $io, io, xhr, sub, n, timedOut, timeoutHandle;
+        var useProp = !!$.fn.prop;
+
+        if ($(':input[name=submit],:input[id=submit]', form).length) {
+            // if there is an input with a name or id of 'submit' then we won't be
+            // able to invoke the submit fn on the form (at least not x-browser)
+            alert('Error: Form elements must not have name or id of "submit".');
+            return;
+        }
+        
+        if (a) {
+            // ensure that every serialized input is still enabled
+            for (i=0; i < elements.length; i++) {
+                el = $(elements[i]);
+                if ( useProp )
+                    el.prop('disabled', false);
+                else
+                    el.removeAttr('disabled');
+            }
+        }
+
+        s = $.extend(true, {}, $.ajaxSettings, options);
+        s.context = s.context || s;
+        id = 'jqFormIO' + (new Date().getTime());
+        if (s.iframeTarget) {
+            $io = $(s.iframeTarget);
+            n = $io.attr('name');
+            if (!n)
+                 $io.attr('name', id);
+            else
+                id = n;
+        }
+        else {
+            $io = $('<iframe name="' + id + '" src="'+ s.iframeSrc +'" />');
+            $io.css({ position: 'absolute', top: '-1000px', left: '-1000px' });
+        }
+        io = $io[0];
+
+
+        xhr = { // mock object
+            aborted: 0,
+            responseText: null,
+            responseXML: null,
+            status: 0,
+            statusText: 'n/a',
+            getAllResponseHeaders: function() {},
+            getResponseHeader: function() {},
+            setRequestHeader: function() {},
+            abort: function(status) {
+                var e = (status === 'timeout' ? 'timeout' : 'aborted');
+                log('aborting upload... ' + e);
+                this.aborted = 1;
+                $io.attr('src', s.iframeSrc); // abort op in progress
+                xhr.error = e;
+                if (s.error)
+                    s.error.call(s.context, xhr, e, status);
+                if (g)
+                    $.event.trigger("ajaxError", [xhr, s, e]);
+                if (s.complete)
+                    s.complete.call(s.context, xhr, e);
+            }
+        };
+
+        g = s.global;
+        // trigger ajax global events so that activity/block indicators work like normal
+        if (g && 0 === $.active++) {
+            $.event.trigger("ajaxStart");
+        }
+        if (g) {
+            $.event.trigger("ajaxSend", [xhr, s]);
+        }
+
+        if (s.beforeSend && s.beforeSend.call(s.context, xhr, s) === false) {
+            if (s.global) {
+                $.active--;
+            }
+            return;
+        }
+        if (xhr.aborted) {
+            return;
+        }
+
+        // add submitting element to data if we know it
+        sub = form.clk;
+        if (sub) {
+            n = sub.name;
+            if (n && !sub.disabled) {
+                s.extraData = s.extraData || {};
+                s.extraData[n] = sub.value;
+                if (sub.type == "image") {
+                    s.extraData[n+'.x'] = form.clk_x;
+                    s.extraData[n+'.y'] = form.clk_y;
+                }
+            }
+        }
+        
+        var CLIENT_TIMEOUT_ABORT = 1;
+        var SERVER_ABORT = 2;
+
+        function getDoc(frame) {
+            var doc = frame.contentWindow ? frame.contentWindow.document : frame.contentDocument ? frame.contentDocument : frame.document;
+            return doc;
+        }
+        
+        // Rails CSRF hack (thanks to Yvan Barthelemy)
+        var csrf_token = $('meta[name=csrf-token]').attr('content');
+        var csrf_param = $('meta[name=csrf-param]').attr('content');
+        if (csrf_param && csrf_token) {
+            s.extraData = s.extraData || {};
+            s.extraData[csrf_param] = csrf_token;
+        }
+
+        // take a breath so that pending repaints get some cpu time before the upload starts
+        function doSubmit() {
+            // make sure form attrs are set
+            var t = $form.attr('target'), a = $form.attr('action');
+
+            // update form attrs in IE friendly way
+            form.setAttribute('target',id);
+            if (!method) {
+                form.setAttribute('method', 'POST');
+            }
+            if (a != s.url) {
+                form.setAttribute('action', s.url);
+            }
+
+            // ie borks in some cases when setting encoding
+            if (! s.skipEncodingOverride && (!method || /post/i.test(method))) {
+                $form.attr({
+                    encoding: 'multipart/form-data',
+                    enctype:  'multipart/form-data'
+                });
+            }
+
+            // support timout
+            if (s.timeout) {
+                timeoutHandle = setTimeout(function() { timedOut = true; cb(CLIENT_TIMEOUT_ABORT); }, s.timeout);
+            }
+            
+            // look for server aborts
+            function checkState() {
+                try {
+                    var state = getDoc(io).readyState;
+                    log('state = ' + state);
+                    if (state && state.toLowerCase() == 'uninitialized')
+                        setTimeout(checkState,50);
+                }
+                catch(e) {
+                    log('Server abort: ' , e, ' (', e.name, ')');
+                    cb(SERVER_ABORT);
+                    if (timeoutHandle)
+                        clearTimeout(timeoutHandle);
+                    timeoutHandle = undefined;
+                }
+            }
+
+            // add "extra" data to form if provided in options
+            var extraInputs = [];
+            try {
+                if (s.extraData) {
+                    for (var n in s.extraData) {
+                        if (s.extraData.hasOwnProperty(n)) {
+                            extraInputs.push(
+                                $('<input type="hidden" name="'+n+'">').attr('value',s.extraData[n])
+                                    .appendTo(form)[0]);
+                        }
+                    }
+                }
+
+                if (!s.iframeTarget) {
+                    // add iframe to doc and submit the form
+                    $io.appendTo('body');
+                    if (io.attachEvent)
+                        io.attachEvent('onload', cb);
+                    else
+                        io.addEventListener('load', cb, false);
+                }
+                setTimeout(checkState,15);
+                form.submit();
+            }
+            finally {
+                // reset attrs and remove "extra" input elements
+                form.setAttribute('action',a);
+                if(t) {
+                    form.setAttribute('target', t);
+                } else {
+                    $form.removeAttr('target');
+                }
+                $(extraInputs).remove();
+            }
+        }
+
+        if (s.forceSync) {
+            doSubmit();
+        }
+        else {
+            setTimeout(doSubmit, 10); // this lets dom updates render
+        }
+
+        var data, doc, domCheckCount = 50, callbackProcessed;
+
+        function cb(e) {
+            if (xhr.aborted || callbackProcessed) {
+                return;
+            }
+            try {
+                doc = getDoc(io);
+            }
+            catch(ex) {
+                log('cannot access response document: ', ex);
+                e = SERVER_ABORT;
+            }
+            if (e === CLIENT_TIMEOUT_ABORT && xhr) {
+                xhr.abort('timeout');
+                return;
+            }
+            else if (e == SERVER_ABORT && xhr) {
+                xhr.abort('server abort');
+                return;
+            }
+
+            if (!doc || doc.location.href == s.iframeSrc) {
+                // response not received yet
+                if (!timedOut)
+                    return;
+            }
+            if (io.detachEvent)
+                io.detachEvent('onload', cb);
+            else    
+                io.removeEventListener('load', cb, false);
+
+            var status = 'success', errMsg;
+            try {
+                if (timedOut) {
+                    throw 'timeout';
+                }
+
+                var isXml = s.dataType == 'xml' || doc.XMLDocument || $.isXMLDoc(doc);
+                log('isXml='+isXml);
+                if (!isXml && window.opera && (doc.body === null || !doc.body.innerHTML)) {
+                    if (--domCheckCount) {
+                        // in some browsers (Opera) the iframe DOM is not always traversable when
+                        // the onload callback fires, so we loop a bit to accommodate
+                        log('requeing onLoad callback, DOM not available');
+                        setTimeout(cb, 250);
+                        return;
+                    }
+                    // let this fall through because server response could be an empty document
+                    //log('Could not access iframe DOM after mutiple tries.');
+                    //throw 'DOMException: not available';
+                }
+
+                //log('response detected');
+                var docRoot = doc.body ? doc.body : doc.documentElement;
+                xhr.responseText = docRoot ? docRoot.innerHTML : null;
+                xhr.responseXML = doc.XMLDocument ? doc.XMLDocument : doc;
+                if (isXml)
+                    s.dataType = 'xml';
+                xhr.getResponseHeader = function(header){
+                    var headers = {'content-type': s.dataType};
+                    return headers[header];
+                };
+                // support for XHR 'status' & 'statusText' emulation :
+                if (docRoot) {
+                    xhr.status = Number( docRoot.getAttribute('status') ) || xhr.status;
+                    xhr.statusText = docRoot.getAttribute('statusText') || xhr.statusText;
+                }
+
+                var dt = (s.dataType || '').toLowerCase();
+                var scr = /(json|script|text)/.test(dt);
+                if (scr || s.textarea) {
+                    // see if user embedded response in textarea
+                    var ta = doc.getElementsByTagName('textarea')[0];
+                    if (ta) {
+                        xhr.responseText = ta.value;
+                        // support for XHR 'status' & 'statusText' emulation :
+                        xhr.status = Number( ta.getAttribute('status') ) || xhr.status;
+                        xhr.statusText = ta.getAttribute('statusText') || xhr.statusText;
+                    }
+                    else if (scr) {
+                        // account for browsers injecting pre around json response
+                        var pre = doc.getElementsByTagName('pre')[0];
+                        var b = doc.getElementsByTagName('body')[0];
+                        if (pre) {
+                            xhr.responseText = pre.textContent ? pre.textContent : pre.innerText;
+                        }
+                        else if (b) {
+                            xhr.responseText = b.textContent ? b.textContent : b.innerText;
+                        }
+                    }
+                }
+                else if (dt == 'xml' && !xhr.responseXML && xhr.responseText) {
+                    xhr.responseXML = toXml(xhr.responseText);
+                }
+
+                try {
+                    data = httpData(xhr, dt, s);
+                }
+                catch (e) {
+                    status = 'parsererror';
+                    xhr.error = errMsg = (e || status);
+                }
+            }
+            catch (e) {
+                log('error caught: ',e);
+                status = 'error';
+                xhr.error = errMsg = (e || status);
+            }
+
+            if (xhr.aborted) {
+                log('upload aborted');
+                status = null;
+            }
+
+            if (xhr.status) { // we've set xhr.status
+                status = (xhr.status >= 200 && xhr.status < 300 || xhr.status === 304) ? 'success' : 'error';
+            }
+
+            // ordering of these callbacks/triggers is odd, but that's how $.ajax does it
+            if (status === 'success') {
+                if (s.success)
+                    s.success.call(s.context, data, 'success', xhr);
+                if (g)
+                    $.event.trigger("ajaxSuccess", [xhr, s]);
+            }
+            else if (status) {
+                if (errMsg === undefined)
+                    errMsg = xhr.statusText;
+                if (s.error)
+                    s.error.call(s.context, xhr, status, errMsg);
+                if (g)
+                    $.event.trigger("ajaxError", [xhr, s, errMsg]);
+            }
+
+            if (g)
+                $.event.trigger("ajaxComplete", [xhr, s]);
+
+            if (g && ! --$.active) {
+                $.event.trigger("ajaxStop");
+            }
+
+            if (s.complete)
+                s.complete.call(s.context, xhr, status);
+
+            callbackProcessed = true;
+            if (s.timeout)
+                clearTimeout(timeoutHandle);
+
+            // clean up
+            setTimeout(function() {
+                if (!s.iframeTarget)
+                    $io.remove();
+                xhr.responseXML = null;
+            }, 100);
+        }
+
+        var toXml = $.parseXML || function(s, doc) { // use parseXML if available (jQuery 1.5+)
+            if (window.ActiveXObject) {
+                doc = new ActiveXObject('Microsoft.XMLDOM');
+                doc.async = 'false';
+                doc.loadXML(s);
+            }
+            else {
+                doc = (new DOMParser()).parseFromString(s, 'text/xml');
+            }
+            return (doc && doc.documentElement && doc.documentElement.nodeName != 'parsererror') ? doc : null;
+        };
+        var parseJSON = $.parseJSON || function(s) {
+            /*jslint evil:true */
+            return window['eval']('(' + s + ')');
+        };
+
+        var httpData = function( xhr, type, s ) { // mostly lifted from jq1.4.4
+
+            var ct = xhr.getResponseHeader('content-type') || '',
+                xml = type === 'xml' || !type && ct.indexOf('xml') >= 0,
+                data = xml ? xhr.responseXML : xhr.responseText;
+
+            if (xml && data.documentElement.nodeName === 'parsererror') {
+                if ($.error)
+                    $.error('parsererror');
+            }
+            if (s && s.dataFilter) {
+                data = s.dataFilter(data, type);
+            }
+            if (typeof data === 'string') {
+                if (type === 'json' || !type && ct.indexOf('json') >= 0) {
+                    data = parseJSON(data);
+                } else if (type === "script" || !type && ct.indexOf("javascript") >= 0) {
+                    $.globalEval(data);
+                }
+            }
+            return data;
+        };
+    }
+};
+
+/**
+ * ajaxForm() provides a mechanism for fully automating form submission.
+ *
+ * The advantages of using this method instead of ajaxSubmit() are:
+ *
+ * 1: This method will include coordinates for <input type="image" /> elements (if the element
+ *    is used to submit the form).
+ * 2. This method will include the submit element's name/value data (for the element that was
+ *    used to submit the form).
+ * 3. This method binds the submit() method to the form for you.
+ *
+ * The options argument for ajaxForm works exactly as it does for ajaxSubmit.  ajaxForm merely
+ * passes the options argument along after properly binding events for submit elements and
+ * the form itself.
+ */
+$.fn.ajaxForm = function(options) {
+    options = options || {};
+    options.delegation = options.delegation && $.isFunction($.fn.on);
+    
+    // in jQuery 1.3+ we can fix mistakes with the ready state
+    if (!options.delegation && this.length === 0) {
+        var o = { s: this.selector, c: this.context };
+        if (!$.isReady && o.s) {
+            log('DOM not ready, queuing ajaxForm');
+            $(function() {
+                $(o.s,o.c).ajaxForm(options);
+            });
+            return this;
+        }
+        // is your DOM ready?  http://docs.jquery.com/Tutorials:Introducing_$(document).ready()
+        log('terminating; zero elements found by selector' + ($.isReady ? '' : ' (DOM not ready)'));
+        return this;
+    }
+
+    if ( options.delegation ) {
+        $(document)
+            .off('submit.form-plugin', this.selector, doAjaxSubmit)
+            .off('click.form-plugin', this.selector, captureSubmittingElement)
+            .on('submit.form-plugin', this.selector, options, doAjaxSubmit)
+            .on('click.form-plugin', this.selector, options, captureSubmittingElement);
+        return this;
+    }
+
+    return this.ajaxFormUnbind()
+        .bind('submit.form-plugin', options, doAjaxSubmit)
+        .bind('click.form-plugin', options, captureSubmittingElement);
+};
+
+// private event handlers    
+function doAjaxSubmit(e) {
+    /*jshint validthis:true */
+    var options = e.data;
+    if (!e.isDefaultPrevented()) { // if event has been canceled, don't proceed
+        e.preventDefault();
+        $(this).ajaxSubmit(options);
+    }
+}
+    
+function captureSubmittingElement(e) {
+    /*jshint validthis:true */
+    var target = e.target;
+    var $el = $(target);
+    if (!($el.is(":submit,input:image"))) {
+        // is this a child element of the submit el?  (ex: a span within a button)
+        var t = $el.closest(':submit');
+        if (t.length === 0) {
+            return;
+        }
+        target = t[0];
+    }
+    var form = this;
+    form.clk = target;
+    if (target.type == 'image') {
+        if (e.offsetX !== undefined) {
+            form.clk_x = e.offsetX;
+            form.clk_y = e.offsetY;
+        } else if (typeof $.fn.offset == 'function') {
+            var offset = $el.offset();
+            form.clk_x = e.pageX - offset.left;
+            form.clk_y = e.pageY - offset.top;
+        } else {
+            form.clk_x = e.pageX - target.offsetLeft;
+            form.clk_y = e.pageY - target.offsetTop;
+        }
+    }
+    // clear form vars
+    setTimeout(function() { form.clk = form.clk_x = form.clk_y = null; }, 100);
+}
+
+
+// ajaxFormUnbind unbinds the event handlers that were bound by ajaxForm
+$.fn.ajaxFormUnbind = function() {
+    return this.unbind('submit.form-plugin click.form-plugin');
+};
+
+/**
+ * formToArray() gathers form element data into an array of objects that can
+ * be passed to any of the following ajax functions: $.get, $.post, or load.
+ * Each object in the array has both a 'name' and 'value' property.  An example of
+ * an array for a simple login form might be:
+ *
+ * [ { name: 'username', value: 'jresig' }, { name: 'password', value: 'secret' } ]
+ *
+ * It is this array that is passed to pre-submit callback functions provided to the
+ * ajaxSubmit() and ajaxForm() methods.
+ */
+$.fn.formToArray = function(semantic, elements) {
+    var a = [];
+    if (this.length === 0) {
+        return a;
+    }
+
+    var form = this[0];
+    var els = semantic ? form.getElementsByTagName('*') : form.elements;
+    if (!els) {
+        return a;
+    }
+
+    var i,j,n,v,el,max,jmax;
+    for(i=0, max=els.length; i < max; i++) {
+        el = els[i];
+        n = el.name;
+        if (!n) {
+            continue;
+        }
+
+        if (semantic && form.clk && el.type == "image") {
+            // handle image inputs on the fly when semantic == true
+            if(!el.disabled && form.clk == el) {
+                a.push({name: n, value: $(el).val(), type: el.type });
+                a.push({name: n+'.x', value: form.clk_x}, {name: n+'.y', value: form.clk_y});
+            }
+            continue;
+        }
+
+        v = $.fieldValue(el, true);
+        if (v && v.constructor == Array) {
+            if (elements) 
+                elements.push(el);
+            for(j=0, jmax=v.length; j < jmax; j++) {
+                a.push({name: n, value: v[j]});
+            }
+        }
+        else if (feature.fileapi && el.type == 'file' && !el.disabled) {
+            if (elements) 
+                elements.push(el);
+            var files = el.files;
+            if (files.length) {
+                for (j=0; j < files.length; j++) {
+                    a.push({name: n, value: files[j], type: el.type});
+                }
+            }
+            else {
+                // #180
+                a.push({ name: n, value: '', type: el.type });
+            }
+        }
+        else if (v !== null && typeof v != 'undefined') {
+            if (elements) 
+                elements.push(el);
+            a.push({name: n, value: v, type: el.type, required: el.required});
+        }
+    }
+
+    if (!semantic && form.clk) {
+        // input type=='image' are not found in elements array! handle it here
+        var $input = $(form.clk), input = $input[0];
+        n = input.name;
+        if (n && !input.disabled && input.type == 'image') {
+            a.push({name: n, value: $input.val()});
+            a.push({name: n+'.x', value: form.clk_x}, {name: n+'.y', value: form.clk_y});
+        }
+    }
+    return a;
+};
+
+/**
+ * Serializes form data into a 'submittable' string. This method will return a string
+ * in the format: name1=value1&amp;name2=value2
+ */
+$.fn.formSerialize = function(semantic) {
+    //hand off to jQuery.param for proper encoding
+    return $.param(this.formToArray(semantic));
+};
+
+/**
+ * Serializes all field elements in the jQuery object into a query string.
+ * This method will return a string in the format: name1=value1&amp;name2=value2
+ */
+$.fn.fieldSerialize = function(successful) {
+    var a = [];
+    this.each(function() {
+        var n = this.name;
+        if (!n) {
+            return;
+        }
+        var v = $.fieldValue(this, successful);
+        if (v && v.constructor == Array) {
+            for (var i=0,max=v.length; i < max; i++) {
+                a.push({name: n, value: v[i]});
+            }
+        }
+        else if (v !== null && typeof v != 'undefined') {
+            a.push({name: this.name, value: v});
+        }
+    });
+    //hand off to jQuery.param for proper encoding
+    return $.param(a);
+};
+
+/**
+ * Returns the value(s) of the element in the matched set.  For example, consider the following form:
+ *
+ *  <form><fieldset>
+ *      <input name="A" type="text" />
+ *      <input name="A" type="text" />
+ *      <input name="B" type="checkbox" value="B1" />
+ *      <input name="B" type="checkbox" value="B2"/>
+ *      <input name="C" type="radio" value="C1" />
+ *      <input name="C" type="radio" value="C2" />
+ *  </fieldset></form>
+ *
+ *  var v = $(':text').fieldValue();
+ *  // if no values are entered into the text inputs
+ *  v == ['','']
+ *  // if values entered into the text inputs are 'foo' and 'bar'
+ *  v == ['foo','bar']
+ *
+ *  var v = $(':checkbox').fieldValue();
+ *  // if neither checkbox is checked
+ *  v === undefined
+ *  // if both checkboxes are checked
+ *  v == ['B1', 'B2']
+ *
+ *  var v = $(':radio').fieldValue();
+ *  // if neither radio is checked
+ *  v === undefined
+ *  // if first radio is checked
+ *  v == ['C1']
+ *
+ * The successful argument controls whether or not the field element must be 'successful'
+ * (per http://www.w3.org/TR/html4/interact/forms.html#successful-controls).
+ * The default value of the successful argument is true.  If this value is false the value(s)
+ * for each element is returned.
+ *
+ * Note: This method *always* returns an array.  If no valid value can be determined the
+ *    array will be empty, otherwise it will contain one or more values.
+ */
+$.fn.fieldValue = function(successful) {
+    for (var val=[], i=0, max=this.length; i < max; i++) {
+        var el = this[i];
+        var v = $.fieldValue(el, successful);
+        if (v === null || typeof v == 'undefined' || (v.constructor == Array && !v.length)) {
+            continue;
+        }
+        if (v.constructor == Array)
+            $.merge(val, v);
+        else
+            val.push(v);
+    }
+    return val;
+};
+
+/**
+ * Returns the value of the field element.
+ */
+$.fieldValue = function(el, successful) {
+    var n = el.name, t = el.type, tag = el.tagName.toLowerCase();
+    if (successful === undefined) {
+        successful = true;
+    }
+
+    if (successful && (!n || el.disabled || t == 'reset' || t == 'button' ||
+        (t == 'checkbox' || t == 'radio') && !el.checked ||
+        (t == 'submit' || t == 'image') && el.form && el.form.clk != el ||
+        tag == 'select' && el.selectedIndex == -1)) {
+            return null;
+    }
+
+    if (tag == 'select') {
+        var index = el.selectedIndex;
+        if (index < 0) {
+            return null;
+        }
+        var a = [], ops = el.options;
+        var one = (t == 'select-one');
+        var max = (one ? index+1 : ops.length);
+        for(var i=(one ? index : 0); i < max; i++) {
+            var op = ops[i];
+            if (op.selected) {
+                var v = op.value;
+                if (!v) { // extra pain for IE...
+                    v = (op.attributes && op.attributes['value'] && !(op.attributes['value'].specified)) ? op.text : op.value;
+                }
+                if (one) {
+                    return v;
+                }
+                a.push(v);
+            }
+        }
+        return a;
+    }
+    return $(el).val();
+};
+
+/**
+ * Clears the form data.  Takes the following actions on the form's input fields:
+ *  - input text fields will have their 'value' property set to the empty string
+ *  - select elements will have their 'selectedIndex' property set to -1
+ *  - checkbox and radio inputs will have their 'checked' property set to false
+ *  - inputs of type submit, button, reset, and hidden will *not* be effected
+ *  - button elements will *not* be effected
+ */
+$.fn.clearForm = function(includeHidden) {
+    return this.each(function() {
+        $('input,select,textarea', this).clearFields(includeHidden);
+    });
+};
+
+/**
+ * Clears the selected form elements.
+ */
+$.fn.clearFields = $.fn.clearInputs = function(includeHidden) {
+    var re = /^(?:color|date|datetime|email|month|number|password|range|search|tel|text|time|url|week)$/i; // 'hidden' is not in this list
+    return this.each(function() {
+        var t = this.type, tag = this.tagName.toLowerCase();
+        if (re.test(t) || tag == 'textarea') {
+            this.value = '';
+        }
+        else if (t == 'checkbox' || t == 'radio') {
+            this.checked = false;
+        }
+        else if (tag == 'select') {
+            this.selectedIndex = -1;
+        }
+        else if (includeHidden) {
+            // includeHidden can be the valud true, or it can be a selector string
+            // indicating a special test; for example:
+            //  $('#myForm').clearForm('.special:hidden')
+            // the above would clean hidden inputs that have the class of 'special'
+            if ( (includeHidden === true && /hidden/.test(t)) ||
+                 (typeof includeHidden == 'string' && $(this).is(includeHidden)) )
+                this.value = '';
+        }
+    });
+};
+
+/**
+ * Resets the form data.  Causes all form elements to be reset to their original value.
+ */
+$.fn.resetForm = function() {
+    return this.each(function() {
+        // guard against an input with the name of 'reset'
+        // note that IE reports the reset function as an 'object'
+        if (typeof this.reset == 'function' || (typeof this.reset == 'object' && !this.reset.nodeType)) {
+            this.reset();
+        }
+    });
+};
+
+/**
+ * Enables or disables any matching elements.
+ */
+$.fn.enable = function(b) {
+    if (b === undefined) {
+        b = true;
+    }
+    return this.each(function() {
+        this.disabled = !b;
+    });
+};
+
+/**
+ * Checks/unchecks any matching checkboxes or radio buttons and
+ * selects/deselects and matching option elements.
+ */
+$.fn.selected = function(select) {
+    if (select === undefined) {
+        select = true;
+    }
+    return this.each(function() {
+        var t = this.type;
+        if (t == 'checkbox' || t == 'radio') {
+            this.checked = select;
+        }
+        else if (this.tagName.toLowerCase() == 'option') {
+            var $sel = $(this).parent('select');
+            if (select && $sel[0] && $sel[0].type == 'select-one') {
+                // deselect all other options
+                $sel.find('option').selected(false);
+            }
+            this.selected = select;
+        }
+    });
+};
+
+// expose debug var
+$.fn.ajaxSubmit.debug = false;
+
+// helper fn for console logging
+function log() {
+    if (!$.fn.ajaxSubmit.debug) 
+        return;
+    var msg = '[jquery.form] ' + Array.prototype.join.call(arguments,'');
+    if (window.console && window.console.log) {
+        window.console.log(msg);
+    }
+    else if (window.opera && window.opera.postError) {
+        window.opera.postError(msg);
+    }
+}
+
+})(jQuery);
public/javascripts/modules/markdown.js
--- public/javascripts/modules/markdown.js
+++ public/javascripts/modules/markdown.js
@@ -1,8 +1,8 @@
 nforge.namespace('markdown');
 
-var markdownRender = function(text) {
-  text = text.
-    replace(/```(\w+)(?:\r\n|\r|\n)((\r|\n|.)*?)(\r|\n)```/gm, function(match, p1, p2) {
+var renderMarkdown = function(text) {
+  text = text
+    .replace(/```(\w+)(?:\r\n|\r|\n)((\r|\n|.)*?)(\r|\n)```/gm, function(match, p1, p2) {
     try {
       return '<pre><code class="' + p1 + '">' + hljs(p2, p1).value + '</code></pre>';
     } catch (e) {
@@ -13,51 +13,311 @@
   return new Showdown.converter().makeHtml(text);
 };
 
-nforge.markdown.edit = function () {
-  var that;
+var getFileList = function(target, urlToGetFileList, fn) {
+  var form = $('<form>')
+    .attr('method', 'get')
+    .attr('action', urlToGetFileList);
+  resourceType = target.attr('resourceType');
+  resourceId = target.attr('resourceId');
+  if (resourceType !== undefined) {
+    form.append('<input type="hidden" name="containerType" value="' + resourceType + '">');
+  }
+  if (resourceId !== undefined) {
+    form.append('<input type="hidden" name="containerId" value="' + resourceId + '">');
+  }
+  form.ajaxForm({ success: fn });
+  form.submit();
+};
 
-  that = {
-    init : function (selector) {
-      var previewDiv, previewSwitch;
+var editor = function (textarea) {
+  var previewDiv, previewSwitch;
 
-      if (!selector) selector = '#body';
+  previewDiv = $('<div>');
+  previewDiv.attr('div', 'preview');
+  previewDiv.css('display', 'none');
 
-      previewDiv = $('<div>');
-      previewDiv.attr('div', 'preview');
+  previewSwitch = $('<div>');
+  previewSwitch.append($('<input type="radio" name="edit-mode" value="edit" checked>Edit</input>'));
+  previewSwitch.append($('<input type="radio" name="edit-mode" value="preview">Preview</input>'));
+  previewSwitch.change(function() {
+    var val = $('input:radio[name=edit-mode]:checked').val();
+    if (val == 'preview') {
+      previewDiv.html(renderMarkdown(textarea.val()));
+      textarea.css('display', 'none');
+      previewDiv.css('display', '');
+    } else {
+      textarea.css('display', '');
       previewDiv.css('display', 'none');
+    }
+  });
 
-      previewSwitch = $('<div>');
-      previewSwitch.append($('<input type="radio" name="edit-mode" value="edit" checked>Edit</input>'));
-      previewSwitch.append($('<input type="radio" name="edit-mode" value="preview">Preview</input>'));
-      previewSwitch.change(function() {
-        var val = $('input:radio[name=edit-mode]:checked').val();
-        if (val == 'preview') {
-          previewDiv.html(markdownRender($(selector).val()));
-          $(selector).css('display', 'none');
-          previewDiv.css('display', '');
-        } else {
-          $(selector).css('display', '');
-          previewDiv.css('display', 'none');
+  textarea.before(previewSwitch);
+  textarea.before(previewDiv);
+};
+
+var viewer = function (target) {
+  target.html(renderMarkdown(target.text()));
+};
+
+var fileUploader = function (textarea, action) {
+  var setProgressBar = function(value) {
+    progressbar.css("width", value + "%");
+    progressbar.text(value + "%");
+  };
+
+  var createFileItem = function(file, link) {
+    var fileitem, icon, filelink, insertButton, deleteButton;
+
+    fileitem = $('<li>');
+    fileitem.attr('tabindex', 0);
+
+    icon = $('<i>');
+    icon.addClass('icon-upload');
+
+    filelink = $('<a>');
+    filelink.attr('href', file.url);
+    filelink.text(file.name);
+
+    insertButton = $('<input type="button">');
+    insertButton.attr('id', file.name);
+    insertButton.attr('value', '본문에 삽입');
+    insertButton.addClass('insertInto label label-info');
+    insertButton.click(function() { insertLinkInto(textarea, link); });
+
+    deleteButton = $('<a>');
+    deleteButton.attr('name', 'submit');
+    deleteButton.addClass('fileDeleteBtn close');
+    deleteButton.text('x');
+    deleteButton.click(function() {
+      var form = $('<form>')
+        .attr('method', 'post')
+        .attr('enctype', 'multipart/form-data')
+        .attr('action', file.url);
+      form.append('<input type="hidden" name="_method" value="delete">');
+      form.ajaxForm({
+        success: function() {
+          fileitem.remove();
+          textarea.val(textarea.val().split(link).join(''));
+          setProgressBar(0);
+          notification.text(file.name + ' is deleted successfully.');
         }
       });
+      form.submit();
+    });
 
-      $(selector).before(previewSwitch);
-      $(selector).before(previewDiv);
+    fileitem.append(icon);
+    icon.after(filelink);
+    filelink.after(insertButton);
+    insertButton.after(deleteButton);
+
+    return fileitem;
+  };
+
+  var createFileList = function(title) {
+    var filelist, div
+
+    filelist = $('<ul>');
+    filelist.attr('id', 'filelist');
+    filelist.addClass('files');
+
+    div = $('<div>');
+    div.addClass('attachmentList');
+    div.append($('<strong>' + title + '</strong>'));
+    div.append(filelist);
+
+    return div;
+  };
+
+  var _getFileNameOnly = function(filename) {
+    var fakepath = 'fakepath';
+    var fakepathPostion = filename.indexOf(fakepath);
+    if (fakepathPostion > -1) {
+      filename = filename.substring(fakepath.length + fakepathPostion + 1);
+    }
+    return filename;
+  };
+
+  var _replaceFileInputControl = function() {
+    label.after(createAttachment());
+  };
+
+  var insertLinkInto = function(textarea, link) {
+    var pos = textarea.prop('selectionStart');
+    var text = textarea.val();
+    textarea.val(text.substring(0, pos) + link + text.substring(pos));
+  };
+
+  var isImageType = function(mimeType) {
+    if (mimeType && mimeType.substr(0, mimeType.indexOf('/')) == 'image') {
+      return true;
+    } else {
+      return false;
+    }
+  };
+
+  var createFileLink = function(name, url, mimeType) {
+    if (isImageType(mimeType)) {
+      return '<img src="' + url + '">';
+    } else {
+      return '[' + name + '](' + url + ')';
+    }
+  };
+
+  var fileUploadOptions = {
+    beforeSubmit: function() {
+      var filename = _getFileNameOnly(attachment.val());
+
+      // show message box
+      if (filename === "") {
+        notification.text('Choose a file to be attached.');
+        return false;
+      }
+
+      return true;
+    },
+
+    success: function(responseBody, statusText, xhr) {
+      var file, link;
+      file = responseBody;
+
+      if (!(file instanceof Object) || !file.name || !file.url) {
+        notification.text('Failed to upload - Server error.');
+        _replaceFileInputControl();
+        setProgressBar(0);
+        return;
+      }
+
+      _replaceFileInputControl();
+
+      link = createFileLink(file.name, file.url, file.mimeType)
+      if (isImageType(file.mimeType)) {
+        insertLinkInto(textarea, link);
+      }
+
+      tempFileList.css('display', '');
+      tempFileList.append(createFileItem(file, link));
+
+      notification.text(file.name + ' is uploaded successfully.');
+
+      setProgressBar(100);
+    },
+
+    error: function(responseBody, statusText, xhr) {
+      notification.text('Failed to upload.');
+      _replaceFileInputControl();
+      setProgressBar(0);
+    },
+
+    uploadProgress: function(event, position, total, percentComplete) {
+      setProgressBar(percentComplete);
+    }
+  };
+
+  var createAttachment = function() {
+    var attachment = $('<input type="file" name="filePath">');
+
+    attachment.click(function(event) {
+      setProgressBar(0);
+    });
+
+    attachment.change(function(event) {
+      if (attachment.val() !== "") {
+        var filename = _getFileNameOnly(attachment.val());
+        var form = $('<form>')
+          .attr('method', 'post')
+          .attr('enctype', 'multipart/form-data')
+          .attr('action', action);
+        form.append(attachment);
+        form.ajaxForm(fileUploadOptions);
+        form.submit();
+        notification.text(filename + ' is uploading...');
+      }
+    });
+
+    return attachment;
+  }
+
+  if (!textarea || !action) {
+    throw new Error('textarea and action is required.');
+  }
+
+  var label = $('<label for="attachment">').text('Select file to upload');
+  var attachment = createAttachment();
+  var progressbar = $('<div class="bar">');
+  var progress = $('<div class="progress progress-warning">')
+      .append(progressbar);
+  var attachmentList = createFileList('Attachments');
+  var tempFileList = createFileList('Temporary files (attached if you save)');
+  var notification = $('<div>');
+
+  attachmentList.css('display', 'none');
+  tempFileList.css('display', 'none');
+
+  getFileList(textarea, action, function(responseBody, statusText, xhr) {
+    var addFiles = function(files, targetList) {
+      if (files) {
+        for (var i = 0; i < files.length; i++) {
+          var file = files[i];
+          var link = createFileLink(file.name, file.url, file.mimeType);
+          targetList.css('display', '');
+          targetList.append(createFileItem(file, link));
+        }
+      }
+    };
+
+    addFiles(responseBody.attachments, attachmentList);
+    addFiles(responseBody.tempFiles, tempFileList);
+  });
+
+  textarea.after(label);
+  label.after(notification);
+  notification.after(attachment);
+  attachment.after(progress);
+  progress.after(attachmentList);
+  attachmentList.after(tempFileList);
+}
+
+var fileDownloader = function (div, urlToGetFileList) {
+  var createFileItem = function(file) {
+    var link = $('<a>')
+        .prop('href', file.url)
+        .append($('<i>').addClass('icon-download'))
+        .append($('<div>').text(file.name).html());
+
+    return $('<li>').append(link);
+  }
+
+  var filelist = $('<ul>');
+  var addFiles = function(responseBody, statusText, xhr) {
+    var files = responseBody.attachments;
+    for (var i = 0; i < files.length; i++) {
+      filelist.css('display', '');
+      filelist.append(createFileItem(files[i]));
+    }
+  }
+
+  getFileList(div, urlToGetFileList, addFiles);
+
+  div.after(filelist);
+}
+
+nforge.markdown.enable = function() {
+  var that = {
+    init: function(targets, action) {
+      for(var i = 0; i < targets.length; i++) {
+        var target = targets[i];
+        var tagname = target.tagName.toLowerCase();
+        if (tagname == 'textarea' || tagname == 'input'
+                || target.contentEditable == 'true') {
+          editor($(target));
+          fileUploader($(target), action);
+        } else {
+          viewer($(target));
+          fileDownloader($(target), action);
+        }
+      }
     }
   };
 
   return that;
-};
-
-nforge.markdown.render = function (selector) {
-  var that;
-
-  that = {
-    init : function (selector) {
-      if (!selector) selector = '#body';
-      $(selector).html(markdownRender($(selector).text()));
-    }
-  };
-
-  return that;
-};
+}
test/models/IssueTest.java
--- test/models/IssueTest.java
+++ test/models/IssueTest.java
@@ -104,8 +104,7 @@
 
         // Given
         // When
-        Page<Issue> issues = Issue.findFilteredIssues("nForge4java", "로그", StateType.OPEN, false,
-                true);
+        Page<Issue> issues = Issue.findFilteredIssues("nForge4java", "로그", StateType.OPEN, false);
         // Then
         assertThat(issues.getTotalRowCount()).isEqualTo(1);
 
@@ -116,15 +115,6 @@
         // Given
         // When
         Page<Issue> issues = Issue.findCommentedIssues("nForge4java", "");
-        // Then
-        assertThat(issues.getTotalRowCount()).isEqualTo(1);
-    }
-
-    @Test
-    public void findFileAttachedIssue() throws Exception {
-        // Given
-        // When
-        Page<Issue> issues = Issue.findFileAttachedIssues("nForge4java", "");
         // Then
         assertThat(issues.getTotalRowCount()).isEqualTo(1);
     }
Add a comment
List