doortts doortts 2014-07-24
online-readme-edit: add online project readme editing feature
     Unfortunately, it can be used only in git project.
@2cae1285222d368136a147989eeb6a4f6127c1f6
app/assets/stylesheets/less/_page.less
--- app/assets/stylesheets/less/_page.less
+++ app/assets/stylesheets/less/_page.less
@@ -2423,7 +2423,6 @@
     .default {
         padding:20px;
         text-align:center;
-        min-height:540px;
         .v { display:inline-block; width:0; height:500px; vertical-align:middle; }
     }
     .readme-wrap {
app/controllers/BoardApp.java
--- app/controllers/BoardApp.java
+++ app/controllers/BoardApp.java
@@ -21,7 +21,6 @@
 package controllers;
 
 import actions.AnonymousCheckAction;
-import actions.DefaultProjectCheckAction;
 import actions.NullProjectCheckAction;
 
 import com.avaje.ebean.ExpressionList;
@@ -41,6 +40,8 @@
 import play.mvc.Call;
 import play.mvc.Result;
 import play.mvc.With;
+import playRepository.BareCommit;
+import playRepository.BareRepository;
 import utils.AccessControl;
 import utils.ErrorViews;
 import utils.JodaDateUtil;
@@ -98,7 +99,20 @@
         boolean isAllowedToNotice =
                 AccessControl.isProjectResourceCreatable(UserApp.currentUser(), project, ResourceType.BOARD_NOTICE);
 
-        return ok(create.render("post.new", new Form<>(Posting.class), project, isAllowedToNotice));
+        String preparedBodyText = "";
+        if(readmeEditRequested() && projectHasReadme(project)){
+            preparedBodyText = BareRepository.readREADME(project);
+        }
+
+        return ok(create.render("post.new", new Form<>(Posting.class), project, isAllowedToNotice, preparedBodyText));
+    }
+
+    private static boolean projectHasReadme(Project project) {
+        return project.readme() != null;
+    }
+
+    private static boolean readmeEditRequested() {
+        return request().getQueryString("readme") != null;
     }
 
     @Transactional
@@ -110,7 +124,7 @@
         if (postForm.hasErrors()) {
             boolean isAllowedToNotice =
                     AccessControl.isProjectResourceCreatable(UserApp.currentUser(), project, ResourceType.BOARD_NOTICE);
-            return badRequest(create.render("error.validation", postForm, project, isAllowedToNotice));
+            return badRequest(create.render("error.validation", postForm, project, isAllowedToNotice, ""));
         }
 
         final Posting post = postForm.get();
@@ -124,19 +138,42 @@
         post.setAuthor(UserApp.currentUser());
         post.project = project;
 
+        if (post.readme) {
+            Posting readmePosting = Posting.findREADMEPosting(project);
+            if (readmePosting != null) {
+                return editPost(userName, projectName, readmePosting.getNumber());
+            } else {
+                commitReadmeFile(project, post);
+            }
+        }
         post.save();
-
         attachUploadFilesToPost(post.asResource());
-
         NotificationEvent.afterNewPost(post);
 
+        if (post.readme) {
+            return redirect(routes.ProjectApp.project(userName, projectName));
+        }
         return redirect(routes.BoardApp.post(project.owner, project.name, post.getNumber()));
