diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/README.md b/README.md index 95a0822..1e8d132 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ GitBucket ========= -GitBucket is a Github clone by Scala, Easy to setup. +GitBucket is the easily installable Github clone written with Scala. The current version of GitBucket provides a basic features below: @@ -9,12 +9,12 @@ - Repository viewer (some advanced features are not implemented) - Wiki - Issues +- Activity timeline - User management (for Administrators) Following features are not implemented, but we will make them in the future release! - Fork and pull request -- Timeline - Search - Network graph - Statics @@ -32,8 +32,20 @@ The default administrator account is **root** and password is **root**. +To upgrade GitBucket, only replace gitbucket.war. + Release Notes -------- +### 1.3 - xx Jul 2013 +- Batch updating for issues. +- Display assigned user on issue list. +- User icon and Gravatar support. +- Convert @xxxx to link to the account page. +- Add copy to clipboard button for git clone URL. +- Allows multi-byte characters as wiki page name. +- Allows to create the empty repository. +- Fixed some bugs. + ### 1.2 - 09 Jul 2013 - Added activity timeline. - Bugfix for Git 1.8.1.5 or later. diff --git a/src/main/resources/noimage.png b/src/main/resources/noimage.png index c48285f..39d1ab4 100644 --- a/src/main/resources/noimage.png +++ b/src/main/resources/noimage.png Binary files differ diff --git a/src/main/resources/update/1_3.sql b/src/main/resources/update/1_3.sql index 8001b9e..59ab009 100644 --- a/src/main/resources/update/1_3.sql +++ b/src/main/resources/update/1_3.sql @@ -1 +1,8 @@ ALTER TABLE ACCOUNT ADD COLUMN IMAGE VARCHAR(100); + +UPDATE ISSUE_COMMENT SET ACTION = 'comment' WHERE ACTION IS NULL; + +ALTER TABLE ISSUE_COMMENT ALTER COLUMN ACTION VARCHAR(20) NOT NULL; + +UPDATE ISSUE_COMMENT SET ACTION = 'close_comment' WHERE ACTION = 'close'; +UPDATE ISSUE_COMMENT SET ACTION = 'reopen_comment' WHERE ACTION = 'reopen'; diff --git a/src/main/scala/app/AccountController.scala b/src/main/scala/app/AccountController.scala index 4586186..68b2d53 100644 --- a/src/main/scala/app/AccountController.scala +++ b/src/main/scala/app/AccountController.scala @@ -58,7 +58,10 @@ getAccountByUserName(userName).flatMap(_.image).map { image => contentType = FileUtil.getMimeType(image) new java.io.File(getUserUploadDir(userName), image) - } getOrElse NotFound + } getOrElse { + contentType = "image/png" + Thread.currentThread.getContextClassLoader.getResourceAsStream("noimage.png") + } } get("/:userName/_edit")(oneselfOnly { diff --git a/src/main/scala/app/IssuesController.scala b/src/main/scala/app/IssuesController.scala index e430bb2..09cc76b 100644 --- a/src/main/scala/app/IssuesController.scala +++ b/src/main/scala/app/IssuesController.scala @@ -128,11 +128,15 @@ }) post("/:owner/:repository/issue_comments/new", commentForm)(readableUsersOnly { (form, repository) => - handleComment(form.issueId, Some(form.content), repository) + handleComment(form.issueId, Some(form.content), repository)() map { id => + redirect("/%s/%s/issues/%d#comment-%d".format(repository.owner, repository.name, form.issueId, id)) + } getOrElse NotFound }) post("/:owner/:repository/issue_comments/state", issueStateForm)(readableUsersOnly { (form, repository) => - handleComment(form.issueId, form.content, repository) + handleComment(form.issueId, form.content, repository)() map { id => + redirect("/%s/%s/issues/%d#comment-%d".format(repository.owner, repository.name, form.issueId, id)) + } getOrElse NotFound }) ajaxPost("/:owner/:repository/issue_comments/edit/:id", commentForm)(readableUsersOnly { (form, repository) => @@ -158,7 +162,7 @@ org.json4s.jackson.Serialization.write( Map("title" -> x.title, "content" -> view.Markdown.toHtml(x.content getOrElse "No description given.", - repository, false, true, true) + repository, false, true) )) } } else Unauthorized @@ -175,7 +179,7 @@ contentType = formats("json") org.json4s.jackson.Serialization.write( Map("content" -> view.Markdown.toHtml(x.content, - repository, false, true, true) + repository, false, true) )) } } else Unauthorized @@ -197,79 +201,76 @@ }) ajaxPost("/:owner/:repository/issues/:id/assign")(collaboratorsOnly { repository => - updateAssignedUserName(repository.owner, repository.name, params("id").toInt, - params.get("assignedUserName") filter (_.trim != "")) + updateAssignedUserName(repository.owner, repository.name, params("id").toInt, assignedUserName("assignedUserName")) Ok("updated") }) ajaxPost("/:owner/:repository/issues/:id/milestone")(collaboratorsOnly { repository => - updateMilestoneId(repository.owner, repository.name, params("id").toInt, - params.get("milestoneId") collect { case x if x.trim != "" => x.toInt }) + updateMilestoneId(repository.owner, repository.name, params("id").toInt, milestoneId("milestoneId")) Ok("updated") }) post("/:owner/:repository/issues/batchedit/state")(collaboratorsOnly { repository => - val owner = repository.owner - val name = repository.name - val userName = context.loginAccount.get.userName + val action = params.get("value") - params.get("value") collect { - case s if s == "close" => (s.capitalize, Some(s), true) - case s if s == "reopen" => (s.capitalize, Some(s), false) - } map { case (content, action, closed) => - params("checked").split(',') foreach { issueId => - createComment(owner, name, userName, issueId.toInt, content, action) - updateClosed(owner, name, issueId.toInt, closed) - } - redirect("/%s/%s/issues".format(owner, name)) - } getOrElse NotFound + executeBatch(repository) { + handleComment(_, None, repository)( _ => action) + } }) post("/:owner/:repository/issues/batchedit/label")(collaboratorsOnly { repository => - val owner = repository.owner - val name = repository.name + val labelId = params("value").toInt - params.get("value").map(_.toInt) map { labelId => - params("checked").split(',') foreach { issueId => - getIssueLabel(owner, name, issueId.toInt, labelId) getOrElse { - registerIssueLabel(owner, name, issueId.toInt, labelId) - } + executeBatch(repository) { issueId => + getIssueLabel(repository.owner, repository.name, issueId, labelId) getOrElse { + registerIssueLabel(repository.owner, repository.name, issueId, labelId) } - redirect("/%s/%s/issues".format(owner, name)) - } getOrElse NotFound + } }) post("/:owner/:repository/issues/batchedit/assign")(collaboratorsOnly { repository => - params("checked").split(',') foreach { issueId => - updateAssignedUserName(repository.owner, repository.name, issueId.toInt, - params.get("value") filter (_.trim != "")) + val value = assignedUserName("value") + + executeBatch(repository) { + updateAssignedUserName(repository.owner, repository.name, _, value) } - redirect("/%s/%s/issues".format(repository.owner, repository.name)) }) post("/:owner/:repository/issues/batchedit/milestone")(collaboratorsOnly { repository => - params("checked").split(',') foreach { issueId => - updateMilestoneId(repository.owner, repository.name, issueId.toInt, - params.get("value") collect { case x if x.trim != "" => x.toInt }) + val value = milestoneId("value") + + executeBatch(repository) { + updateMilestoneId(repository.owner, repository.name, _, value) } - redirect("/%s/%s/issues".format(repository.owner, repository.name)) }) + val assignedUserName = (key: String) => params.get(key) filter (_.trim != "") + val milestoneId = (key: String) => params.get(key) collect { case x if x.trim != "" => x.toInt } + private def isEditable(owner: String, repository: String, author: String)(implicit context: app.Context): Boolean = hasWritePermission(owner, repository, context.loginAccount) || author == context.loginAccount.get.userName - private def handleComment(issueId: Int, content: Option[String], repository: RepositoryService.RepositoryInfo) = { + private def executeBatch(repository: RepositoryService.RepositoryInfo)(execute: Int => Unit) = { + params("checked").split(',') map(_.toInt) foreach execute + redirect("/%s/%s/issues".format(repository.owner, repository.name)) + } + + /** + * @see + */ + private def handleComment(issueId: Int, content: Option[String], repository: RepositoryService.RepositoryInfo) + (getAction: model.Issue => Option[String] = + p1 => params.get("action").filter(_ => isEditable(p1.userName, p1.repositoryName, p1.openedUserName))) = { val owner = repository.owner val name = repository.name val userName = context.loginAccount.get.userName getIssue(owner, name, issueId.toString) map { issue => val (action, recordActivity) = - params.get("action") - .filter(_ => isEditable(owner, name, issue.openedUserName)) + getAction(issue) .collect { - case s if s == "close" => true -> (Some(s) -> Some(recordCloseIssueActivity _)) - case s if s == "reopen" => false -> (Some(s) -> Some(recordReopenIssueActivity _)) + case "close" => true -> (Some("close") -> Some(recordCloseIssueActivity _)) + case "reopen" => false -> (Some("reopen") -> Some(recordReopenIssueActivity _)) } .map { case (closed, t) => updateClosed(owner, name, issueId, closed) @@ -277,14 +278,19 @@ } .getOrElse(None -> None) - val commentId = createComment(owner, name, userName, issueId, content.getOrElse(action.get.capitalize), action) + val commentId = content + .map ( _ -> action.map( _ + "_comment" ).getOrElse("comment") ) + .getOrElse ( action.get.capitalize -> action.get ) + match { + case (content, action) => createComment(owner, name, userName, issueId, content, action) + } // record activity content foreach ( recordCommentIssueActivity(owner, name, userName, issueId, _) ) recordActivity foreach ( _ (owner, name, userName, issueId, issue.title) ) - redirect("/%s/%s/issues/%d#comment-%d".format(owner, name, issueId, commentId)) - } getOrElse NotFound + commentId + } } private def searchIssues(filter: String, repository: RepositoryService.RepositoryInfo) = { diff --git a/src/main/scala/app/RepositoryViewerController.scala b/src/main/scala/app/RepositoryViewerController.scala index a70c254..43cb126 100644 --- a/src/main/scala/app/RepositoryViewerController.scala +++ b/src/main/scala/app/RepositoryViewerController.scala @@ -27,8 +27,7 @@ contentType = "text/html" view.helpers.markdown(params("content"), repository, params("enableWikiLink").toBoolean, - params("enableCommitLink").toBoolean, - params("enableIssueLink").toBoolean) + params("enableRefsLink").toBoolean) }) /** diff --git a/src/main/scala/model/IssueComment.scala b/src/main/scala/model/IssueComment.scala index 1084a33..f9fd983 100644 --- a/src/main/scala/model/IssueComment.scala +++ b/src/main/scala/model/IssueComment.scala @@ -9,9 +9,9 @@ def content = column[String]("CONTENT") def registeredDate = column[java.util.Date]("REGISTERED_DATE") def updatedDate = column[java.util.Date]("UPDATED_DATE") - def * = userName ~ repositoryName ~ issueId ~ commentId ~ action.? ~ commentedUserName ~ content ~ registeredDate ~ updatedDate <> (IssueComment, IssueComment.unapply _) + def * = userName ~ repositoryName ~ issueId ~ commentId ~ action ~ commentedUserName ~ content ~ registeredDate ~ updatedDate <> (IssueComment, IssueComment.unapply _) - def autoInc = userName ~ repositoryName ~ issueId ~ action.? ~ commentedUserName ~ content ~ registeredDate ~ updatedDate returning commentId + def autoInc = userName ~ repositoryName ~ issueId ~ action ~ commentedUserName ~ content ~ registeredDate ~ updatedDate returning commentId def byPrimaryKey(commentId: Int) = this.commentId is commentId.bind } @@ -20,7 +20,7 @@ repositoryName: String, issueId: Int, commentId: Int, - action: Option[String], + action: String, commentedUserName: String, content: String, registeredDate: java.util.Date, diff --git a/src/main/scala/service/IssuesService.scala b/src/main/scala/service/IssuesService.scala index 17f3c69..aef6436 100644 --- a/src/main/scala/service/IssuesService.scala +++ b/src/main/scala/service/IssuesService.scala @@ -102,7 +102,10 @@ // get issues and comment count val issues = searchIssueQuery(owner, repository, condition, filter, userName) .leftJoin(Query(IssueComments) - .filter { _.byRepository(owner, repository) } + .filter { t => + (t.byRepository(owner, repository)) && + (t.action inSetBind Seq("comment", "close_comment", "reopen_comment")) + } .groupBy { _.issueId } .map { case (issueId, t) => issueId ~ t.length }).on((t1, t2) => t1.issueId is t2._1) .sortBy { case (t1, t2) => @@ -192,7 +195,7 @@ IssueLabels filter(_.byPrimaryKey(owner, repository, issueId, labelId)) delete def createComment(owner: String, repository: String, loginUser: String, - issueId: Int, content: String, action: Option[String]) = + issueId: Int, content: String, action: String) = IssueComments.autoInc insert ( owner, repository, diff --git a/src/main/scala/service/RequestCache.scala b/src/main/scala/service/RequestCache.scala new file mode 100644 index 0000000..758c373 --- /dev/null +++ b/src/main/scala/service/RequestCache.scala @@ -0,0 +1,26 @@ +package service + +import model._ + +/** + * This service is used for a view helper mainly. + * + * It may be called many times in one request, so each method stores + * its result into the cache which available during a request. + */ +trait RequestCache { + + def getIssue(userName: String, repositoryName: String, issueId: String)(implicit context: app.Context): Option[Issue] = { + context.cache(s"issue.${userName}/${repositoryName}#${issueId}"){ + new IssuesService {}.getIssue(userName, repositoryName, issueId) + } + } + + def getAccountByUserName(userName: String)(implicit context: app.Context): Option[Account] = { + context.cache(s"account.${userName}"){ + new AccountService {}.getAccountByUserName(userName) + } + } + +} + diff --git a/src/main/scala/servlet/GitRepositoryServlet.scala b/src/main/scala/servlet/GitRepositoryServlet.scala index 55ce0e9..94c432b 100644 --- a/src/main/scala/servlet/GitRepositoryServlet.scala +++ b/src/main/scala/servlet/GitRepositoryServlet.scala @@ -85,7 +85,7 @@ "(^|\\W)#(\\d+)(\\W|$)".r.findAllIn(commit.fullMessage).matchData.foreach { matchData => val issueId = matchData.group(2) if(getAccountByUserName(commit.committer).isDefined && getIssue(owner, repository, issueId).isDefined){ - createComment(owner, repository, commit.committer, issueId.toInt, commit.fullMessage, Some("commit")) + createComment(owner, repository, commit.committer, issueId.toInt, commit.fullMessage, "commit") } } Some(commit) diff --git a/src/main/scala/util/Implicits.scala b/src/main/scala/util/Implicits.scala index 1de9e24..c475136 100644 --- a/src/main/scala/util/Implicits.scala +++ b/src/main/scala/util/Implicits.scala @@ -1,7 +1,7 @@ package util -import twirl.api.Html import scala.slick.driver.H2Driver.simple._ +import scala.util.matching.Regex /** * Provides some usable implicit conversions. @@ -30,4 +30,23 @@ def &&(c2: => Column[Boolean], guard: => Boolean): Column[Boolean] = if(guard) c1 && c2 else c1 } + implicit class RichString(value: String){ + def replaceBy(regex: Regex)(replace: Regex.MatchData => Option[String]): String = { + val sb = new StringBuilder() + var i = 0 + regex.findAllIn(value).matchData.foreach { m => + sb.append(value.substring(i, m.start)) + i = m.end + replace(m) match { + case Some(s) => sb.append(s) + case None => sb.append(m.matched) + } + } + if(i < value.length){ + sb.append(value.substring(i)) + } + sb.toString + } + } + } \ No newline at end of file diff --git a/src/main/scala/util/JGitUtil.scala b/src/main/scala/util/JGitUtil.scala index bf8c0a7..4f9091c 100644 --- a/src/main/scala/util/JGitUtil.scala +++ b/src/main/scala/util/JGitUtil.scala @@ -54,7 +54,7 @@ * @param id the commit id * @param time the commit time * @param committer the committer name - * @param mailAddress the committer's mail address + * @param mailAddress the mail address of the committer * @param shortMessage the short message * @param fullMessage the full message * @param parents the list of parent commit id @@ -63,9 +63,12 @@ shortMessage: String, fullMessage: String, parents: List[String]){ def this(rev: org.eclipse.jgit.revwalk.RevCommit) = this( - rev.getName, rev.getCommitterIdent.getWhen, - rev.getCommitterIdent.getName, rev.getCommitterIdent.getEmailAddress, - rev.getShortMessage, rev.getFullMessage, + rev.getName, + rev.getCommitterIdent.getWhen, + rev.getCommitterIdent.getName, + rev.getCommitterIdent.getEmailAddress, + rev.getShortMessage, + rev.getFullMessage, rev.getParents().map(_.name).toList) val summary = { diff --git a/src/main/scala/view/AvatarImageProvider.scala b/src/main/scala/view/AvatarImageProvider.scala new file mode 100644 index 0000000..c2c7fa2 --- /dev/null +++ b/src/main/scala/view/AvatarImageProvider.scala @@ -0,0 +1,31 @@ +package view + +import service.RequestCache +import twirl.api.Html +import util.StringUtil + +trait AvatarImageProvider { self: RequestCache => + + /** + * Returns <img> which displays the avatar icon. + * Looks up Gravatar if avatar icon has not been configured in user settings. + */ + protected def getAvatarImageHtml(userName: String, size: Int, + mailAddress: String = "", tooltip: Boolean = false)(implicit context: app.Context): Html = { + val src = getAccountByUserName(userName).collect { case account if(account.image.isEmpty) => + s"""http://www.gravatar.com/avatar/${StringUtil.md5(account.mailAddress)}?s=${size}""" + } getOrElse { + if(mailAddress.nonEmpty){ + s"""http://www.gravatar.com/avatar/${StringUtil.md5(mailAddress)}?s=${size}""" + } else { + s"""${context.path}/${userName}/_avatar""" + } + } + if(tooltip){ + Html(s"""""") + } else { + Html(s"""""") + } + } + +} \ No newline at end of file diff --git a/src/main/scala/view/LinkConverter.scala b/src/main/scala/view/LinkConverter.scala new file mode 100644 index 0000000..968f34f --- /dev/null +++ b/src/main/scala/view/LinkConverter.scala @@ -0,0 +1,33 @@ +package view + +import service.RequestCache +import util.Implicits.RichString + +trait LinkConverter { self: RequestCache => + + /** + * Converts issue id, username and commit id to link. + */ + protected def convertRefsLinks(value: String, repository: service.RepositoryService.RepositoryInfo, + issueIdPrefix: String = "#")(implicit context: app.Context): String = { + value + // escape HTML tags + .replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """) + // convert issue id to link + .replaceBy(("(^|\\W)" + issueIdPrefix + "(\\d+)(\\W|$)").r){ m => + if(getIssue(repository.owner, repository.name, m.group(2)).isDefined){ + Some(s"""${m.group(1)}#${m.group(2)}${m.group(3)}""") + } else { + Some(s"""${m.group(1)}#${m.group(2)}${m.group(3)}""") + } + } + // convert @username to link + .replaceBy("(^|\\W)@([a-zA-Z0-9\\-_]+)(\\W|$)".r){ m => + getAccountByUserName(m.group(2)).map { _ => + s"""${m.group(1)}@${m.group(2)}${m.group(3)}""" + } + } + // convert commit id to link + .replaceAll("(^|\\W)([a-f0-9]{40})(\\W|$)", s"""$$1$$2$$3""") + } +} diff --git a/src/main/scala/view/Markdown.scala b/src/main/scala/view/Markdown.scala index b90ba10..32cd513 100644 --- a/src/main/scala/view/Markdown.scala +++ b/src/main/scala/view/Markdown.scala @@ -6,6 +6,7 @@ import org.pegdown.ast._ import org.pegdown.LinkRenderer.Rendering import scala.collection.JavaConverters._ +import service.RequestCache object Markdown { @@ -13,12 +14,17 @@ * Converts Markdown of Wiki pages to HTML. */ def toHtml(markdown: String, repository: service.RepositoryService.RepositoryInfo, - enableWikiLink: Boolean, enableCommitLink: Boolean, enableIssueLink: Boolean)(implicit context: app.Context): String = { + enableWikiLink: Boolean, enableRefsLink: Boolean)(implicit context: app.Context): String = { + // escape issue id + val source = if(enableRefsLink){ + markdown.replaceAll("(^|\\W)#([0-9]+)(\\W|$)", "$1issue:$2$3") + } else markdown + val rootNode = new PegDownProcessor( Extensions.AUTOLINKS | Extensions.WIKILINKS | Extensions.FENCED_CODE_BLOCKS | Extensions.TABLES - ).parseMarkdown(markdown.toCharArray) + ).parseMarkdown(source.toCharArray) - new GitBucketHtmlSerializer(markdown, context, repository, enableWikiLink, enableCommitLink, enableIssueLink).toHtml(rootNode) + new GitBucketHtmlSerializer(markdown, repository, enableWikiLink, enableRefsLink).toHtml(rootNode) } } @@ -64,15 +70,13 @@ class GitBucketHtmlSerializer( markdown: String, - context: app.Context, repository: service.RepositoryService.RepositoryInfo, enableWikiLink: Boolean, - enableCommitLink: Boolean, - enableIssueLink: Boolean - ) extends ToHtmlSerializer( + enableRefsLink: Boolean + )(implicit val context: app.Context) extends ToHtmlSerializer( new GitBucketLinkRender(context, repository, enableWikiLink), Map[String, VerbatimSerializer](VerbatimSerializer.DEFAULT -> new GitBucketVerbatimSerializer).asJava - ) { + ) with LinkConverter with RequestCache { override protected def printImageTag(imageNode: SuperNode, url: String): Unit = printer.print("\"").printEncoded(printChildrenToString(imageNode)).print("\"/") @@ -100,10 +104,7 @@ override def visit(node: TextNode) { // convert commit id and username to link. - val text = if(enableCommitLink) node.getText - .replaceAll("(^|\\W)([0-9a-f]{40})(\\W|$)", s"""$$1$$2$$3""") - .replaceAll("(^|\\W)@([a-zA-Z0-9\\-_]+)(\\W|$)", s"""$$1@$$2$$3""") - else node.getText + val text = if(enableRefsLink) convertRefsLinks(node.getText, repository, "issue:") else node.getText if (abbreviations.isEmpty) { printer.print(text) @@ -112,15 +113,4 @@ } } - override def visit(node: HeaderNode) { - val text = markdown.substring(node.getStartIndex, node.getEndIndex - 1).trim - if(enableIssueLink && text.matches("#[\\d]+")){ - // convert issue id to link - val issueId = text.substring(1).toInt - printer.print(s"""#${issueId}""") - } else { - printTag(node, "h" + node.getLevel) - } - } - } \ No newline at end of file diff --git a/src/main/scala/view/helpers.scala b/src/main/scala/view/helpers.scala index 9b4f09c..b2648a0 100644 --- a/src/main/scala/view/helpers.scala +++ b/src/main/scala/view/helpers.scala @@ -4,11 +4,12 @@ import twirl.api.Html import util.StringUtil import service.AccountService +import service.RequestCache /** * Provides helper methods for Twirl templates. */ -object helpers { +object helpers extends AvatarImageProvider with LinkConverter with RequestCache { /** * Format java.util.Date to "yyyy-MM-dd HH:mm:ss". @@ -31,10 +32,26 @@ * Converts Markdown of Wiki pages to HTML. */ def markdown(value: String, repository: service.RepositoryService.RepositoryInfo, - enableWikiLink: Boolean, enableCommitLink: Boolean, enableIssueLink: Boolean)(implicit context: app.Context): Html = { - Html(Markdown.toHtml(value, repository, enableWikiLink, enableCommitLink, enableIssueLink)) + enableWikiLink: Boolean, enableRefsLink: Boolean)(implicit context: app.Context): Html = { + Html(Markdown.toHtml(value, repository, enableWikiLink, enableRefsLink)) } + /** + * Returns <img> which displays the avatar icon. + * Looks up Gravatar if avatar icon has not been configured in user settings. + */ + def avatar(userName: String, size: Int, tooltip: Boolean = false)(implicit context: app.Context): Html = + getAvatarImageHtml(userName, size, "", tooltip) + + def avatar(commit: util.JGitUtil.CommitInfo, size: Int)(implicit context: app.Context): Html = + getAvatarImageHtml(commit.committer, size, commit.mailAddress) + + /** + * Converts commit id, issue id and username to the link. + */ + def link(value: String, repository: service.RepositoryService.RepositoryInfo)(implicit context: app.Context): Html = + Html(convertRefsLinks(value, repository)) + def activityMessage(message: String)(implicit context: app.Context): Html = Html(message .replaceAll("\\[issue:([^\\s]+?)/([^\\s]+?)#((\\d+))\\]" , s"""$$1/$$2#$$3""") @@ -66,19 +83,6 @@ def assets(implicit context: app.Context): String = s"${context.path}/assets" - /** - * Converts issue id and commit id to link. - */ - def link(value: String, repository: service.RepositoryService.RepositoryInfo)(implicit context: app.Context): Html = - Html(value - // escape HTML tags - .replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """) - // convert issue id to link - .replaceAll("(^|\\W)#(\\d+)(\\W|$)", s"""$$1#$$2$$3""") - // convert commit id to link - .replaceAll("(^|\\W)([a-f0-9]{40})(\\W|$)", s"""$$1$$2$$3""")) - - def user(userName: String, mailAddress: String, styleClass: String = "")(implicit context: app.Context): Html = { val account = context.cache(s"account.${mailAddress}"){ new AccountService {}.getAccountByMailAddress(mailAddress) @@ -89,30 +93,6 @@ } /** - * Returns <img> which displays the avatar icon. - * Looks up Gravatar if avatar icon has not been configured in user settings. - */ - def avatar(userName: String, size: Int, tooltip: Boolean = false)(implicit context: app.Context): Html = { - val account = context.cache(s"account.${userName}"){ - if(userName.contains("@")){ - new AccountService {}.getAccountByMailAddress(userName) - } else { - new AccountService {}.getAccountByUserName(userName) - } - } - val src = account.collect { case account if(account.image.isEmpty) => - s"""http://www.gravatar.com/avatar/${StringUtil.md5(account.mailAddress)}?s=${size}""" - } getOrElse { - s"""${context.path}/${userName}/_avatar""" - } - if(tooltip){ - Html(s"""""") - } else { - Html(s"""""") - } - } - - /** * Implicit conversion to add mkHtml() to Seq[Html]. */ implicit class RichHtmlSeq(seq: Seq[Html]) { diff --git a/src/main/twirl/helper/preview.scala.html b/src/main/twirl/helper/preview.scala.html index 642c3bc..9c1a607 100644 --- a/src/main/twirl/helper/preview.scala.html +++ b/src/main/twirl/helper/preview.scala.html @@ -1,5 +1,5 @@ -@(repository: service.RepositoryService.RepositoryInfo, content: String, enableWikiLink: Boolean, - enableCommitLink: Boolean, enableIssueLink: Boolean, style: String = "", placeholder: String = "Leave a comment")(implicit context: app.Context) +@(repository: service.RepositoryService.RepositoryInfo, content: String, enableWikiLink: Boolean, enableRefsLink: Boolean, + style: String = "", placeholder: String = "Leave a comment")(implicit context: app.Context) @import context._ @import view.helpers._
@@ -30,10 +30,9 @@ $('#preview').click(function(){ $('#preview-area').html(' Previewing...'); $.post('@url(repository)/_preview', { - content : $('#content').val(), - enableWikiLink : @enableWikiLink, - enableCommitLink : @enableCommitLink, - enableIssueLink : @enableIssueLink + content : $('#content').val(), + enableWikiLink : @enableWikiLink, + enableRefsLink : @enableRefsLink }, function(data){ $('#preview-area').html(data); prettyPrint(); diff --git a/src/main/twirl/issues/create.scala.html b/src/main/twirl/issues/create.scala.html index 68d8eff..fec4193 100644 --- a/src/main/twirl/issues/create.scala.html +++ b/src/main/twirl/issues/create.scala.html @@ -43,7 +43,7 @@

- @helper.html.preview(repository, "", false, true, true, "width: 600px; height: 200px;") + @helper.html.preview(repository, "", false, true, "width: 600px; height: 200px;")
diff --git a/src/main/twirl/issues/issue.scala.html b/src/main/twirl/issues/issue.scala.html index c36405b..57edfcd 100644 --- a/src/main/twirl/issues/issue.scala.html +++ b/src/main/twirl/issues/issue.scala.html @@ -64,11 +64,12 @@
- @markdown(issue.content getOrElse "No description given.", repository, false, true, true) + @markdown(issue.content getOrElse "No description given.", repository, false, true)
@comments.map { comment => + @if(comment.action != "close" && comment.action != "reopen"){
@avatar(comment.commentedUserName, 48)
@@ -76,24 +77,26 @@ @comment.commentedUserName commented @datetime(comment.registeredDate) - @if(hasWritePermission || loginAccount.map(_.userName == comment.commentedUserName).getOrElse(false)){ + @if(comment.action != "commit" && (hasWritePermission || loginAccount.map(_.userName == comment.commentedUserName).getOrElse(false))){ }
- @markdown(comment.content, repository, false, true, true) + @markdown(comment.content, repository, false, true)
- @comment.action.map { action => + } + @if(comment.action == "close" || comment.action == "close_comment"){
- @if(action == "close"){ - Closed - @comment.commentedUserName closed the issue @datetime(comment.registeredDate) - } else { - Reopened - @comment.commentedUserName reopened the issue @datetime(comment.registeredDate) - } + Closed + @comment.commentedUserName closed the issue @datetime(comment.registeredDate) +
+ } + @if(comment.action == "reopen" || comment.action == "reopen_comment"){ +
+ Reopened + @comment.commentedUserName reopened the issue @datetime(comment.registeredDate)
} } @@ -102,7 +105,7 @@
@avatar(loginAccount.get.userName, 48)
- @helper.html.preview(repository, "", false, true, true, "width: 680px; height: 100px;") + @helper.html.preview(repository, "", false, true, "width: 680px; height: 100px;")
@@ -122,7 +125,9 @@ Open }
- @comments.size @plural(comments.size, "comment") + @defining(comments.filter( _.action.contains("comment") ).size){ count => + @count @plural(count, "comment") + }

diff --git a/src/main/twirl/issues/list.scala.html b/src/main/twirl/issues/list.scala.html index 16bb6cb..675c643 100644 --- a/src/main/twirl/issues/list.scala.html +++ b/src/main/twirl/issues/list.scala.html @@ -227,7 +227,7 @@ } @issue.assignedUserName.map { userName => - @avatar(userName, 20, true) + @avatar(userName, 20, tooltip = true) } #@issue.issueId @@ -247,6 +247,12 @@
+ @if(hasWritePermission){ +
+ + +
+ } } @if(hasWritePermission){ + - \ No newline at end of file + diff --git a/src/main/twirl/repo/blob.scala.html b/src/main/twirl/repo/blob.scala.html index cd47e59..c39c090 100644 --- a/src/main/twirl/repo/blob.scala.html +++ b/src/main/twirl/repo/blob.scala.html @@ -23,7 +23,7 @@
- @avatar(latestCommit.mailAddress, 20) + @avatar(latestCommit, 20) @user(latestCommit.committer, latestCommit.mailAddress, "username strong") @datetime(latestCommit.time) @link(latestCommit.summary, repository) diff --git a/src/main/twirl/repo/commit.scala.html b/src/main/twirl/repo/commit.scala.html index 5a05711..c14287b 100644 --- a/src/main/twirl/repo/commit.scala.html +++ b/src/main/twirl/repo/commit.scala.html @@ -43,7 +43,7 @@ - @avatar(commit.mailAddress, 20) + @avatar(commit, 20) @user(commit.committer, commit.mailAddress, "username strong") @datetime(commit.time)
diff --git a/src/main/twirl/repo/commits.scala.html b/src/main/twirl/repo/commits.scala.html index 7955c1e..ec73b70 100644 --- a/src/main/twirl/repo/commits.scala.html +++ b/src/main/twirl/repo/commits.scala.html @@ -38,18 +38,20 @@ Browse code
-
@avatar(commit.mailAddress, 40)
- @link(commit.summary, repository) - @if(commit.description.isDefined){ - ... - } -
- @if(commit.description.isDefined){ - - } -
- @user(commit.committer, commit.mailAddress, "username") - @datetime(commit.time) +
@avatar(commit, 40)
+
+ @link(commit.summary, repository) + @if(commit.description.isDefined){ + ... + } +
+ @if(commit.description.isDefined){ + + } +
+ @user(commit.committer, commit.mailAddress, "username") + @datetime(commit.time) +
diff --git a/src/main/twirl/repo/files.scala.html b/src/main/twirl/repo/files.scala.html index 1b69fe3..bfa9629 100644 --- a/src/main/twirl/repo/files.scala.html +++ b/src/main/twirl/repo/files.scala.html @@ -27,8 +27,6 @@ @link(latestCommit.summary, repository) @if(latestCommit.description.isDefined){ ... - } - @if(latestCommit.description.isDefined){ } @@ -36,7 +34,7 @@
- @avatar(latestCommit.mailAddress, 20) + @avatar(latestCommit, 20) @user(latestCommit.committer, latestCommit.mailAddress, "username strong") @datetime(latestCommit.time)
@@ -82,7 +80,7 @@ @readme.map { content =>
README.md
-
@markdown(content, repository, false, false, false)
+
@markdown(content, repository, false, false)
} } \ No newline at end of file diff --git a/src/main/twirl/repo/tab.scala.html b/src/main/twirl/repo/tab.scala.html index 8a1d859..01b2ada 100644 --- a/src/main/twirl/repo/tab.scala.html +++ b/src/main/twirl/repo/tab.scala.html @@ -30,9 +30,9 @@ Commits Tags@if(repository.tags.length > 0){ @repository.tags.length}
  • -
    - HTTP +
    +
  • diff --git a/src/main/twirl/wiki/edit.scala.html b/src/main/twirl/wiki/edit.scala.html index 3da2b6c..580c83b 100644 --- a/src/main/twirl/wiki/edit.scala.html +++ b/src/main/twirl/wiki/edit.scala.html @@ -23,7 +23,7 @@
    - @helper.html.preview(repository, page.map(_.content).getOrElse(""), true, false, false, "width: 900px; height: 400px;", "") + @helper.html.preview(repository, page.map(_.content).getOrElse(""), true, false, "width: 900px; height: 400px;", "") diff --git a/src/main/twirl/wiki/page.scala.html b/src/main/twirl/wiki/page.scala.html index 102454d..21a8a62 100644 --- a/src/main/twirl/wiki/page.scala.html +++ b/src/main/twirl/wiki/page.scala.html @@ -22,7 +22,7 @@
    - @markdown(page.content, repository, true, false, false) + @markdown(page.content, repository, true, false)
    Last edited by @page.committer at @datetime(page.time) diff --git a/src/main/twirl/wiki/tab.scala.html b/src/main/twirl/wiki/tab.scala.html index abca145..5cce0ad 100644 --- a/src/main/twirl/wiki/tab.scala.html +++ b/src/main/twirl/wiki/tab.scala.html @@ -6,9 +6,9 @@ Pages Wiki History
  • -
    - HTTP +
    +
  • diff --git a/src/main/webapp/assets/common/css/gitbucket.css b/src/main/webapp/assets/common/css/gitbucket.css index 580116d..6c525b3 100644 --- a/src/main/webapp/assets/common/css/gitbucket.css +++ b/src/main/webapp/assets/common/css/gitbucket.css @@ -335,6 +335,10 @@ margin-right: 4px; } +div.commit-message-box { + margin-left: 42px; +} + pre.commit-description { font-weight: normal; border: none; diff --git a/src/main/webapp/assets/common/js/gitbucket.js b/src/main/webapp/assets/common/js/gitbucket.js index c8160c9..7983c83 100644 --- a/src/main/webapp/assets/common/js/gitbucket.js +++ b/src/main/webapp/assets/common/js/gitbucket.js @@ -1,4 +1,5 @@ $(function(){ + // disable Ajax cache $.ajaxSetup({ cache: false }); $('#repository-url').click(function(){ @@ -8,5 +9,36 @@ $('img[data-toggle=tooltip]').tooltip(); $('a[data-toggle=tooltip]').tooltip(); + // copy to clipboard + (function() { + // Find ZeroClipboard.swf file URI from ZeroClipboard JavaScript file path. + // NOTE(tanacasino) I think this way is wrong... but i don't know correct way. + var moviePath = (function() { + var zclipjs = "ZeroClipboard.min.js"; + var scripts = document.getElementsByTagName("script"); + var i = scripts.length; + while(i--) { + var match = scripts[i].src.match(zclipjs + "$"); + if(match) { + return match.input.substr(0, match.input.length - 6) + 'swf'; + } + } + })(); + var clip = new ZeroClipboard($("#repository-url-copy"), { + moviePath: moviePath + }); + var title = $('#repository-url-copy').attr('title'); + $('#repository-url-copy').removeAttr('title') + clip.on('complete', function(client, args) { + $(clip.htmlBridge).attr('title', 'copied!').tooltip('fixTitle').tooltip('show'); + $(clip.htmlBridge).attr('title', title).tooltip('fixTitle'); + }); + $(clip.htmlBridge).tooltip({ + title: title, + placement: $('#repository-url-copy').attr('data-placement') + }); + })(); + + // syntax highlighting by google-code-prettify prettyPrint(); }); diff --git a/src/main/webapp/assets/zclip/ZeroClipboard.min.js b/src/main/webapp/assets/zclip/ZeroClipboard.min.js new file mode 100644 index 0000000..32535fd --- /dev/null +++ b/src/main/webapp/assets/zclip/ZeroClipboard.min.js @@ -0,0 +1,8 @@ +/*! + * zeroclipboard + * The Zero Clipboard library provides an easy way to copy text to the clipboard using an invisible Adobe Flash movie, and a JavaScript interface. + * Copyright 2012 Jon Rohan, James M. Greene, . + * Released under the MIT license + * http://jonrohan.github.com/ZeroClipboard/ + * v1.1.7 + */(function(){"use strict";var a=function(a,b){var c=a.style[b];a.currentStyle?c=a.currentStyle[b]:window.getComputedStyle&&(c=document.defaultView.getComputedStyle(a,null).getPropertyValue(b));if(c=="auto"&&b=="cursor"){var d=["a"];for(var e=0;e=0?"&":"?")+"nocache="+(new Date).getTime()},i=function(a){var b=[];return a.trustedDomains&&(typeof a.trustedDomains=="string"?b.push("trustedDomain="+a.trustedDomains):b.push("trustedDomain="+a.trustedDomains.join(","))),b.join("&")},j=function(a,b){if(b.indexOf)return b.indexOf(a);for(var c=0,d=b.length;c ';b=document.createElement("div"),b.id="global-zeroclipboard-html-bridge",b.setAttribute("class","global-zeroclipboard-container"),b.setAttribute("data-clipboard-ready",!1),b.style.position="absolute",b.style.left="-9999px",b.style.top="-9999px",b.style.width="15px",b.style.height="15px",b.style.zIndex="9999",b.innerHTML=c,document.body.appendChild(b)}a.htmlBridge=b,a.flashBridge=document["global-zeroclipboard-flash-bridge"]||b.children[0].lastElementChild};l.prototype.resetBridge=function(){this.htmlBridge.style.left="-9999px",this.htmlBridge.style.top="-9999px",this.htmlBridge.removeAttribute("title"),this.htmlBridge.removeAttribute("data-clipboard-text"),f(m,this.options.activeClass),m=null,this.options.text=null},l.prototype.ready=function(){var a=this.htmlBridge.getAttribute("data-clipboard-ready");return a==="true"||a===!0},l.prototype.reposition=function(){if(!m)return!1;var a=g(m);this.htmlBridge.style.top=a.top+"px",this.htmlBridge.style.left=a.left+"px",this.htmlBridge.style.width=a.width+"px",this.htmlBridge.style.height=a.height+"px",this.htmlBridge.style.zIndex=a.zIndex+1,this.setSize(a.width,a.height)},l.dispatch=function(a,b){l.prototype._singleton.receiveEvent(a,b)},l.prototype.on=function(a,b){var c=a.toString().split(/\s/g);for(var d=0;d