From cd31ba4a6ccd772eef05fcd98d8893132e31f5be Mon Sep 17 00:00:00 2001 From: Gary Sharp Date: Thu, 6 Feb 2014 16:11:45 +1100 Subject: [PATCH] Feature: Quick Search Device/Job/User Search refactoring. Quick-Search implemented. --- Disco.BI/BI/DeviceBI/Searching.cs | 76 ------ Disco.BI/BI/JobBI/Searching.cs | 90 ------- Disco.BI/BI/UserBI/Searching.cs | 62 ----- Disco.BI/Disco.BI.csproj | 5 +- .../BI/Search/UserSearchResultItem.cs | 13 - Disco.Models/Disco.Models.csproj | 8 +- .../Jobs/JobLists/JobTableItemModel.cs | 24 +- .../Searching}/DeviceSearchResultItem.cs | 16 +- .../Services/Searching/ISearchResultItem.cs | 16 ++ .../Services/Searching/JobSearchResultItem.cs | 23 ++ .../Searching/UserSearchResultItem.cs | 24 ++ Disco.Models/UI/Search/SearchQueryModel.cs | 4 +- Disco.Services/Disco.Services.csproj | 4 +- Disco.Services/Extensions/StringExtensions.cs | 107 ++++++++ Disco.Services/Jobs/JobExtensions.cs | 2 +- .../Jobs/JobLists/JobTableExtensions.cs | 4 +- .../Jobs/JobLists/ManagedJobList.cs | 4 +- Disco.Services/Searching/Search.cs | 247 ++++++++++++++++++ Disco.Services/Users/UserService.cs | 26 +- Disco.Web/App_Start/AppConfig.cs | 3 +- Disco.Web/App_Start/RouteConfig.cs | 3 +- Disco.Web/Areas/API/APIAreaRegistration.cs | 3 +- .../Controllers/DocumentTemplateController.cs | 6 +- .../Areas/API/Controllers/SearchController.cs | 50 ++++ .../Areas/API/Controllers/UserController.cs | 2 +- .../ImporterUndetectedDataIdLookupModel.cs | 14 +- Disco.Web/ClientSource/Scripts/Core.js | 65 ++++- Disco.Web/ClientSource/Scripts/Core.min.js | 2 +- .../ClientSource/Scripts/Core.min.js.map | 4 +- .../ClientSource/Scripts/Core/disco.uicore.js | 65 ++++- Disco.Web/ClientSource/Style/BundleSite.css | 20 +- .../ClientSource/Style/BundleSite.min.css | 2 +- Disco.Web/ClientSource/Style/Site.css | 19 ++ Disco.Web/ClientSource/Style/Site.less | 26 ++ Disco.Web/ClientSource/Style/Site.min.css | 2 +- .../ClientSource/Style/jQueryUIExtensions.css | 1 - .../Style/jQueryUIExtensions.less | 2 +- .../Style/jQueryUIExtensions.min.css | 2 +- Disco.Web/Controllers/DeviceController.cs | 2 +- Disco.Web/Controllers/JobController.cs | 20 +- Disco.Web/Controllers/SearchController.cs | 20 +- Disco.Web/Controllers/UserController.cs | 2 +- Disco.Web/Disco.Web.csproj | 2 + Disco.Web/Models/Search/QueryModel.cs | 2 +- Disco.Web/T4MVC.cs | 113 +++++++- Disco.Web/Views/Device/_DeviceTable.cshtml | 6 +- .../Views/Device/_DeviceTable.generated.cs | 17 +- Disco.Web/Views/Shared/_JobTableRender.cshtml | 4 +- .../Views/Shared/_JobTableRender.generated.cs | 72 ++--- Disco.Web/Views/Shared/_Layout.cshtml | 34 +-- Disco.Web/Views/Shared/_Layout.generated.cs | 170 ++++++------ Disco.Web/Views/User/_UserTable.cshtml | 2 +- Disco.Web/Views/User/_UserTable.generated.cs | 3 +- 53 files changed, 1045 insertions(+), 470 deletions(-) delete mode 100644 Disco.BI/BI/DeviceBI/Searching.cs delete mode 100644 Disco.BI/BI/JobBI/Searching.cs delete mode 100644 Disco.BI/BI/UserBI/Searching.cs delete mode 100644 Disco.Models/BI/Search/UserSearchResultItem.cs rename Disco.Models/{BI/Search => Services/Searching}/DeviceSearchResultItem.cs (59%) create mode 100644 Disco.Models/Services/Searching/ISearchResultItem.cs create mode 100644 Disco.Models/Services/Searching/JobSearchResultItem.cs create mode 100644 Disco.Models/Services/Searching/UserSearchResultItem.cs create mode 100644 Disco.Services/Extensions/StringExtensions.cs create mode 100644 Disco.Services/Searching/Search.cs create mode 100644 Disco.Web/Areas/API/Controllers/SearchController.cs diff --git a/Disco.BI/BI/DeviceBI/Searching.cs b/Disco.BI/BI/DeviceBI/Searching.cs deleted file mode 100644 index 5b6ea0e5..00000000 --- a/Disco.BI/BI/DeviceBI/Searching.cs +++ /dev/null @@ -1,76 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using Disco.Models.BI.Search; -using Disco.Models.Repository; -using Disco.Data.Repository; - -namespace Disco.BI.DeviceBI -{ - public static class Searching - { - private static List Search_SelectDeviceSearchResultItem(IQueryable Query, int? LimitCount = null) - { - if (LimitCount.HasValue) - Query = Query.Take(LimitCount.Value); - - return Query.Select(d => new DeviceSearchResultItem() - { - SerialNumber = d.SerialNumber, - AssetNumber = d.AssetNumber, - ComputerName = d.ComputerName, - DeviceModelDescription = d.DeviceModel.Description, - DeviceProfileDescription = d.DeviceProfile.Description, - DecommissionedDate = d.DecommissionedDate, - AssignedUserId = d.AssignedUserId, - AssignedUserDisplayName = d.AssignedUser.DisplayName, - JobCount = d.Jobs.Count() - }).ToList(); - } - - public static List Search(DiscoDataContext Database, string Term, int? LimitCount = null, bool SearchDetails = false) - { - IQueryable query; - - query = null; - - if (SearchDetails) - { - query = Database.Devices.Where(d => - d.AssetNumber.Contains(Term) || - d.ComputerName.Contains(Term) || - d.SerialNumber.Contains(Term) || - d.Location.Contains(Term) || - Term.Contains(d.SerialNumber) || - d.DeviceDetails.Any(dd => dd.Value.Contains(Term)) - ); - } - else - { - query = Database.Devices.Where(d => - d.AssetNumber.Contains(Term) || - d.ComputerName.Contains(Term) || - d.SerialNumber.Contains(Term) || - d.Location.Contains(Term) || - Term.Contains(d.SerialNumber)); - } - - return Search_SelectDeviceSearchResultItem(query, LimitCount); - } - - public static List SearchDeviceModel(DiscoDataContext Database, int DeviceModelId, int? LimitCount = null) - { - return Search_SelectDeviceSearchResultItem(Database.Devices.Where(d => d.DeviceModelId == DeviceModelId), LimitCount); - } - public static List SearchDeviceProfile(DiscoDataContext Database, int DeviceProfileId, int? LimitCount = null) - { - return Search_SelectDeviceSearchResultItem(Database.Devices.Where(d => d.DeviceProfileId == DeviceProfileId), LimitCount); - } - public static List SearchDeviceBatch(DiscoDataContext Database, int DeviceBatchId, int? LimitCount = null) - { - return Search_SelectDeviceSearchResultItem(Database.Devices.Where(d => d.DeviceBatchId == DeviceBatchId), LimitCount); - } - - } -} diff --git a/Disco.BI/BI/JobBI/Searching.cs b/Disco.BI/BI/JobBI/Searching.cs deleted file mode 100644 index e00ee3ef..00000000 --- a/Disco.BI/BI/JobBI/Searching.cs +++ /dev/null @@ -1,90 +0,0 @@ -using Disco.Data.Repository; -using Disco.Models.Repository; -using Disco.Models.Services.Jobs.JobLists; -using Disco.Services; -using System.Linq; - -namespace Disco.BI.JobBI -{ - public static class Searching - { - public static JobTableModel Search(DiscoDataContext Database, string Term, int? LimitCount = null, bool IncludeJobStatus = true, bool SearchDetails = false) - { - int termInt = default(int); - - IQueryable query = default(IQueryable); - - if (int.TryParse(Term, out termInt)) - { - // Term is a Number (int) - if (SearchDetails) - { - query = BuildJobTableModel(Database).Where(j => - j.Id == termInt || - j.DeviceHeldLocation.Contains(Term) || - j.Device.SerialNumber.Contains(Term) || - j.Device.AssetNumber.Contains(Term) || - j.User.Id == Term || - j.User.Surname.Contains(Term) || - j.User.GivenName.Contains(Term) || - j.User.DisplayName.Contains(Term) || - j.JobLogs.Any(jl => jl.Comments.Contains(Term)) || - j.JobAttachments.Any(ja => ja.Comments.Contains(Term))); - } - else - { - query = BuildJobTableModel(Database).Where(j => - j.Id == termInt || - j.DeviceHeldLocation.Contains(Term) || - j.Device.SerialNumber.Contains(Term) || - j.Device.AssetNumber.Contains(Term) || - j.User.Id == Term || - j.User.Surname.Contains(Term) || - j.User.GivenName.Contains(Term) || - j.User.DisplayName.Contains(Term)); - } - } - else - { - if (SearchDetails) - { - query = BuildJobTableModel(Database).Where(j => - j.DeviceHeldLocation.Contains(Term) || - j.Device.SerialNumber.Contains(Term) || - j.Device.AssetNumber.Contains(Term) || - j.User.Id == Term || - j.User.Surname.Contains(Term) || - j.User.GivenName.Contains(Term) || - j.User.DisplayName.Contains(Term) || - j.JobLogs.Any(jl => jl.Comments.Contains(Term)) || - j.JobAttachments.Any(ja => ja.Comments.Contains(Term))); - } - else - { - query = BuildJobTableModel(Database).Where(j => - j.DeviceHeldLocation.Contains(Term) || - j.Device.SerialNumber.Contains(Term) || - j.Device.AssetNumber.Contains(Term) || - j.User.Id == Term || - j.User.Surname.Contains(Term) || - j.User.GivenName.Contains(Term) || - j.User.DisplayName.Contains(Term)); - } - } - - if (LimitCount.HasValue) - query = query.Take(LimitCount.Value); - - JobTableModel model = new JobTableModel() { ShowStatus = IncludeJobStatus }; - model.Fill(Database, query, true); - - return model; - } - - public static IQueryable BuildJobTableModel(DiscoDataContext Database) - { - return Database.Jobs.Include("JobType").Include("Device").Include("User").Include("OpenedTechUser"); - } - - } -} diff --git a/Disco.BI/BI/UserBI/Searching.cs b/Disco.BI/BI/UserBI/Searching.cs deleted file mode 100644 index e4d80794..00000000 --- a/Disco.BI/BI/UserBI/Searching.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using Disco.Models.BI.Search; -using Disco.Models.Repository; -using Disco.Data.Repository; -using Disco.Services.Users; - -namespace Disco.BI.UserBI -{ - public static class Searching - { - - public static List SearchUpstream(string Term) - { - return Interop.ActiveDirectory.ActiveDirectory.SearchUsers(Term).Select(adU => adU.ToRepositoryUser()).ToList(); - } - - private static List Search_SelectUserSearchResultItems(IQueryable Query, int? LimitCount = null) - { - if (LimitCount.HasValue) - Query = Query.Take(LimitCount.Value); - - return Query.Select(u => new UserSearchResultItem() - { - Id = u.Id, - Surname = u.Surname, - GivenName = u.GivenName, - DisplayName = u.DisplayName, - AssignedDevicesCount = u.DeviceUserAssignments.Where(dua => !dua.UnassignedDate.HasValue).Count(), - JobCount = u.Jobs.Count() - }).ToList(); - } - - public static List Search(DiscoDataContext Database, string Term, int? LimitCount = null) - { - if (string.IsNullOrWhiteSpace(Term) || Term.Length < 2) - throw new ArgumentException("Search Term must contain at least two characters", "Term"); - - // Search Active Directory & Import Relevant Users - var adImportedUsers = Interop.ActiveDirectory.ActiveDirectory.SearchUsers(Term).Select(adU => adU.ToRepositoryUser()); - foreach (var adU in adImportedUsers) - { - var existingUser = Database.Users.Find(adU.Id); - if (existingUser != null) - existingUser.UpdateSelf(adU); - else - Database.Users.Add(adU); - Database.SaveChanges(); - UserService.InvalidateCachedUser(adU.Id); - } - - return Search_SelectUserSearchResultItems(Database.Users.Where(u => - u.Id.Contains(Term) || - u.Surname.Contains(Term) || - u.GivenName.Contains(Term) || - u.DisplayName.Contains(Term) - ), LimitCount); - } - } -} diff --git a/Disco.BI/Disco.BI.csproj b/Disco.BI/Disco.BI.csproj index d7b87a9b..4de17cfa 100644 --- a/Disco.BI/Disco.BI.csproj +++ b/Disco.BI/Disco.BI.csproj @@ -129,7 +129,6 @@ - @@ -196,10 +195,8 @@ - - @@ -254,7 +251,7 @@ - + diff --git a/Disco.Models/BI/Search/UserSearchResultItem.cs b/Disco.Models/BI/Search/UserSearchResultItem.cs deleted file mode 100644 index f686c187..00000000 --- a/Disco.Models/BI/Search/UserSearchResultItem.cs +++ /dev/null @@ -1,13 +0,0 @@ - -namespace Disco.Models.BI.Search -{ - public class UserSearchResultItem - { - public int AssignedDevicesCount { get; set; } - public string DisplayName { get; set; } - public string GivenName { get; set; } - public string Id { get; set; } - public int JobCount { get; set; } - public string Surname { get; set; } - } -} diff --git a/Disco.Models/Disco.Models.csproj b/Disco.Models/Disco.Models.csproj index 81ac4cbf..17e9cfa2 100644 --- a/Disco.Models/Disco.Models.csproj +++ b/Disco.Models/Disco.Models.csproj @@ -63,8 +63,6 @@ - - @@ -111,6 +109,10 @@ + + + + @@ -165,7 +167,7 @@ - + diff --git a/Disco.Models/Services/Jobs/JobLists/JobTableItemModel.cs b/Disco.Models/Services/Jobs/JobLists/JobTableItemModel.cs index 08a058bb..a450a8c8 100644 --- a/Disco.Models/Services/Jobs/JobLists/JobTableItemModel.cs +++ b/Disco.Models/Services/Jobs/JobLists/JobTableItemModel.cs @@ -1,22 +1,34 @@ -using System; +using Disco.Models.Services.Searching; +using System; namespace Disco.Models.Services.Jobs.JobLists { - public class JobTableItemModel + public class JobTableItemModel : JobSearchResultItem { - public int Id { get; set; } + public int JobId { get; set; } + + [Obsolete("Use [int] JobId instead")] + public override string Id + { + get + { + return this.JobId.ToString(); + } + set + { + base.Id = value; + this.JobId = int.Parse(value); + } + } public DateTime OpenedDate { get; set; } public DateTime? ClosedDate { get; set; } public string JobTypeId { get; set; } public string JobTypeDescription { get; set; } - public string DeviceSerialNumber { get; set; } public int? DeviceModelId { get; set; } public string DeviceModelDescription { get; set; } public int? DeviceProfileId { get; set; } public int? DeviceAddressId { get; set; } public string DeviceAddress { get; set; } - public string UserId { get; set; } - public string UserDisplayName { get; set; } public string OpenedTechUserId { get; set; } public string OpenedTechUserDisplayName { get; set; } public string StatusDescription { get; set; } diff --git a/Disco.Models/BI/Search/DeviceSearchResultItem.cs b/Disco.Models/Services/Searching/DeviceSearchResultItem.cs similarity index 59% rename from Disco.Models/BI/Search/DeviceSearchResultItem.cs rename to Disco.Models/Services/Searching/DeviceSearchResultItem.cs index f176fd33..9e328da6 100644 --- a/Disco.Models/BI/Search/DeviceSearchResultItem.cs +++ b/Disco.Models/Services/Searching/DeviceSearchResultItem.cs @@ -1,9 +1,20 @@ using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; -namespace Disco.Models.BI.Search +namespace Disco.Models.Services.Searching { - public class DeviceSearchResultItem + public class DeviceSearchResultItem : ISearchResultItem { + private const string type = "Device"; + + public string Id { get; set; } + public string Type { get { return type; } } + public string Description { get { return string.Format("{0} ({1})", this.Id, this.ComputerName); } } + public string ScoreValue { get { return string.Format("{0} {1} {2} {3}", this.Id, this.AssignedUserId, this.AssignedUserDisplayName, this.AssetNumber); } } + public string AssetNumber { get; set; } public string AssignedUserDescription { @@ -26,6 +37,5 @@ namespace Disco.Models.BI.Search public string DeviceProfileDescription { get; set; } public int JobCount { get; set; } public DateTime? DecommissionedDate { get; set; } - public string SerialNumber { get; set; } } } diff --git a/Disco.Models/Services/Searching/ISearchResultItem.cs b/Disco.Models/Services/Searching/ISearchResultItem.cs new file mode 100644 index 00000000..d42da955 --- /dev/null +++ b/Disco.Models/Services/Searching/ISearchResultItem.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Disco.Models.Services.Searching +{ + public interface ISearchResultItem + { + string Id { get; set; } + string Type { get; } + string Description { get; } + string ScoreValue { get; } + } +} diff --git a/Disco.Models/Services/Searching/JobSearchResultItem.cs b/Disco.Models/Services/Searching/JobSearchResultItem.cs new file mode 100644 index 00000000..8f7b643c --- /dev/null +++ b/Disco.Models/Services/Searching/JobSearchResultItem.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Disco.Models.Services.Searching +{ + public class JobSearchResultItem : ISearchResultItem + { + private const string type = "Job"; + + public virtual string Id { get; set; } + public string Type { get { return type; } } + public string Description { get { return string.Format("{0} ({1}; {2})", this.Id, this.UserId, this.DeviceSerialNumber); } } + public string ScoreValue { get { return string.Format("{0} {1} {2} {3}", this.Id, this.UserId, this.DeviceSerialNumber, this.UserDisplayName); } } + + public string DeviceSerialNumber { get; set; } + + public string UserId { get; set; } + public string UserDisplayName { get; set; } + } +} diff --git a/Disco.Models/Services/Searching/UserSearchResultItem.cs b/Disco.Models/Services/Searching/UserSearchResultItem.cs new file mode 100644 index 00000000..8e073111 --- /dev/null +++ b/Disco.Models/Services/Searching/UserSearchResultItem.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Disco.Models.Services.Searching +{ + public class UserSearchResultItem : ISearchResultItem + { + private const string type = "User"; + + public string Id { get; set; } + public string Type { get { return type; } } + public string Description { get { return string.Format("{0} ({1})", this.DisplayName, this.Id); } } + public string ScoreValue { get { return string.Format("{0} {1}", this.Id, this.DisplayName); } } + + public int AssignedDevicesCount { get; set; } + public string DisplayName { get; set; } + public string GivenName { get; set; } + public int JobCount { get; set; } + public string Surname { get; set; } + } +} diff --git a/Disco.Models/UI/Search/SearchQueryModel.cs b/Disco.Models/UI/Search/SearchQueryModel.cs index 470efdc6..64093a4d 100644 --- a/Disco.Models/UI/Search/SearchQueryModel.cs +++ b/Disco.Models/UI/Search/SearchQueryModel.cs @@ -1,5 +1,5 @@ -using Disco.Models.BI.Search; -using Disco.Models.Services.Jobs.JobLists; +using Disco.Models.Services.Jobs.JobLists; +using Disco.Models.Services.Searching; using System.Collections.Generic; namespace Disco.Models.UI.Search diff --git a/Disco.Services/Disco.Services.csproj b/Disco.Services/Disco.Services.csproj index 86ccf60e..076e2e52 100644 --- a/Disco.Services/Disco.Services.csproj +++ b/Disco.Services/Disco.Services.csproj @@ -173,6 +173,7 @@ + @@ -230,6 +231,7 @@ + @@ -278,7 +280,7 @@ - + diff --git a/Disco.Services/Extensions/StringExtensions.cs b/Disco.Services/Extensions/StringExtensions.cs new file mode 100644 index 00000000..34c000cf --- /dev/null +++ b/Disco.Services/Extensions/StringExtensions.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Disco +{ + public static class StringExtensions + { + /// + /// A fuzzy string search algorithm. + /// + /// Based on: ScoreSharp (https://github.com/bltavares/scoresharp) + /// Based on: string_score from Joshaven Potter (https://github.com/joshaven/string_score) + /// + /// MIT License + /// + public static double Score(this string Source, string Test, double Fuzziness = 0) + { + double total_char_score = 0, abbrv_size = Test.Length, + fuzzies = 1, final_score, abbrv_score; + int word_size = Source.Length; + bool start_of_word_bonus = false; + + //If strings are equal, return 1.0 + if (Source == Test) return 1.0; + + int index_in_string, + index_char_lowercase, + index_char_uppercase, + min_index; + double char_score; + string c; + for (int i = 0; i < abbrv_size; i++) + { + c = Test[i].ToString(); + index_char_uppercase = Source.IndexOf(c.ToUpper()); + index_char_lowercase = Source.IndexOf(c.ToLower()); + min_index = Math.Min(index_char_lowercase, index_char_uppercase); + + //Finds first valid occurrence + //In upper or lowercase + index_in_string = min_index > -1 ? + min_index : Math.Max(index_char_lowercase, index_char_uppercase); + + //If no value is found + //Check if fuzziness is allowed + if (index_in_string == -1) + { + if (Fuzziness > 0) + { + fuzzies += 1 - Fuzziness; + continue; + } + else return 0; + } + else + char_score = 0.1; + + //Check if current char is the same case + //Then add bonus + if (Source[index_in_string].ToString() == c) char_score += 0.1; + + //Check if char matches the first letter + //And add bonus for consecutive letters + if (index_in_string == 0) + { + char_score += 0.6; + + //Check if the abbreviation + //is in the start of the word + start_of_word_bonus = i == 0; + } + else + { + // Acronym Bonus + // Weighing Logic: Typing the first character of an acronym is as if you + // preceded it with two perfect character matches. + if (Source.ElementAtOrDefault(index_in_string - 1).ToString() == " ") char_score += 0.8; + } + + + //Remove the start of string, so we don't reprocess it + Source = Source.Substring(index_in_string + 1); + + //sum chars scores + total_char_score += char_score; + } + + abbrv_score = total_char_score / abbrv_size; + + //Reduce penalty for longer words + final_score = ((abbrv_score * (abbrv_size / word_size)) + abbrv_score) / 2; + + //Reduce using fuzzies; + final_score = final_score / fuzzies; + + //Process start of string bonus + if (start_of_word_bonus && final_score <= 0.85) + final_score += 0.15; + + return final_score; + } + + } +} diff --git a/Disco.Services/Jobs/JobExtensions.cs b/Disco.Services/Jobs/JobExtensions.cs index 245265dc..ae59d952 100644 --- a/Disco.Services/Jobs/JobExtensions.cs +++ b/Disco.Services/Jobs/JobExtensions.cs @@ -14,7 +14,7 @@ namespace Disco.Services { var i = new JobTableStatusItemModel() { - Id = j.Id, + JobId = j.Id, OpenedDate = j.OpenedDate, ClosedDate = j.ClosedDate, JobTypeId = j.JobTypeId, diff --git a/Disco.Services/Jobs/JobLists/JobTableExtensions.cs b/Disco.Services/Jobs/JobLists/JobTableExtensions.cs index c9fe917f..064fc500 100644 --- a/Disco.Services/Jobs/JobLists/JobTableExtensions.cs +++ b/Disco.Services/Jobs/JobLists/JobTableExtensions.cs @@ -120,7 +120,7 @@ namespace Disco.Services var jobItems = Jobs.Select(j => new JobTableStatusItemModel() { - Id = j.Id, + JobId = j.Id, OpenedDate = j.OpenedDate, ClosedDate = j.ClosedDate, JobTypeId = j.JobTypeId, @@ -177,7 +177,7 @@ namespace Disco.Services { items = Jobs.Select(j => new JobTableItemModel() { - Id = j.Id, + JobId = j.Id, OpenedDate = j.OpenedDate, ClosedDate = j.ClosedDate, JobTypeId = j.JobTypeId, diff --git a/Disco.Services/Jobs/JobLists/ManagedJobList.cs b/Disco.Services/Jobs/JobLists/ManagedJobList.cs index e964ffaa..bd4860cf 100644 --- a/Disco.Services/Jobs/JobLists/ManagedJobList.cs +++ b/Disco.Services/Jobs/JobLists/ManagedJobList.cs @@ -217,7 +217,7 @@ using Disco.Services.Authorization; if (existingItems == null) throw new InvalidOperationException("Notification algorithm didn't indicate any Jobs for update"); else - jobIds = existingItems.Select(i => i.Id).ToList(); + jobIds = existingItems.Select(i => i.JobId).ToList(); } if (jobIds.Count == 0) @@ -232,7 +232,7 @@ using Disco.Services.Authorization; { // Check for existing items, if not handed them if (existingItems == null) - existingItems = base.Items.Where(i => jobIds.Contains(i.Id)).ToArray(); + existingItems = base.Items.Where(i => jobIds.Contains(i.JobId)).ToArray(); var updatedItems = this.DetermineItems(Database, this.FilterFunction(Database.Jobs.Where(j => jobIds.Contains(j.Id))), false); diff --git a/Disco.Services/Searching/Search.cs b/Disco.Services/Searching/Search.cs new file mode 100644 index 00000000..3149e810 --- /dev/null +++ b/Disco.Services/Searching/Search.cs @@ -0,0 +1,247 @@ +using Disco.Data.Repository; +using Disco.Models.Interop.ActiveDirectory; +using Disco.Models.Repository; +using Disco.Models.Services.Jobs.JobLists; +using Disco.Models.Services.Searching; +using Disco.Services.Users; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Disco.Services.Searching +{ + public static class Search + { + + #region Jobs + public static List SearchJobs(DiscoDataContext Database, string Term, int? LimitCount = null) + { + int termInt = default(int); + + IQueryable query = default(IQueryable); + + if (int.TryParse(Term, out termInt)) + { + // Term is a Number (int) + query = Database.Jobs.Where(j => + j.Id == termInt || + j.Device.SerialNumber.Contains(Term) || + j.Device.AssetNumber.Contains(Term) || + j.User.Id == Term || + j.User.DisplayName.Contains(Term)); + } + else + { + query = Database.Jobs.Where(j => + j.Device.SerialNumber.Contains(Term) || + j.Device.AssetNumber.Contains(Term) || + j.User.Id == Term || + j.User.DisplayName.Contains(Term)); + } + + if (LimitCount.HasValue) + query = query.Take(LimitCount.Value); + + return query.Select(j => new + { + Id = j.Id, + DeviceSerialNumber = j.DeviceSerialNumber, + UserId = j.UserId, + UserDisplayName = j.User.DisplayName + }).ToArray().Select(i => new JobSearchResultItem() + { + Id = i.Id.ToString(), + DeviceSerialNumber = i.DeviceSerialNumber, + UserId = i.UserId, + UserDisplayName = i.UserDisplayName + }).ToList(); + } + + public static JobTableModel SearchJobsTable(DiscoDataContext Database, string Term, int? LimitCount = null, bool IncludeJobStatus = true, bool SearchDetails = false) + { + int termInt = default(int); + + IQueryable query = default(IQueryable); + + if (int.TryParse(Term, out termInt)) + { + // Term is a Number (int) + if (SearchDetails) + { + query = BuildJobTableModel(Database).Where(j => + j.Id == termInt || + j.DeviceHeldLocation.Contains(Term) || + j.Device.SerialNumber.Contains(Term) || + j.Device.AssetNumber.Contains(Term) || + j.User.Id == Term || + j.User.Surname.Contains(Term) || + j.User.GivenName.Contains(Term) || + j.User.DisplayName.Contains(Term) || + j.JobLogs.Any(jl => jl.Comments.Contains(Term)) || + j.JobAttachments.Any(ja => ja.Comments.Contains(Term))); + } + else + { + query = BuildJobTableModel(Database).Where(j => + j.Id == termInt || + j.DeviceHeldLocation.Contains(Term) || + j.Device.SerialNumber.Contains(Term) || + j.Device.AssetNumber.Contains(Term) || + j.User.Id == Term || + j.User.Surname.Contains(Term) || + j.User.GivenName.Contains(Term) || + j.User.DisplayName.Contains(Term)); + } + } + else + { + if (SearchDetails) + { + query = BuildJobTableModel(Database).Where(j => + j.DeviceHeldLocation.Contains(Term) || + j.Device.SerialNumber.Contains(Term) || + j.Device.AssetNumber.Contains(Term) || + j.User.Id == Term || + j.User.Surname.Contains(Term) || + j.User.GivenName.Contains(Term) || + j.User.DisplayName.Contains(Term) || + j.JobLogs.Any(jl => jl.Comments.Contains(Term)) || + j.JobAttachments.Any(ja => ja.Comments.Contains(Term))); + } + else + { + query = BuildJobTableModel(Database).Where(j => + j.DeviceHeldLocation.Contains(Term) || + j.Device.SerialNumber.Contains(Term) || + j.Device.AssetNumber.Contains(Term) || + j.User.Id == Term || + j.User.Surname.Contains(Term) || + j.User.GivenName.Contains(Term) || + j.User.DisplayName.Contains(Term)); + } + } + + if (LimitCount.HasValue) + query = query.Take(LimitCount.Value); + + JobTableModel model = new JobTableModel() { ShowStatus = IncludeJobStatus }; + model.Fill(Database, query, true); + + return model; + } + + public static IQueryable BuildJobTableModel(DiscoDataContext Database) + { + return Database.Jobs.Include("JobType").Include("Device").Include("User").Include("OpenedTechUser"); + } + #endregion + + #region Users + public static List SearchUsers(DiscoDataContext Database, string Term, int? LimitCount = null) + { + if (string.IsNullOrWhiteSpace(Term) || Term.Length < 2) + throw new ArgumentException("Search Term must contain at least two characters", "Term"); + + // Search Active Directory & Import Relevant Users + UserService.SearchUsers(Database, Term); + + var matches = Database.Users.Where(u => + u.Id.Contains(Term) || + u.Surname.Contains(Term) || + u.GivenName.Contains(Term) || + u.DisplayName.Contains(Term) + ); + + if (LimitCount.HasValue) + matches = matches.Take(LimitCount.Value); + + return matches.Select(u => new UserSearchResultItem() + { + Id = u.Id, + Surname = u.Surname, + GivenName = u.GivenName, + DisplayName = u.DisplayName, + AssignedDevicesCount = u.DeviceUserAssignments.Where(dua => !dua.UnassignedDate.HasValue).Count(), + JobCount = u.Jobs.Count() + }).ToList(); + } + + public static List SearchUsersUpstream(string Term, int? LimitCount = null) + { + IEnumerable matches = UserService.SearchUsers(Term); + + if (LimitCount.HasValue) + matches = matches.Take(LimitCount.Value); + + return matches.Select(m => m.ToRepositoryUser()).ToList(); + } + #endregion + + #region Devices + public static List SearchDevices(DiscoDataContext Database, string Term, int? LimitCount = null, bool SearchDetails = false) + { + IQueryable query; + + query = null; + + if (SearchDetails) + { + query = Database.Devices.Where(d => + d.AssetNumber.Contains(Term) || + d.ComputerName.Contains(Term) || + d.SerialNumber.Contains(Term) || + d.Location.Contains(Term) || + Term.Contains(d.SerialNumber) || + d.DeviceDetails.Any(dd => dd.Value.Contains(Term)) + ); + } + else + { + query = Database.Devices.Where(d => + d.AssetNumber.Contains(Term) || + d.ComputerName.Contains(Term) || + d.SerialNumber.Contains(Term) || + d.Location.Contains(Term) || + Term.Contains(d.SerialNumber)); + } + + return query.ToDeviceSearchResultItems(LimitCount); + } + + public static List SearchDeviceModel(DiscoDataContext Database, int DeviceModelId, int? LimitCount = null) + { + return Database.Devices.Where(d => d.DeviceModelId == DeviceModelId).ToDeviceSearchResultItems(LimitCount); + } + public static List SearchDeviceProfile(DiscoDataContext Database, int DeviceProfileId, int? LimitCount = null) + { + return Database.Devices.Where(d => d.DeviceProfileId == DeviceProfileId).ToDeviceSearchResultItems(LimitCount); + } + public static List SearchDeviceBatch(DiscoDataContext Database, int DeviceBatchId, int? LimitCount = null) + { + return Database.Devices.Where(d => d.DeviceBatchId == DeviceBatchId).ToDeviceSearchResultItems(LimitCount); + } + + private static List ToDeviceSearchResultItems(this IQueryable Query, int? LimitCount = null) + { + if (LimitCount.HasValue) + Query = Query.Take(LimitCount.Value); + + return Query.Select(d => new DeviceSearchResultItem() + { + Id = d.SerialNumber, + AssetNumber = d.AssetNumber, + ComputerName = d.ComputerName, + DeviceModelDescription = d.DeviceModel.Description, + DeviceProfileDescription = d.DeviceProfile.Description, + DecommissionedDate = d.DecommissionedDate, + AssignedUserId = d.AssignedUserId, + AssignedUserDisplayName = d.AssignedUser.DisplayName, + JobCount = d.Jobs.Count() + }).ToList(); + } + #endregion + + } +} diff --git a/Disco.Services/Users/UserService.cs b/Disco.Services/Users/UserService.cs index 8238a410..c6d327dd 100644 --- a/Disco.Services/Users/UserService.cs +++ b/Disco.Services/Users/UserService.cs @@ -22,13 +22,16 @@ namespace Disco.Services.Users private const string _cacheHttpRequestKey = "Disco_CurrentUserToken"; private static Func _GetActiveDirectoryUserAccount; private static Func _GetActiveDirectoryMachineAccount; + private static Func> _SearchActiveDirectoryUsers; public static void Initialize(DiscoDataContext Database, Func GetActiveDirectoryUserAccount, - Func GetActiveDirectoryMachineAccount) + Func GetActiveDirectoryMachineAccount, + Func> SearchActiveDirectoryUsers) { _GetActiveDirectoryUserAccount = GetActiveDirectoryUserAccount; _GetActiveDirectoryMachineAccount = GetActiveDirectoryMachineAccount; + _SearchActiveDirectoryUsers = SearchActiveDirectoryUsers; Authorization.Roles.RoleCache.Initialize(Database); } @@ -201,6 +204,27 @@ namespace Disco.Services.Users Cache.FlushCache(); } + internal static List SearchUsers(string Term) + { + return _SearchActiveDirectoryUsers(Term); + } + + internal static List SearchUsers(DiscoDataContext Database, string Term) + { + var adImportedUsers = SearchUsers(Term); + foreach (var adU in adImportedUsers.Select(adU => adU.ToRepositoryUser())) + { + var existingUser = Database.Users.Find(adU.Id); + if (existingUser != null) + existingUser.UpdateSelf(adU); + else + Database.Users.Add(adU); + Database.SaveChanges(); + UserService.InvalidateCachedUser(adU.Id); + } + return adImportedUsers; + } + internal static Tuple ImportUser(DiscoDataContext Database, string UserId) { if (_GetActiveDirectoryUserAccount == null) diff --git a/Disco.Web/App_Start/AppConfig.cs b/Disco.Web/App_Start/AppConfig.cs index e281c96c..44bc53e9 100644 --- a/Disco.Web/App_Start/AppConfig.cs +++ b/Disco.Web/App_Start/AppConfig.cs @@ -45,7 +45,8 @@ namespace Disco.Web // Initialize User Service Interop Disco.Services.Users.UserService.Initialize(Database, (UserId, AdditionalProperties) => Disco.BI.Interop.ActiveDirectory.ActiveDirectory.GetUserAccount(UserId, AdditionalProperties), - (UserId, AdditionalProperties) => Disco.BI.Interop.ActiveDirectory.ActiveDirectory.GetMachineAccount(UserId, null, null, AdditionalProperties)); + (UserId, AdditionalProperties) => Disco.BI.Interop.ActiveDirectory.ActiveDirectory.GetMachineAccount(UserId, null, null, AdditionalProperties), + (Term) => Disco.BI.Interop.ActiveDirectory.ActiveDirectory.SearchUsers(Term)); } diff --git a/Disco.Web/App_Start/RouteConfig.cs b/Disco.Web/App_Start/RouteConfig.cs index 32d9bece..df557198 100644 --- a/Disco.Web/App_Start/RouteConfig.cs +++ b/Disco.Web/App_Start/RouteConfig.cs @@ -37,7 +37,8 @@ namespace Disco.Web routes.MapRoute( name: "SearchQuery", url: "Search/Query/{SearchQuery}", - defaults: new { controller = "Search", action = "Query", SearchQuery = UrlParameter.Optional } + defaults: new { controller = "Search", action = "Query", SearchQuery = UrlParameter.Optional }, + namespaces: new string[] { "Disco.Web.Controllers" } // Controllers Namespace Only ); // User Route routes.MapRoute( diff --git a/Disco.Web/Areas/API/APIAreaRegistration.cs b/Disco.Web/Areas/API/APIAreaRegistration.cs index 7e4ce8a8..da471ee0 100644 --- a/Disco.Web/Areas/API/APIAreaRegistration.cs +++ b/Disco.Web/Areas/API/APIAreaRegistration.cs @@ -35,7 +35,8 @@ namespace Disco.Web.Areas.API context.MapRoute( "API_default", "API/{controller}/{action}/{id}", - new { id = UrlParameter.Optional } + new { id = UrlParameter.Optional }, + new string[] { "Disco.Web.Areas.API.Controllers" } ); } } diff --git a/Disco.Web/Areas/API/Controllers/DocumentTemplateController.cs b/Disco.Web/Areas/API/Controllers/DocumentTemplateController.cs index 9d5ade8c..8c7023cf 100644 --- a/Disco.Web/Areas/API/Controllers/DocumentTemplateController.cs +++ b/Disco.Web/Areas/API/Controllers/DocumentTemplateController.cs @@ -324,13 +324,13 @@ namespace Disco.Web.Areas.API.Controllers switch (searchScope) { case DocumentTemplate.DocumentTemplateScopes.Device: - results = BI.DeviceBI.Searching.Search(Database, term, limitCount).Select(sr => Models.DocumentTemplate.ImporterUndetectedDataIdLookupModel.FromSearchResultItem(sr)).ToArray(); + results = Disco.Services.Searching.Search.SearchDevices(Database, term, limitCount).Select(sr => Models.DocumentTemplate.ImporterUndetectedDataIdLookupModel.FromSearchResultItem(sr)).ToArray(); break; case DocumentTemplate.DocumentTemplateScopes.Job: - results = BI.JobBI.Searching.Search(Database, term, limitCount, false).Items.Select(sr => Models.DocumentTemplate.ImporterUndetectedDataIdLookupModel.FromSearchResultItem(sr)).ToArray(); + results = Disco.Services.Searching.Search.SearchJobsTable(Database, term, limitCount, false).Items.Select(sr => Models.DocumentTemplate.ImporterUndetectedDataIdLookupModel.FromSearchResultItem(sr)).ToArray(); break; case DocumentTemplate.DocumentTemplateScopes.User: - results = BI.UserBI.Searching.Search(Database, term, limitCount).Select(sr => Models.DocumentTemplate.ImporterUndetectedDataIdLookupModel.FromSearchResultItem(sr)).ToArray(); + results = Disco.Services.Searching.Search.SearchUsers(Database, term, limitCount).Select(sr => Models.DocumentTemplate.ImporterUndetectedDataIdLookupModel.FromSearchResultItem(sr)).ToArray(); break; default: results = null; diff --git a/Disco.Web/Areas/API/Controllers/SearchController.cs b/Disco.Web/Areas/API/Controllers/SearchController.cs new file mode 100644 index 00000000..4eeb1f0c --- /dev/null +++ b/Disco.Web/Areas/API/Controllers/SearchController.cs @@ -0,0 +1,50 @@ +using Disco.Models.Services.Searching; +using Disco.Services.Authorization; +using Disco.Services.Searching; +using Disco.Services.Web; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Web; +using System.Web.Mvc; + +namespace Disco.Web.Areas.API.Controllers +{ + public partial class SearchController : AuthorizedDatabaseController + { + [DiscoAuthorizeAny(Claims.Job.Search, Claims.Device.Search, Claims.User.Search)] + public virtual ActionResult QuickQuery(string Term, int Limit = 15) + { + if (string.IsNullOrWhiteSpace(Term)) + throw new ArgumentNullException("Term", "The search query term is required"); + if (Term.Length < 2) + throw new ArgumentException("The search query term must be at least two characters", "Term"); + if (Limit < 1) + throw new ArgumentException("The search query limit cannot be less than 1", "Limit"); + + IEnumerable results = Enumerable.Empty(); + + if (Authorization.Has(Claims.Job.Search)) + { + var jobMatches = Search.SearchJobs(Database, Term, Limit); + results = results.Concat(jobMatches); + } + + if (Authorization.Has(Claims.User.Search)) + { + var userMatches = Search.SearchUsers(Database, Term, Limit); + results = results.Concat(userMatches); + } + + if (Authorization.Has(Claims.Device.Search)) + { + var deviceMatches = Search.SearchDevices(Database, Term, Limit); + results = results.Concat(deviceMatches); + } + + results = results.OrderByDescending(i => i.ScoreValue.Score(Term, .5)).Take(Limit); + + return Json(results, JsonRequestBehavior.AllowGet); + } + } +} diff --git a/Disco.Web/Areas/API/Controllers/UserController.cs b/Disco.Web/Areas/API/Controllers/UserController.cs index 7f14beb2..fcf37c82 100644 --- a/Disco.Web/Areas/API/Controllers/UserController.cs +++ b/Disco.Web/Areas/API/Controllers/UserController.cs @@ -14,7 +14,7 @@ namespace Disco.Web.Areas.API.Controllers [DiscoAuthorize(Claims.User.Search)] public virtual ActionResult UpstreamUsers(string term) { - return Json(BI.UserBI.Searching.SearchUpstream(term), JsonRequestBehavior.AllowGet); + return Json(Disco.Services.Searching.Search.SearchUsersUpstream(term), JsonRequestBehavior.AllowGet); } #region User Attachements diff --git a/Disco.Web/Areas/API/Models/DocumentTemplate/ImporterUndetectedDataIdLookupModel.cs b/Disco.Web/Areas/API/Models/DocumentTemplate/ImporterUndetectedDataIdLookupModel.cs index 2d041f56..a3cd4210 100644 --- a/Disco.Web/Areas/API/Models/DocumentTemplate/ImporterUndetectedDataIdLookupModel.cs +++ b/Disco.Web/Areas/API/Models/DocumentTemplate/ImporterUndetectedDataIdLookupModel.cs @@ -1,5 +1,5 @@ -using Disco.Models.BI.Search; -using Disco.Models.Services.Jobs.JobLists; +using Disco.Models.Services.Jobs.JobLists; +using Disco.Models.Services.Searching; using System; using System.Collections.Generic; using System.Linq; @@ -12,20 +12,20 @@ namespace Disco.Web.Areas.API.Models.DocumentTemplate public string value { get; set; } public string label { get; set; } - public static ImporterUndetectedDataIdLookupModel FromSearchResultItem(Disco.Models.BI.Search.DeviceSearchResultItem item) + public static ImporterUndetectedDataIdLookupModel FromSearchResultItem(DeviceSearchResultItem item) { return new ImporterUndetectedDataIdLookupModel { - value = item.SerialNumber, - label = string.Format("{0} - {1} - {2}", item.SerialNumber, item.ComputerName, item.DeviceModelDescription) + value = item.Id, + label = string.Format("{0} - {1} - {2}", item.Id, item.ComputerName, item.DeviceModelDescription) }; } public static ImporterUndetectedDataIdLookupModel FromSearchResultItem(JobTableItemModel item) { return new ImporterUndetectedDataIdLookupModel { - value = item.Id.ToString(), - label = string.Format("{0} ({1}; {2})", item.Id, item.DeviceSerialNumber, item.UserDisplayName) + value = item.JobId.ToString(), + label = string.Format("{0} ({1}; {2})", item.JobId, item.DeviceSerialNumber, item.UserDisplayName) }; } public static ImporterUndetectedDataIdLookupModel FromSearchResultItem(UserSearchResultItem item) diff --git a/Disco.Web/ClientSource/Scripts/Core.js b/Disco.Web/ClientSource/Scripts/Core.js index d52e8ea1..9bb4425a 100644 --- a/Disco.Web/ClientSource/Scripts/Core.js +++ b/Disco.Web/ClientSource/Scripts/Core.js @@ -41936,13 +41936,76 @@ jQuery.fn.DataTable.defaults.aLengthMenu = [[10, 20, 50, -1], [10, 20, 50, "All" $(function () { // Search Functionality + var quickSearchInited = false; $('#SearchQuery').watermark('Search').keypress(function (e) { if (e.keyCode == 13) { $(this).closest('form').submit(); return false; } }).focus(function () { - $(this).select(); + $this = $(this); + $this.select(); + + if (!quickSearchInited) { + var quickSearchUrl = $this.attr('data-quicksearchurl'); + if (quickSearchUrl) { + $this.autocomplete({ + source: quickSearchUrl, + minLength: 2, + select: function (e, ui) { + $this.val(ui.item.tag); + $this.closest('form').submit(); + }, + response: function (e, ui) { + for (var i = 0; i < ui.content.length; i++) { + var item = ui.content[i]; + switch (item.Type) { + case 'Device': + item.tag = '!' + item.Id; + break; + case 'Job': + item.tag = '#' + item.Id; + break; + case 'User': + item.tag = '@' + item.Id; + break; + } + } + } + }).autocomplete("widget").attr('id', 'QuickSearchMenu'); + + $this.data('ui-autocomplete')._renderItem = function (ul, item) { + var template; + + //"" + item.DisplayName + "
" + item.Id + " (" + item.Type + ")
" + + switch (item.Type) { + case 'Device': + template = $('').append('').append($('').text('Device ' + item.Id)).append($('
').text(item.ComputerName + '; ' + item.DeviceModelDescription)) + break; + case 'Job': + if (item.DeviceSerialNumber && item.UserId) { + template = $('').append('').append($('').text('Job ' + item.Id)).append($('
').text(item.UserId + '; ' + item.DeviceSerialNumber)) + } else if (item.DeviceSerialNumber) { + template = $('').append('').append($('').text('Job ' + item.Id)).append($('
').text(item.DeviceSerialNumber)) + } else if (item.UserId) { + template = $('').append('').append($('').text('Job ' + item.Id)).append($('
').text(item.UserId)) + } + break; + case 'User': + template = $('').append('').append($('').text(item.DisplayName)).append($('
').text(item.Id)) + break; + } + + return $("
  • ") + .data("item.autocomplete", item) + .append(template) + .appendTo(ul); + }; + + } + quickSearchInited = true; + } }); // Menu Functionality diff --git a/Disco.Web/ClientSource/Scripts/Core.min.js b/Disco.Web/ClientSource/Scripts/Core.min.js index 56edd378..ea02ade2 100644 --- a/Disco.Web/ClientSource/Scripts/Core.min.js +++ b/Disco.Web/ClientSource/Scripts/Core.min.js @@ -1,4 +1,4 @@ -window.Modernizr=function(n,t,i){function a(n){c.cssText=n}function vt(n,t){return a(y.join(n+";")+(t||""))}function h(n,t){return typeof n===t}function v(n,t){return!!~(""+n).indexOf(t)}function lt(n,t){var u,r;for(u in n)if(r=n[u],!v(r,"-")&&c[r]!==i)return t=="pfx"?r:!0;return!1}function yt(n,t,r){var f,u;for(f in n)if(u=t[n[f]],u!==i)return r===!1?n[f]:h(u,"function")?u.bind(r||t):u;return!1}function f(n,t,i){var r=n.charAt(0).toUpperCase()+n.slice(1),u=(n+" "+ot.join(r+" ")+r).split(" ");return h(t,"string")||h(t,"undefined")?lt(u,t):(u=(n+" "+st.join(r+" ")+r).split(" "),yt(u,t,i))}function pt(){u.input=function(i){for(var r=0,u=i.length;r',n,"<\/style>"].join(""),f.id=e,(h?f:o).innerHTML+=l,o.appendChild(f),h||(o.style.background="",o.style.overflow="hidden",v=s.style.overflow,s.style.overflow="hidden",s.appendChild(o)),a=i(f,n),h?f.parentNode.removeChild(f):(o.parentNode.removeChild(o),s.style.overflow=v),!!a},at=function(t){var i=n.matchMedia||n.msMatchMedia,r;return i?i(t).matches:(l("@media "+t+" { #"+e+" { position: absolute; } }",function(t){r=(n.getComputedStyle?getComputedStyle(t,null):t.currentStyle).position=="absolute"}),r)},ct=function(){function r(r,u){u=u||t.createElement(n[r]||"div");r="on"+r;var f=r in u;return f||(u.setAttribute||(u=t.createElement("div")),u.setAttribute&&u.removeAttribute&&(u.setAttribute(r,""),f=h(u[r],"function"),h(u[r],"undefined")||(u[r]=i),u.removeAttribute(r))),u=null,f}var n={select:"input",change:"input",submit:"form",reset:"form",error:"img",load:"img",abort:"img"};return r}(),it={}.hasOwnProperty,rt,k;rt=h(it,"undefined")||h(it.call,"undefined")?function(n,t){return t in n&&h(n.constructor.prototype[t],"undefined")}:function(n,t){return it.call(n,t)};Function.prototype.bind||(Function.prototype.bind=function(n){var t=this,i,r;if(typeof t!="function")throw new TypeError;return i=tt.call(arguments,1),r=function(){var f,e,u;return this instanceof r?(f=function(){},f.prototype=t.prototype,e=new f,u=t.apply(e,i.concat(tt.call(arguments))),Object(u)===u)?u:e:t.apply(n,i.concat(tt.call(arguments)))},r});r.flexbox=function(){return f("flexWrap")};r.flexboxlegacy=function(){return f("boxDirection")};r.canvas=function(){var n=t.createElement("canvas");return!!(n.getContext&&n.getContext("2d"))};r.canvastext=function(){return!!(u.canvas&&h(t.createElement("canvas").getContext("2d").fillText,"function"))};r.webgl=function(){return!!n.WebGLRenderingContext};r.touch=function(){var i;return"ontouchstart"in n||n.DocumentTouch&&t instanceof DocumentTouch?i=!0:l(["@media (",y.join("touch-enabled),("),e,")","{#modernizr{top:9px;position:absolute}}"].join(""),function(n){i=n.offsetTop===9}),i};r.geolocation=function(){return"geolocation"in navigator};r.postmessage=function(){return!!n.postMessage};r.websqldatabase=function(){return!!n.openDatabase};r.indexedDB=function(){return!!f("indexedDB",n)};r.hashchange=function(){return ct("hashchange",n)&&(t.documentMode===i||t.documentMode>7)};r.history=function(){return!!(n.history&&history.pushState)};r.draganddrop=function(){var n=t.createElement("div");return"draggable"in n||"ondragstart"in n&&"ondrop"in n};r.websockets=function(){return"WebSocket"in n||"MozWebSocket"in n};r.rgba=function(){return a("background-color:rgba(150,255,150,.5)"),v(c.backgroundColor,"rgba")};r.hsla=function(){return a("background-color:hsla(120,40%,100%,.5)"),v(c.backgroundColor,"rgba")||v(c.backgroundColor,"hsla")};r.multiplebgs=function(){return a("background:url(https://),url(https://),red url(https://)"),/(url\s*\(.*?){3}/.test(c.background)};r.backgroundsize=function(){return f("backgroundSize")};r.borderimage=function(){return f("borderImage")};r.borderradius=function(){return f("borderRadius")};r.boxshadow=function(){return f("boxShadow")};r.textshadow=function(){return t.createElement("div").style.textShadow===""};r.opacity=function(){return vt("opacity:.55"),/^0.55$/.test(c.opacity)};r.cssanimations=function(){return f("animationName")};r.csscolumns=function(){return f("columnCount")};r.cssgradients=function(){var n="background-image:";return a((n+"-webkit- ".split(" ").join("gradient(linear,left top,right bottom,from(#9f9),to(white));"+n)+y.join("linear-gradient(left top,#9f9, white);"+n)).slice(0,-n.length)),v(c.backgroundImage,"gradient")};r.cssreflections=function(){return f("boxReflect")};r.csstransforms=function(){return!!f("transform")};r.csstransforms3d=function(){var n=!!f("perspective");return n&&"webkitPerspective"in s.style&&l("@media (transform-3d),(-webkit-transform-3d){#modernizr{left:9px;position:absolute;height:3px;}}",function(t){n=t.offsetLeft===9&&t.offsetHeight===3}),n};r.csstransitions=function(){return f("transition")};r.fontface=function(){var n;return l('@font-face {font-family:"font";src:url("https://")}',function(i,r){var f=t.getElementById("smodernizr"),u=f.sheet||f.styleSheet,e=u?u.cssRules&&u.cssRules[0]?u.cssRules[0].cssText:u.cssText||"":"";n=/src/i.test(e)&&e.indexOf(r.split(" ")[0])===0}),n};r.generatedcontent=function(){var n;return l(["#",e,"{font:0/0 a}#",e,':after{content:"',g,'";visibility:hidden;font:3px/1 a}'].join(""),function(t){n=t.offsetHeight>=3}),n};r.video=function(){var i=t.createElement("video"),n=!1;try{(n=!!i.canPlayType)&&(n=new Boolean(n),n.ogg=i.canPlayType('video/ogg; codecs="theora"').replace(/^no$/,""),n.h264=i.canPlayType('video/mp4; codecs="avc1.42E01E"').replace(/^no$/,""),n.webm=i.canPlayType('video/webm; codecs="vp8, vorbis"').replace(/^no$/,""))}catch(r){}return n};r.audio=function(){var i=t.createElement("audio"),n=!1;try{(n=!!i.canPlayType)&&(n=new Boolean(n),n.ogg=i.canPlayType('audio/ogg; codecs="vorbis"').replace(/^no$/,""),n.mp3=i.canPlayType("audio/mpeg;").replace(/^no$/,""),n.wav=i.canPlayType('audio/wav; codecs="1"').replace(/^no$/,""),n.m4a=(i.canPlayType("audio/x-m4a;")||i.canPlayType("audio/aac;")).replace(/^no$/,""))}catch(r){}return n};r.localstorage=function(){try{return localStorage.setItem(e,e),localStorage.removeItem(e),!0}catch(n){return!1}};r.sessionstorage=function(){try{return sessionStorage.setItem(e,e),sessionStorage.removeItem(e),!0}catch(n){return!1}};r.webworkers=function(){return!!n.Worker};r.applicationcache=function(){return!!n.applicationCache};r.svg=function(){return!!t.createElementNS&&!!t.createElementNS(p.svg,"svg").createSVGRect};r.inlinesvg=function(){var n=t.createElement("div");return n.innerHTML="",(n.firstChild&&n.firstChild.namespaceURI)==p.svg};r.smil=function(){return!!t.createElementNS&&/SVGAnimate/.test(ft.call(t.createElementNS(p.svg,"animate")))};r.svgclippaths=function(){return!!t.createElementNS&&/SVGClipPath/.test(ft.call(t.createElementNS(p.svg,"clipPath")))};for(k in r)rt(r,k)&&(b=k.toLowerCase(),u[b]=r[k](),nt.push((u[b]?"":"no-")+b));return u.input||pt(),u.addTest=function(n,t){if(typeof n=="object")for(var r in n)rt(n,r)&&u.addTest(r,n[r]);else{if(n=n.toLowerCase(),u[n]!==i)return u;t=typeof t=="function"?t():t;typeof d!="undefined"&&d&&(s.className+=" "+(t?"":"no-")+n);u[n]=t}return u},a(""),ut=o=null,function(n,t){function p(n,t){var i=n.createElement("p"),r=n.getElementsByTagName("head")[0]||n.documentElement;return i.innerHTML="x