diff --git a/src/main/scala/app/CreateRepositoryController.scala b/src/main/scala/app/CreateRepositoryController.scala index e10e5bb..1461a09 100644 --- a/src/main/scala/app/CreateRepositoryController.scala +++ b/src/main/scala/app/CreateRepositoryController.scala @@ -5,7 +5,6 @@ import service._ import java.io.File import org.eclipse.jgit.api.Git -import org.eclipse.jgit.lib._ import org.apache.commons.io._ import jp.sf.amateras.scalatra.forms._ diff --git a/src/main/scala/app/IndexController.scala b/src/main/scala/app/IndexController.scala index c16873b..664a55a 100644 --- a/src/main/scala/app/IndexController.scala +++ b/src/main/scala/app/IndexController.scala @@ -1,13 +1,26 @@ package app +import util._ import service._ +import jp.sf.amateras.scalatra.forms._ class IndexController extends IndexControllerBase with RepositoryService with AccountService with SystemSettingsService with ActivityService + with RepositorySearchService with IssuesService + with ReferrerAuthenticator trait IndexControllerBase extends ControllerBase { self: RepositoryService - with SystemSettingsService with ActivityService => - + with SystemSettingsService with ActivityService with RepositorySearchService + with ReferrerAuthenticator => + + val searchForm = mapping( + "query" -> trim(text(required)), + "owner" -> trim(text(required)), + "repository" -> trim(text(required)) + )(SearchForm.apply) + + case class SearchForm(query: String, owner: String, repository: String) + get("/"){ val loginAccount = context.loginAccount @@ -18,4 +31,31 @@ ) } -} \ No newline at end of file + post("/search", searchForm){ form => + redirect(s"${form.owner}/${form.repository}/search?q=${StringUtil.urlEncode(form.query)}") + } + + get("/:owner/:repository/search")(referrersOnly { repository => + val query = params("q").trim + val target = params.getOrElse("type", "code") + val page = try { + val i = params.getOrElse("page", "1").toInt + if(i <= 0) 1 else i + } catch { + case e: NumberFormatException => 1 + } + + target.toLowerCase match { + case "issue" => search.html.issues( + searchIssues(repository.owner, repository.name, query), + countFiles(repository.owner, repository.name, query), + query, page, repository) + + case _ => search.html.code( + searchFiles(repository.owner, repository.name, query), + countIssues(repository.owner, repository.name, query), + query, page, repository) + } + }) + +} diff --git a/src/main/scala/app/MilestonesController.scala b/src/main/scala/app/MilestonesController.scala index 55c60d0..f4ae4cb 100644 --- a/src/main/scala/app/MilestonesController.scala +++ b/src/main/scala/app/MilestonesController.scala @@ -3,7 +3,7 @@ import jp.sf.amateras.scalatra.forms._ import service._ -import util.{CollaboratorsAuthenticator, ReferrerAuthenticator, UsersAuthenticator} +import util.{CollaboratorsAuthenticator, ReferrerAuthenticator} class MilestonesController extends MilestonesControllerBase with MilestonesService with RepositoryService with AccountService diff --git a/src/main/scala/app/UserManagementController.scala b/src/main/scala/app/UserManagementController.scala index 66a6692..363744f 100644 --- a/src/main/scala/app/UserManagementController.scala +++ b/src/main/scala/app/UserManagementController.scala @@ -1,12 +1,9 @@ package app import service._ -import util.{FileUtil, AdminAuthenticator} +import util.AdminAuthenticator import util.StringUtil._ import jp.sf.amateras.scalatra.forms._ -import org.apache.commons.io.FileUtils -import util.Directory._ -import scala.Some class UserManagementController extends UserManagementControllerBase with AccountService with AdminAuthenticator diff --git a/src/main/scala/service/IssuesService.scala b/src/main/scala/service/IssuesService.scala index 33a8496..abb2358 100644 --- a/src/main/scala/service/IssuesService.scala +++ b/src/main/scala/service/IssuesService.scala @@ -6,6 +6,8 @@ import Q.interpolation import model._ +import util.Implicits._ +import util.StringUtil trait IssuesService { import IssuesService._ @@ -235,6 +237,52 @@ } .update (closed, currentDate) + /** + * Search issues by keyword. + * + * @param owner the repository owner + * @param repository the repository name + * @param query the keywords separated by whitespace. + * @return issues with comment count and matched content of issue or comment + */ + def searchIssuesByKeyword(owner: String, repository: String, query: String): List[(Issue, Int, String)] = { + import scala.slick.driver.H2Driver.likeEncode + val keywords = StringUtil.splitWords(query.toLowerCase) + + // Search Issue + val issues = Query(Issues).filter { t => + keywords.map { keyword => + (t.title.toLowerCase like (s"%${likeEncode(keyword)}%", '^')) || (t.content.toLowerCase like (s"%${likeEncode(keyword)}%", '^')) + } .reduceLeft(_ && _) + }.map { t => (t, 0, t.content) } + + // Search IssueComment + val comments = Query(IssueComments).innerJoin(Issues).on { case (t1, t2) => + t1.byIssue(t2.userName, t2.repositoryName, t2.issueId) + }.filter { case (t1, t2) => + keywords.map { query => + t1.content.toLowerCase like (s"%${likeEncode(query)}%", '^') + }.reduceLeft(_ && _) + }.map { case (t1, t2) => (t2, t1.commentId, t1.content) } + + // TODO Excludes some actions which should be ignored. + def getCommentCount(issue: Issue): Int = { + Query(IssueComments) + .filter(_.byIssue(issue.userName, issue.repositoryName, issue.issueId)) + .map(_.issueId) + .list.length + } + + issues.union(comments).sortBy { case (issue, commentId, _) => + issue.issueId ~ commentId + }.list.splitWith { case ((issue1, _, _), (issue2, _, _)) => + issue1.issueId == issue2.issueId + }.map { result => + val (issue, _, content) = result.head + (issue, getCommentCount(issue) , content) + }.toList + } + } object IssuesService { @@ -281,4 +329,5 @@ param(request, "sort", Seq("created", "comments", "updated")).getOrElse("created"), param(request, "direction", Seq("asc", "desc")).getOrElse("desc")) } + } diff --git a/src/main/scala/service/RepositorySearchService.scala b/src/main/scala/service/RepositorySearchService.scala new file mode 100644 index 0000000..669a36b --- /dev/null +++ b/src/main/scala/service/RepositorySearchService.scala @@ -0,0 +1,121 @@ +package service + +import model.Issue +import util.{FileUtil, StringUtil, JGitUtil} +import util.Directory._ +import model.Issue +import org.eclipse.jgit.revwalk.RevWalk +import org.eclipse.jgit.treewalk.TreeWalk +import scala.collection.mutable.ListBuffer +import org.eclipse.jgit.lib.FileMode +import org.eclipse.jgit.api.Git + +trait RepositorySearchService { self: IssuesService => + import RepositorySearchService._ + + def countIssues(owner: String, repository: String, query: String): Int = + searchIssuesByKeyword(owner, repository, query).length + + def searchIssues(owner: String, repository: String, query: String): List[IssueSearchResult] = + searchIssuesByKeyword(owner, repository, query).map { case (issue, commentCount, content) => + IssueSearchResult( + issue.issueId, + issue.title, + issue.openedUserName, + issue.registeredDate, + commentCount, + getHighlightText(content, query)._1) + } + + def countFiles(owner: String, repository: String, query: String): Int = + JGitUtil.withGit(getRepositoryDir(owner, repository)){ git => + searchRepositoryFiles(git, query).length + } + + def searchFiles(owner: String, repository: String, query: String): List[FileSearchResult] = + JGitUtil.withGit(getRepositoryDir(owner, repository)){ git => + val files = searchRepositoryFiles(git, query) + val commits = JGitUtil.getLatestCommitFromPaths(git, files.toList.map(_._1), "HEAD") + files.map { case (path, text) => + val (highlightText, lineNumber) = getHighlightText(text, query) + FileSearchResult( + path, + commits(path).getCommitterIdent.getWhen, + highlightText, + lineNumber) + } + } + + private def searchRepositoryFiles(git: Git, query: String): List[(String, String)] = { + val revWalk = new RevWalk(git.getRepository) + val objectId = git.getRepository.resolve("HEAD") + val revCommit = revWalk.parseCommit(objectId) + val treeWalk = new TreeWalk(git.getRepository) + treeWalk.setRecursive(true) + treeWalk.addTree(revCommit.getTree) + + val keywords = StringUtil.splitWords(query.toLowerCase) + val list = new ListBuffer[(String, String)] + + while (treeWalk.next()) { + if(treeWalk.getFileMode(0) != FileMode.TREE){ + JGitUtil.getContent(git, treeWalk.getObjectId(0), false).foreach { bytes => + if(FileUtil.isText(bytes)){ + val text = new String(bytes, "UTF-8") + val lowerText = text.toLowerCase + val indices = keywords.map(lowerText.indexOf _) + if(!indices.exists(_ < 0)){ + list.append((treeWalk.getPathString, text)) + } + } + } + } + } + treeWalk.release + revWalk.release + + list.toList + } + +} + +object RepositorySearchService { + + val CodeLimit = 10 + val IssueLimit = 10 + + def getHighlightText(content: String, query: String): (String, Int) = { + val keywords = StringUtil.splitWords(query.toLowerCase) + val lowerText = content.toLowerCase + val indices = keywords.map(lowerText.indexOf _) + + if(!indices.exists(_ < 0)){ + val lineNumber = content.substring(0, indices.min).split("\n").size - 1 + val highlightText = StringUtil.escapeHtml(content.split("\n").drop(lineNumber).take(5).mkString("\n")) + .replaceAll("(?i)(" + keywords.map("\\Q" + _ + "\\E").mkString("|") + ")", + "$1") + (highlightText, lineNumber + 1) + } else { + (content.split("\n").take(5).mkString("\n"), 1) + } + } + + case class SearchResult( + files : List[(String, String)], + issues: List[(Issue, Int, String)]) + + case class IssueSearchResult( + issueId: Int, + title: String, + openedUserName: String, + registeredDate: java.util.Date, + commentCount: Int, + highlightText: String) + + case class FileSearchResult( + path: String, + lastModified: java.util.Date, + highlightText: String, + highlightLineNumber: Int) + +} \ No newline at end of file diff --git a/src/main/scala/service/WikiService.scala b/src/main/scala/service/WikiService.scala index 918c583..fb01cad 100644 --- a/src/main/scala/service/WikiService.scala +++ b/src/main/scala/service/WikiService.scala @@ -6,7 +6,6 @@ import org.apache.commons.io.FileUtils import util.JGitUtil.DiffInfo import util.{Directory, JGitUtil} -import org.eclipse.jgit.lib.RepositoryBuilder import org.eclipse.jgit.treewalk.CanonicalTreeParser import java.util.concurrent.ConcurrentHashMap diff --git a/src/main/scala/util/Directory.scala b/src/main/scala/util/Directory.scala index f2b7f4c..754d737 100644 --- a/src/main/scala/util/Directory.scala +++ b/src/main/scala/util/Directory.scala @@ -1,8 +1,6 @@ package util import java.io.File -import org.eclipse.jgit.api.Git -import org.eclipse.jgit.lib.Ref /** * Provides directories used by GitBucket. diff --git a/src/main/scala/util/FileUtil.scala b/src/main/scala/util/FileUtil.scala index 01f8874..b56c7f4 100644 --- a/src/main/scala/util/FileUtil.scala +++ b/src/main/scala/util/FileUtil.scala @@ -1,6 +1,6 @@ package util -import org.apache.commons.io.{IOUtils, FileUtils, FilenameUtils} +import org.apache.commons.io.{IOUtils, FileUtils} import java.net.URLConnection import java.io.File import org.apache.commons.compress.archivers.zip.{ZipArchiveEntry, ZipArchiveOutputStream} diff --git a/src/main/scala/util/StringUtil.scala b/src/main/scala/util/StringUtil.scala index ae6d868..d8ab997 100644 --- a/src/main/scala/util/StringUtil.scala +++ b/src/main/scala/util/StringUtil.scala @@ -20,4 +20,9 @@ def urlDecode(value: String): String = URLDecoder.decode(value, "UTF-8") + def splitWords(value: String): Array[String] = value.split("[ \\t ]+") + + def escapeHtml(value: String): String = + value.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """) + } diff --git a/src/main/scala/util/Validations.scala b/src/main/scala/util/Validations.scala index 1d42d99..dd383e3 100644 --- a/src/main/scala/util/Validations.scala +++ b/src/main/scala/util/Validations.scala @@ -1,7 +1,6 @@ package util import jp.sf.amateras.scalatra.forms._ -import scala.Some trait Validations { diff --git a/src/main/twirl/helper/paginator.scala.html b/src/main/twirl/helper/paginator.scala.html index 4a37d81..0925004 100644 --- a/src/main/twirl/helper/paginator.scala.html +++ b/src/main/twirl/helper/paginator.scala.html @@ -1,32 +1,32 @@ @(page: Int, count: Int, limit: Int, width: Int, baseURL: String) -@defining(view.Pagination(page, count, service.IssuesService.IssueLimit, width)){ p => +@defining(view.Pagination(page, count, limit, width)){ p => @if(p.count > p.limit){