+    }
+
+    private static void commitReadmeFile(Project project, Posting post){
+        BareCommit bare = new BareCommit(project, UserApp.currentUser());
+        try{
+            bare.commitTextFile("README.md", post.body, post.title);
+        } catch (IOException e) {
+            e.printStackTrace();
+            play.Logger.error(e.getMessage());
+        }
     }
 
     @IsAllowed(value = Operation.READ, resourceType = ResourceType.BOARD_POST)
     public static Result post(String userName, String projectName, Long number) {
         Project project = Project.findByOwnerAndProjectName(userName, projectName);
         Posting post = Posting.findByNumber(project, number);
+
+        if(post.readme){
+            post.body = BareRepository.readREADME(project);
+        }
 
         if (request().getHeader("Accept").contains("application/json")) {
             ObjectNode json = Json.newObject();
@@ -163,6 +200,9 @@
         Form<Posting> editForm = new Form<>(Posting.class).fill(posting);
         boolean isAllowedToNotice = ProjectUser.isAllowedToNotice(UserApp.currentUser(), project);
 
+        if(posting.readme){
+            posting.body = BareRepository.readREADME(project);
+        }
         return ok(edit.render("post.modify", editForm, posting, number, project, isAllowedToNotice));
     }
 
@@ -183,7 +223,11 @@
 
         final Posting post = postForm.get();
         final Posting original = Posting.findByNumber(project, number);
-
+        if (post.readme) {
+            post.setAuthor(UserApp.currentUser());
+            commitReadmeFile(project, post);
+            unmarkAnotherReadmePostingIfExists(project, number);
+        }
         Call redirectTo = routes.BoardApp.post(project.owner, project.name, number);
         Runnable updatePostingBeforeUpdate = new Runnable() {
             @Override
@@ -195,6 +239,14 @@
         return editPosting(original, post, postForm, redirectTo, updatePostingBeforeUpdate);
     }
 
+    private static void unmarkAnotherReadmePostingIfExists(Project project, Long postingNumber) {
+        Posting previousReadmePosting = Posting.findREADMEPosting(project);
+        if(previousReadmePosting != null && previousReadmePosting.getNumber() != postingNumber){
+            previousReadmePosting.readme = false;
+            previousReadmePosting.directSave();
+        }
+    }
+
     /**
      * @see controllers.AbstractPostingApp#delete(play.db.ebean.Model, models.resource.Resource, play.mvc.Call)
      */
app/models/AbstractPosting.java
--- app/models/AbstractPosting.java
+++ app/models/AbstractPosting.java
@@ -139,6 +139,14 @@
         updateMention();
     }
 
+    /**
+     * use EBean save functionality directly
+     * to prevent occurring select table lock
+     */
+    public void directSave(){
+        updateMention();
+        super.save();
+    }
 
     public void updateNumber() {
         number = increaseNumber();
app/models/Posting.java
--- app/models/Posting.java
+++ app/models/Posting.java
@@ -22,6 +22,7 @@
     public static final Finder<Long, Posting> finder = new Finder<>(Long.class, Posting.class);
 
     public boolean notice;
+    public boolean readme;
 
     @OneToMany(cascade = CascadeType.ALL)
     public List<PostingComment> comments;
@@ -92,4 +93,20 @@
     public static int countPostings(Project project) {
         return finder.where().eq("project", project).findRowCount();
     }
+
+    /**
+     * use EBean save functionality directly
+     * to prevent occurring select table lock
+     */
+    public void directSave(){
+        super.directSave();
+    }
+
+    public static Posting findREADMEPosting(Project project) {
+        return Posting.finder.where()
+                .eq("project.id", project.id)
+                .add(eq("readme", true))
+                .findUnique();
+    }
+
 }
 
