diff --git a/Src/VideoGameQuotes.Api/Search/IQuoteSearcher.cs b/Src/VideoGameQuotes.Api/Search/IQuoteSearcher.cs new file mode 100644 index 0000000..32bb933 --- /dev/null +++ b/Src/VideoGameQuotes.Api/Search/IQuoteSearcher.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; + +namespace VideoGameQuotes.Api.Search { + /// + /// Exposes an interface to search for quotes + /// + public interface IQuoteSearcher { + /// + /// Searches for quote based on the given search query + /// + IEnumerable Search(string query); + + /// + /// (Re)builds the search index + /// + /// Set to false to not overwrite the old index with a brand new one. + void BuildIndex(bool replaceOldIndex = true); + } +} \ No newline at end of file diff --git a/Src/VideoGameQuotes.Api/Search/ISearchIndexLocator.cs b/Src/VideoGameQuotes.Api/Search/ISearchIndexLocator.cs new file mode 100644 index 0000000..c12380c --- /dev/null +++ b/Src/VideoGameQuotes.Api/Search/ISearchIndexLocator.cs @@ -0,0 +1,15 @@ +namespace VideoGameQuotes.Api.Search { + public interface ISearchIndexLocator { + string IndexDirectory { get; } + } + + public class SearchIndexLocator : ISearchIndexLocator { + private readonly string indexDirectory; + + public SearchIndexLocator(string indexDirectory) { + this.indexDirectory = indexDirectory; + } + + public string IndexDirectory { get { return indexDirectory; } } + } +} \ No newline at end of file diff --git a/Src/VideoGameQuotes.Api/Search/Lucene/LuceneExtensions.cs b/Src/VideoGameQuotes.Api/Search/Lucene/LuceneExtensions.cs new file mode 100644 index 0000000..533e73c --- /dev/null +++ b/Src/VideoGameQuotes.Api/Search/Lucene/LuceneExtensions.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using Lucene.Net.Search; + +namespace VideoGameQuotes.Api.Search.Lucene { + public static class LuceneExtensions { + public static IEnumerable ToEnumerable(this Hits hits) { + var iterator = hits.Iterator(); + while (iterator.MoveNext()) { + yield return (Hit)iterator.Current; + } + } + } +} \ No newline at end of file diff --git a/Src/VideoGameQuotes.Api/Search/Lucene/LuceneQuoteSearcher.cs b/Src/VideoGameQuotes.Api/Search/Lucene/LuceneQuoteSearcher.cs new file mode 100644 index 0000000..a5333e6 --- /dev/null +++ b/Src/VideoGameQuotes.Api/Search/Lucene/LuceneQuoteSearcher.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; +using System.Linq; +using Lucene.Net.Analysis; +using Lucene.Net.Analysis.Standard; +using Lucene.Net.Documents; +using Lucene.Net.Index; +using Lucene.Net.QueryParsers; +using Lucene.Net.Search; +using Portoa.Persistence; + +namespace VideoGameQuotes.Api.Search.Lucene { + /// + /// implementation based on Lucene.NET + /// + public class LuceneQuoteSearcher : IQuoteSearcher { + private readonly ISearchIndexLocator indexLocator; + private readonly IRepository quoteRepository; + private readonly Analyzer analyzer = new StandardAnalyzer(); + private readonly QueryParser queryParser; + + public LuceneQuoteSearcher(ISearchIndexLocator indexLocator, IRepository quoteRepository) { + this.indexLocator = indexLocator; + this.quoteRepository = quoteRepository; + queryParser = new QueryParser("text", analyzer); + } + + [UnitOfWork] + public IEnumerable Search(string searchString) { + var query = queryParser.Parse(QueryParser.Escape(searchString)); + var searcher = new IndexSearcher(indexLocator.IndexDirectory); + return searcher + .Search(query) + .ToEnumerable() + .Select(hit => new SearchResult { + Score = hit.GetScore(), + Quote = quoteRepository.FindById(int.Parse(hit.GetDocument().GetField("id").StringValue())) + }); + } + + [UnitOfWork] + public void BuildIndex(bool replaceOldIndex = true) { + var indexWriter = new IndexWriter(indexLocator.IndexDirectory, analyzer, replaceOldIndex); + foreach (var quote in quoteRepository.Records) { + indexWriter.AddDocument(CreateDocument(quote)); + } + + indexWriter.Optimize(); + indexWriter.Close(); + } + + private static Document CreateDocument(Quote quote) { + var document = new Document(); + document.Add(new Field("id", quote.Id.ToString(), Field.Store.YES, Field.Index.NO)); + document.Add(new Field("text", quote.Text, Field.Store.YES, Field.Index.TOKENIZED)); + return document; + } + } +} \ No newline at end of file diff --git a/Src/VideoGameQuotes.Api/Search/SearchResult.cs b/Src/VideoGameQuotes.Api/Search/SearchResult.cs new file mode 100644 index 0000000..5d6c37c --- /dev/null +++ b/Src/VideoGameQuotes.Api/Search/SearchResult.cs @@ -0,0 +1,17 @@ +namespace VideoGameQuotes.Api.Search { + /// + /// Represents a search result + /// + public class SearchResult { + /// + /// A value (between 0 and 1, the higher the better) representing how good + /// the match is between the search query and the value + /// + public double Score { get; set; } + + /// + /// The matched quote + /// + public Quote Quote { get; set; } + } +} \ No newline at end of file diff --git a/Src/VideoGameQuotes.Api/VideoGameQuotes.Api.csproj b/Src/VideoGameQuotes.Api/VideoGameQuotes.Api.csproj index 010aea4..baf8322 100644 --- a/Src/VideoGameQuotes.Api/VideoGameQuotes.Api.csproj +++ b/Src/VideoGameQuotes.Api/VideoGameQuotes.Api.csproj @@ -37,6 +37,9 @@ ..\..\Lib\log4net.dll + + ..\..\Lib\Lucene.Net.dll + ..\..\Lib\MySql.Data.dll @@ -58,11 +61,7 @@ - - - - @@ -71,6 +70,10 @@ + + + + @@ -80,6 +83,7 @@ + diff --git a/Src/VideoGameQuotes.Web/Controllers/QuoteController.cs b/Src/VideoGameQuotes.Web/Controllers/QuoteController.cs index 0afc67b..82301b4 100644 --- a/Src/VideoGameQuotes.Web/Controllers/QuoteController.cs +++ b/Src/VideoGameQuotes.Web/Controllers/QuoteController.cs @@ -8,6 +8,7 @@ using Portoa.Persistence; using Portoa.Web.Controllers; using Portoa.Web.Results; using VideoGameQuotes.Api; +using VideoGameQuotes.Api.Search; using VideoGameQuotes.Web.Models; using VideoGameQuotes.Web.Security; using VideoGameQuotes.Web.Services; @@ -16,10 +17,12 @@ namespace VideoGameQuotes.Web.Controllers { public class QuoteController : Controller { private readonly IQuoteService quoteService; private readonly ICurrentUserProvider currentUserProvider; + private readonly IQuoteSearcher quoteSearcher; - public QuoteController(IQuoteService quoteService, ICurrentUserProvider currentUserProvider) { + public QuoteController(IQuoteService quoteService, ICurrentUserProvider currentUserProvider, IQuoteSearcher quoteSearcher) { this.quoteService = quoteService; this.currentUserProvider = currentUserProvider; + this.quoteSearcher = quoteSearcher; } public ActionResult Browse(BrowseModel model) { @@ -27,7 +30,10 @@ namespace VideoGameQuotes.Web.Controllers { return View("DefaultBrowse"); } - return View("QualifiedBrowse", new QualifiedBrowseModel(model) { Quotes = quoteService.GetBrowsableQuotes(model) }); + return View("QualifiedBrowse", new QualifiedBrowseModel(model) { + Quotes = quoteService.GetBrowsableQuotes(model), + User = currentUserProvider.CurrentUser + }); } [HttpPost, IsValidUser] @@ -237,5 +243,15 @@ namespace VideoGameQuotes.Web.Controllers { return Json(this.CreateJsonErrorResponse(e)); } } + + public ActionResult Search(string searchQuery) { + var model = new SearchModel { + User = currentUserProvider.CurrentUser, + Results = quoteSearcher.Search(searchQuery), + SearchQuery = searchQuery + }; + + return View(model); + } } } \ No newline at end of file diff --git a/Src/VideoGameQuotes.Web/Global.asax.cs b/Src/VideoGameQuotes.Web/Global.asax.cs index 4a9362b..3830ec4 100644 --- a/Src/VideoGameQuotes.Web/Global.asax.cs +++ b/Src/VideoGameQuotes.Web/Global.asax.cs @@ -1,12 +1,18 @@ -using System.Web.Mvc; +using System.Collections.Specialized; +using System.Configuration; +using System.Web.Mvc; using System.Web.Routing; using Microsoft.Practices.Unity; +using Portoa.Logging; +using Portoa.Persistence; using Portoa.Web; using Portoa.Web.Models; using Portoa.Web.Unity; using UnityGenerics; using VideoGameQuotes.Api; using VideoGameQuotes.Api.Persistence; +using VideoGameQuotes.Api.Search; +using VideoGameQuotes.Api.Search.Lucene; using VideoGameQuotes.Web.Controllers; using VideoGameQuotes.Web.Models; using VideoGameQuotes.Web.Security; @@ -36,23 +42,33 @@ namespace VideoGameQuotes.Web { .RegisterType() .RegisterType() .RegisterType() - .RegisterType(); + .RegisterType() + .RegisterType( + new ContainerControlledLifetimeManager(), + new InjectionFactory(container => new SearchIndexLocator(((NameValueCollection)ConfigurationManager.GetSection("vgquotes"))["luceneIndexDirectory"])) + ).RegisterType(); + } + + protected override void AfterStartUp() { + var logger = Container.Resolve(); + logger.Info("Building lucene index"); + Container.Resolve().BuildIndex(); + logger.Info("Done building lucene index"); } protected override void RegisterRoutes(RouteCollection routes) { routes.IgnoreRoute("{resource}.axd/{*pathInfo}"); routes.IgnoreRoute("media/{*anything}"); - routes.MapRoute("api", "api/{action}/{id}/{*criteria}", new { controller = "Api" }, new { action = "game|system|category|publisher|quote", id = @"\d+|all" }); - routes.MapRoute("home", "{action}", new { controller = "Home", action = "Index" }, new { action = "about|contact" }); - routes.MapRoute("best", "best/{start}-{end}/", new { controller = "Quote", action = "Best" }, new { start = @"\d+", end = @"\d+" }); routes.MapRoute("browse", "browse/{*qualifiers}", new { controller = "Quote", action = "Browse" }); - routes.MapRoute("quote", "{action}", new { controller = "Quote" }, new { action = "submit|search|recent|random|best|vote|report" }); + routes.MapRoute("search", "search/{*searchQuery}", new { controller = "Quote", action = "Search" }); + routes.MapRoute("quote", "{action}", new { controller = "Quote" }, new { action = "submit|recent|random|best|vote|report" }); routes.MapRoute("individual-quote", "quote/{id}/{*text}", new { controller = "Quote", action = "Quote" }, new { id = @"\d+" }); routes.MapRoute("create-category", "category/create", new { controller = "Quote", action = "CreateCategory" }); + routes.MapRoute("default", "{controller}", new { controller = "home", action = "index" }); } } } \ No newline at end of file diff --git a/Src/VideoGameQuotes.Web/Models/SearchModel.cs b/Src/VideoGameQuotes.Web/Models/SearchModel.cs new file mode 100644 index 0000000..7c237cf --- /dev/null +++ b/Src/VideoGameQuotes.Web/Models/SearchModel.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using VideoGameQuotes.Api; +using VideoGameQuotes.Api.Search; + +namespace VideoGameQuotes.Web.Models { + public class SearchModel { + public User User { get; set; } + public IEnumerable Results { get; set; } + public string SearchQuery { get; set; } + } +} \ No newline at end of file diff --git a/Src/VideoGameQuotes.Web/VideoGameQuotes.Web.csproj b/Src/VideoGameQuotes.Web/VideoGameQuotes.Web.csproj index 53e7486..b21afc8 100644 --- a/Src/VideoGameQuotes.Web/VideoGameQuotes.Web.csproj +++ b/Src/VideoGameQuotes.Web/VideoGameQuotes.Web.csproj @@ -94,6 +94,7 @@ + @@ -134,6 +135,7 @@ + diff --git a/Src/VideoGameQuotes.Web/Views/Quote/Search.aspx b/Src/VideoGameQuotes.Web/Views/Quote/Search.aspx new file mode 100644 index 0000000..5c0175b --- /dev/null +++ b/Src/VideoGameQuotes.Web/Views/Quote/Search.aspx @@ -0,0 +1,14 @@ +<%@ Page Title="" Language="C#" Inherits="System.Web.Mvc.ViewPage" MasterPageFile="~/Views/Shared/Site.Master" %> +<%@ Import Namespace="VideoGameQuotes.Web.Models" %> +Search<%= !string.IsNullOrEmpty(Model.SearchQuery) ? " - " + Model.SearchQuery : "" %> + +

+ Search results for: <%: Model.SearchQuery %> +

+ + <% + foreach (var result in Model.Results) { + Html.RenderPartial("SingleQuote", new QuoteModel {Quote = result.Quote, User = Model.User }); + } + %> +
\ No newline at end of file diff --git a/Src/VideoGameQuotes.Web/Views/Shared/Site.Master b/Src/VideoGameQuotes.Web/Views/Shared/Site.Master index 70108d4..cd39cf7 100644 --- a/Src/VideoGameQuotes.Web/Views/Shared/Site.Master +++ b/Src/VideoGameQuotes.Web/Views/Shared/Site.Master @@ -29,9 +29,9 @@
  • <%= Html.ActionLink("Submit", "submit", "Quote", null, new { title = "Submit a new quote" }) %>
  • <%= Html.ActionLink("About", "about", "Home", null, new { title = "About the site" })%>