app/playRepository/BareCommit.java (added)
+++ app/playRepository/BareCommit.java
@@ -0,0 +1,227 @@
+/**
+ * Yobi, Project Hosting SW
+ *
+ * Copyright 2014 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.
+ */
+package playRepository;
+
+import models.Project;
+import models.User;
+import org.eclipse.jgit.lib.*;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.CanonicalTreeParser;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.RandomAccessFile;
+import java.nio.channels.FileChannel;
+import java.nio.channels.FileLock;
+import java.nio.channels.OverlappingFileLockException;
+
+import static org.eclipse.jgit.lib.Constants.HEAD;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+public class BareCommit {
+    private PersonIdent personIdent;
+    private Repository repository;
+    private String commitMessage;
+    private ObjectInserter objectInserter;
+
+    private File file;
+    private String refName;
+    private ObjectId headObjectId;
+
+    public BareCommit(Project project, User user) {
+        this.repository = BareRepository.getRepository(project);
+        this.personIdent = new PersonIdent(user.name, user.email);
+    }
+
+    /**
+     * Commit to bare repository with filename and text contents
+     *
+     * @param fileNameWithPath
+     * @param contents
+     * @param message
+     */
+    public void commitTextFile(String fileNameWithPath, String contents, String message) throws IOException {
+        this.file = new File(fileNameWithPath);
+        setCommitMessage(message);
+        if (this.refName == null) {
+            setRefName(HEAD);
+        }
+
+        RefHeadFileLock refHeadFileLock = new RefHeadFileLock().invoke(this.refName);
+        try {
+            this.objectInserter = this.repository.newObjectInserter();
+            refUpdate(createCommitWithNewTree(createGitObjectWithText(contents)), refName);
+        } catch (OverlappingFileLockException e) {
+            play.Logger.error("Overlapping File Lock Error: " + e.getMessage());
+        } finally {
+            objectInserter.release();
+            repository.close();
+            refHeadFileLock.release();
+        }
+    }
+
+    private boolean noHeadRef() {
+        if(this.headObjectId == null) {
+            return true;
+        }
+        return this.headObjectId.equals(ObjectId.zeroId());
+    }
+
+    private ObjectId createCommitWithNewTree(ObjectId targetTextFileObjectId) throws IOException {
+        return objectInserter.insert(buildCommitWith(file.getName(), targetTextFileObjectId));
+    }
+
+    private CommitBuilder buildCommitWith(String fileName, ObjectId fileObjectId) throws IOException {
+        CommitBuilder commit = new CommitBuilder();
+        commit.setTreeId(createTreeWith(fileName, fileObjectId));
+        if (!noHeadRef()) {
+            commit.setParentId(this.headObjectId);
+        }
+        commit.setAuthor(getPersonIdent());
+        commit.setCommitter(getPersonIdent());
+        commit.setMessage(getCommitMessage());
+        return commit;
+    }
+
+    private ObjectId createTreeWith(String fileName, ObjectId fileObjectId) throws IOException {
+        if (noHeadRef()){
+            return objectInserter.insert(newTreeWith(fileName, fileObjectId));
+        } else {
+            return objectInserter.insert(rebuildExistingTreeWith(fileName, fileObjectId));
+        }
+    }
+
+    private TreeFormatter newTreeWith(String fileName, ObjectId fileObjectId) {
+        TreeFormatter formatter = new TreeFormatter();
+        formatter.append(fileName, FileMode.REGULAR_FILE, fileObjectId);
+        return formatter;
+    }
+
+    private TreeFormatter rebuildExistingTreeWith(String fileName, ObjectId fileObjectId) throws IOException {
+        TreeFormatter formatter = new TreeFormatter();
+        CanonicalTreeParser treeParser = getCanonicalTreeParser(this.repository);
+        formatter.append(fileName, FileMode.REGULAR_FILE, fileObjectId);
+        while(!treeParser.eof()){
+            String entryName = new String(treeParser.getEntryPathBuffer(), 0, treeParser.getEntryPathLength());
+            if (!entryName.equals(fileName)){
+                formatter.append(entryName
+                        , treeParser.getEntryFileMode()
+                        , treeParser.getEntryObjectId());
+            }
+            treeParser = treeParser.next();
+        }
+        return formatter;
+    }
+
+    private CanonicalTreeParser getCanonicalTreeParser(Repository repository) throws IOException {
+        RevWalk revWalk = new RevWalk(repository);
+        RevCommit commit = revWalk.parseCommit(this.headObjectId);
+        return new CanonicalTreeParser(new byte[]{}, revWalk.getObjectReader(), commit.getTree().getId());
+    }
+
+    private ObjectId createGitObjectWithText(String contents) throws IOException {
+        return objectInserter.insert(OBJ_BLOB, contents.getBytes(), 0, contents.getBytes().length);
+    }
+
+    private void refUpdate(ObjectId commitId, String refName) throws IOException {
+        RefUpdate ru = this.repository.updateRef(refName);
+        ru.setForceUpdate(false);
+        ru.setRefLogIdent(getPersonIdent());
+        ru.setNewObjectId(commitId);
+        if(hasOldCommit(refName)){
+            ru.setExpectedOldObjectId(getCurrentMomentHeadObjectId());
+        }
+        ru.setRefLogMessage(getCommitMessage(), false);
+        ru.update();
+    }
+
+    private boolean hasOldCommit(String refName) throws IOException {
+        return this.repository.getRef(refName).getObjectId() != null;
+    }
+
+    private PersonIdent getPersonIdent() {
+        if (this.personIdent == null) {
+            this.personIdent = new PersonIdent(this.repository);
+        }
+        return personIdent;
+    }
+
+    private String getCommitMessage() {
+        if(this.commitMessage == null){
+            return "Update " + this.file.getName();
+        }
+        return commitMessage;
+    }
+
+    public void setCommitMessage(String commitMessage) {
+        this.commitMessage = commitMessage;
+    }
+
+    public void setHeadObjectId(String refName) throws IOException {
+        if(this.repository.getRef(refName).getObjectId() == null){
+            this.headObjectId = ObjectId.zeroId();
+        } else {
+            this.headObjectId = this.repository.getRef(refName).getObjectId();
+        }
+    }
+
+    public ObjectId getCurrentMomentHeadObjectId() throws IOException {
+        if( this.repository.getRef(refName).getObjectId() == null ){
+            return ObjectId.zeroId();
+        } else {
+            return this.repository.getRef(refName).getObjectId();
+        }
+    }
+
+    public void setRefName(String refName){
+        this.refName = refName;
+    }
+
+    private class RefHeadFileLock {
+        private FileChannel channel;
+        private FileLock lock;
+        private File refHeadFile;
+
+        public RefHeadFileLock invoke(String refName) throws IOException {
+            refHeadFile = new File(repository.getDirectory().getPath(),
+                    repository.getRef(refName).getLeaf().getName());
+            if(refHeadFile.exists()){
+                channel = new RandomAccessFile(refHeadFile, "rw").getChannel();
+                lock = channel.lock();
+            }
+            setHeadObjectId(refName);
+            return this;
+        }
+
+        public void release() {
+            try {
+                if(refHeadFile.exists()) {
+                    if(lock != null) lock.release();
+                    if(channel != null) channel.close();
+                }
+            } catch (IOException e) {
+                e.printStackTrace();
+                play.Logger.error(e.getMessage());
+            }
+        }
+    }
+}
 
app/playRepository/BareRepository.java (added)
+++ app/playRepository/BareRepository.java
@@ -0,0 +1,118 @@
+/**
+ * Yobi, Project Hosting SW
+ *
+ * Copyright 2014 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.
+ */
+package playRepository;
+
+import models.Project;
+import org.eclipse.jgit.lib.*;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.eclipse.jgit.treewalk.filter.OrTreeFilter;
+import org.eclipse.jgit.treewalk.filter.PathFilter;
+import org.eclipse.jgit.treewalk.filter.TreeFilter;
+import org.eclipse.jgit.util.FS;
+
+import javax.servlet.ServletException;
+import java.io.IOException;
+
+import static org.eclipse.jgit.lib.Constants.HEAD;
+
+public class BareRepository {
+    /**
+     * read project README file with readme filename filter from repository
+     *
+     * @param project
+     * @return
+     */
+    public static String readREADME(Project project){
+        Repository repository;
+        ObjectLoader loader = null;
+        repository = getRepository(project);
+        try {
+            loader = repository.open(getFirstFoundREADMEfileObjectId(repository));
+        } catch (IOException e) {
+            e.printStackTrace();
+            play.Logger.error(e.getMessage());
+        }
+        return new String(loader.getCachedBytes());
+    }
+
+    public static Repository getRepository(Project project){
+        Repository repository = null;
+        try {
+            RepositoryCache.FileKey fileKey = RepositoryCache.FileKey.exact(
+                    RepositoryService.getRepository(project).getDirectory(), FS.DETECTED);
+            repository = fileKey.open(false);
+        } catch (ServletException | IOException e) {
+            e.printStackTrace();
+            play.Logger.error(e.getMessage());
+        }
+        return repository;
+    }
+
+    public static ObjectId getFileObjectId(Repository repository, String fileNameWithPath) throws IOException {
+        TreeWalk treeWalk = new TreeWalk(repository);
+        RevTree revTree = getRevTreeFromRef(repository, repository.getRef(HEAD));
+        if( revTree == null ){
+            return null;
+        }
+        treeWalk.addTree(revTree);
+        treeWalk.setRecursive(false);
+        treeWalk.setFilter(PathFilter.create(fileNameWithPath));
+        return treeWalk.getObjectId(0);
+    }
+
+    private static ObjectId getFirstFoundREADMEfileObjectId(Repository repository) throws IOException {
+        TreeWalk treeWalk = new TreeWalk(repository);
+        RevTree revTree = getRevTreeFromRef(repository, repository.getRef(HEAD));
+        if( revTree != null ){
+            treeWalk.addTree(revTree);
+        }
+        treeWalk.setRecursive(false);
+        treeWalk.setFilter(OrTreeFilter.create(READMEFileNameFilter()));
+
+        if (!treeWalk.next()) {
+            play.Logger.info("no tree or no README file found");
+            throw new IllegalStateException("Did not find expected file 'README.md'");
+        }
+
+        return treeWalk.getObjectId(0);
+    }
+
+    private static RevTree getRevTreeFromRef(Repository repository, Ref ref) throws IOException {
+        if(ref.getObjectId() == null){
+            return null;
+        }
+        RevWalk revWalk = new RevWalk(repository);
+        RevCommit commit = revWalk.parseCommit(ref.getObjectId());
+        return commit.getTree();
+    }
+
+    private static TreeFilter[] READMEFileNameFilter() {
+        TreeFilter[] filters = new TreeFilter[4];
+        filters[0] = PathFilter.create("README.md");
+        filters[1] = PathFilter.create("readme.md");
+        filters[2] = PathFilter.create("README.markdown");
+        filters[3] = PathFilter.create("readme.markdown");
+        return filters;
+    }
+}
app/playRepository/GitRepository.java
--- app/playRepository/GitRepository.java
+++ app/playRepository/GitRepository.java
@@ -1925,4 +1925,9 @@
             return false;
         }
     }
+
+    @Override
+    public File getDirectory() {
+        return this.repository.getDirectory();
+    }
 }
app/playRepository/PlayRepository.java
--- app/playRepository/PlayRepository.java
+++ app/playRepository/PlayRepository.java
@@ -24,9 +24,11 @@
 
 import org.codehaus.jackson.node.ObjectNode;
 import org.eclipse.jgit.api.errors.GitAPIException;
+import org.eclipse.jgit.lib.Repository;
 import org.tigris.subversion.javahl.ClientException;
 import org.tmatesoft.svn.core.SVNException;
 
+import java.io.File;
 import java.io.IOException;
 import java.util.List;
 
@@ -76,4 +78,6 @@
     boolean isEmpty();
 
     boolean move(String srcProjectOwner, String srcProjectName, String desrProjectOwner, String destProjectName);
+
+    public File getDirectory();
 }
app/playRepository/SVNRepository.java
--- app/playRepository/SVNRepository.java
+++ app/playRepository/SVNRepository.java
@@ -20,7 +20,6 @@
  */
 package playRepository;
 
-import controllers.ProjectApp;
 import controllers.UserApp;
 import controllers.routes;
 import models.Project;
@@ -439,4 +438,9 @@
             return false;
         }
     }
+
+    @Override
+    public File getDirectory() {
+        return new File(getRepoPrefix() + ownerName + "/" + projectName);
+    }
 }
 
app/utils/HtmlUtil.java (added)
+++ app/utils/HtmlUtil.java
@@ -0,0 +1,45 @@
+package utils;
+
+import org.apache.commons.lang.StringUtils;
+
+/**
+ * Yobi, Project Hosting SW
+ *
+ * Copyright 2014 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.
+ */
+public class HtmlUtil {
+    public static String boolToCheckedString(boolean bool){
+        if (bool == true) {
+            return "checked";
+        } else {
+            return "";
+        }
+    }
+
+    public static String boolToCheckedString(String bool){
+        if (StringUtils.isBlank(bool)){
+            return "";
+        }
+        if (bool.equals("true")) {
+            return "checked";
+        } else {
+            return "";
+        }
+    }
+
+}
app/views/board/create.scala.html
--- app/views/board/create.scala.html
+++ app/views/board/create.scala.html
@@ -18,12 +18,18 @@
 * See the License for the specific language governing permissions and
 * limitations under the License.
 **@
-@(title:String, form:Form[Posting], project:Project, isAllowedToNotice:Boolean)
+@(title:String, form:Form[Posting], project:Project, isAllowedToNotice:Boolean, preparedPostBody:String = "")
 
-@import scala.collection.Map
 @import utils.TemplateHelper._
+@import utils.HtmlUtil._
 @import models.enumeration._
 @implicitField = @{ helper.FieldConstructor(simpleForm) }
+
+@readmeUpdateMessage = @{
+    if( request().getQueryString("readme") != null ) {
+        "Update README.md"
+    }
+}
 
 @projectLayout(title, project, utils.MenuType.BOARD) {
 @projectMenu(project, utils.MenuType.BOARD, "main-menu-only")
@@ -37,7 +43,7 @@
     			</dt>
     			<dd>
     				@defining(form.errors().get("title")) { errors =>
-    				<input type="text" id="title" name="title" class="zen-mode text title @if(errors != null) {error}" maxlength="250" tabindex="1" />
+				<input type="text" id="title" name="title" class="zen-mode text title @if(errors != null) {error}" maxlength="250" tabindex="1" value="@readmeUpdateMessage"/>
     					@if(errors != null) {
     						<div class="message">
     						@for(error <- errors) {
@@ -49,13 +55,18 @@
     			</dd>
 
                 <dd style="position: relative;">
-                    @common.editor("body", "", "tabindex=2")
+                    @common.editor("body", preparedPostBody, "tabindex=2")
     			</dd>
 
     			@if(isAllowedToNotice){
     			<dd class="right-txt">
     				<input type="checkbox" id="notice" name="notice" class="checkbox" /><!--
     			 --><label for="notice" class="label-public">@Messages("post.notice.label")</label>
+                    @if(project.isGit && request().getQueryString("readme") != null ) {
+                        <input type="checkbox" id="readme" name="readme" class="checkbox" @boolToCheckedString(request().getQueryString("readme"))
+                        /><!--
+                    --><label for="readme" class="label-public">@Messages("post.readmefy")</label>
+                    }
     			</dd>
     			}
     		</dl>
app/views/board/edit.scala.html
--- app/views/board/edit.scala.html
+++ app/views/board/edit.scala.html
@@ -24,6 +24,7 @@
 @import utils.TemplateHelper._
 @import models.enumeration.ResourceType
 @implicitField = @{ helper.FieldConstructor(simpleForm) }
+@import utils.HtmlUtil._
 
 @projectLayout(title, project, utils.MenuType.BOARD) {
 @projectMenu(project, utils.MenuType.BOARD, "main-menu-only")
@@ -57,6 +58,10 @@
     			<dd class="right-txt">
     				<input type="checkbox" id="notice" name="notice" class="checkbox" @toHtmlArgs(args) @(if(value.equals(Some("true"))) "checked" else "")/><!--
     			 --><label for="notice" class="label-public">@Messages("post.notice.label")</label>
+                @if(project.isGit){
+                    <input type="checkbox" id="readme" name="readme" class="checkbox" @boolToCheckedString(posting.readme)/>
+                    <label for="readme" class="label-public">@Messages("post.readmefy")</label>
+                }
     			</dd>
     			}
     			}
app/views/board/partial_list.scala.html
--- app/views/board/partial_list.scala.html
+++ app/views/board/partial_list.scala.html
@@ -34,6 +34,9 @@
     <div class="title-wrap">
         @if(post.notice){
             <span class="label label-notice">@Messages("post.notice")</span>&nbsp;
+        }
+        @if(post.readme){
+            <span class="label label-important">README</span>&nbsp;
         } else {
             <span class="post-id">@post.getNumber</span>
         }
app/views/project/partial_readme.scala.html
--- app/views/project/partial_readme.scala.html
+++ app/views/project/partial_readme.scala.html
@@ -19,20 +19,28 @@
 * limitations under the License.
 **@
 @(project: Project)
+@import utils.AccessControl._
+@import models.enumeration.ResourceType
 
 <div class="bubble-wrap gray readme">
 @if(project.readme == null){
     <p class="default">
-       <span class="v"></span>
           @if(project.vcs.equals("GIT")) {
-              <span>@Messages("project.readme")</span>
+            <span>@Messages("project.readme")</span><br/><br/>
+              @if(isProjectResourceCreatable(UserApp.currentUser(), project, ResourceType.COMMIT)) {
+              <a href="@routes.BoardApp.newPostForm(project.owner, project.name)?readme=true" class="ybtn">@Messages("project.readme.create")</a>
+              }
           } else {
               <span>@Messages("project.svn.readme")</span>
           }
     </p>
 } else {
     <div class="readme-wrap">
-        <header><i class="yobicon-book-open"></i><strong> @project.getReadmeFileName</strong></header>
+        <header><i class="yobicon-book-open"></i><strong> @project.getReadmeFileName</strong>
+            @if(isProjectResourceCreatable(UserApp.currentUser(), project, ResourceType.COMMIT)) {
+                <a href="@routes.BoardApp.newPostForm(project.owner, project.name)?readme=true" class="ybtn">edit</a>
+            }
+        </header>
         <div class="readme-body markdown-wrap markdown-before" markdown="true">@project.readme</div>
     </div>
 }
 
conf/evolutions/default/84.sql (added)
+++ conf/evolutions/default/84.sql
@@ -0,0 +1,8 @@
+# --- !Ups
+
+alter table posting add column readme boolean;
+update posting set readme = false;
+
+# --- !Downs
+
+alter table posting drop column readme;
conf/messages
--- conf/messages
+++ conf/messages
@@ -406,6 +406,7 @@
 post.popup.fileAttach.hasMissing = {0} attachment files are missing.
 post.popup.fileAttach.hasMissing.description = It may caused by server keep time of temporary upload file ({1} min). Please upload again.
 post.popup.fileAttach.title = Select files
+post.readmefy= make it a README file
 post.unwatch.start = Notifications about this post has been muted
 post.update.error = Errors in input values.
 post.watch.start = You will receive notifications about this post
@@ -504,6 +505,7 @@
 project.public = PUBLIC
 project.public.notice = Anonymous users are available to access the project.
 project.readme = README.md will be shown here if you add it into the code repository''s root directory.
+project.readme.create = create README
 project.recently.visited = Recently visited
 project.reviewer.count = Reviewer
 project.reviewer.count.description = ( ) of reviewers is required to accept pull-request.
conf/messages.ko
--- conf/messages.ko
+++ conf/messages.ko
@@ -406,6 +406,7 @@
 post.popup.fileAttach.hasMissing = 첨부되지 못한 파일이 {0}개 있습니다.
 post.popup.fileAttach.hasMissing.description = 업로드 후 {1}분이 지나도록 글 작성을 완료하지 않은 경우 이 문제가 발생할 수 있습니다. 파일을 다시 첨부해 주세요.
 post.popup.fileAttach.title = 첨부파일 선택
+post.readmefy= 프로젝트 README 파일로 만듭니다
 post.unwatch.start = 이제 이 글에 관한 알림을 받지 않습니다
 post.update.error = 입력값 오류
 post.watch.start = 이제 이 글에 관한 알림을 받습니다
@@ -504,6 +505,7 @@
 project.public = 공개
 project.public.notice = 모든 사람이 인증절차 없이 접근할 수 있습니다. 일부 기능은 로그인이 필요할 수 있습니다.
 project.readme = 프로젝트에 대한 설명을 README.md 파일로 작성해서 코드저장소 루트 디렉토리에 추가하면 이 곳에 나타납니다.
+project.readme.create = README 만들기
 project.recently.visited = 최근 방문한 프로젝트
 project.reviewer.count.description = 명이 리뷰를 완료하면 코드를 받을 수 있습니다.
 project.reviewer.count.disable = 사용 안함
Add a comment
List