New Search (#1029)
* Implemented a basic version of enhanced search where we can return multiple types of entities in one go. Current unoptimized version is twice as expensive as normal search, but under NFR. Currently 200ms max. * Worked in some basic code for grouped typeahead search component. Keyboard navigation is working. * Most of the code is in place for the typeahead. Needs css work and some accessibility work. * Hooked up filtering into all-series. Added debouncing on search, clear input field now works. Some optimizations related to memory cleanup * Added ability to define a custom placeholder * Hooked in noResults template and logic * Fixed a duplicate issue in Collection tag searching and commented out old code. OPDS still needs some updates. * Don't trigger inputChanged when reopening/clicking on input. * Added Reading list to OPDS search * Added a new image component so all the images can be lazyloaded without logic duplication * Added a maxWidth/Height on the image component * Search css update * cursor fixes * card changes - fixing border radius on cards - adding bottom card color * Expose intenral state of if the search component has focus * Adjusted the accessibility to not use complex keys and just use tab instead since this is a search, not a typeahead * Cleaned up dead code, removed angular-ng-complete library as it's no longer used. * Fixes for mobile search * Merged code * Fixed a bad merge and some nav bar styling * Cleaned up the focus code for nav bar * Removed focusIndex and just use hover state. Fixed clicking on items * fixing overlay overlap issue Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
parent
60b717ea1d
commit
03112d3f8f
37 changed files with 871 additions and 145 deletions
|
@ -51,7 +51,7 @@ namespace API.Controllers
|
|||
public async Task<IEnumerable<CollectionTagDto>> SearchTags(string queryString)
|
||||
{
|
||||
queryString ??= "";
|
||||
queryString = queryString.Replace(@"%", "");
|
||||
queryString = queryString.Replace(@"%", string.Empty);
|
||||
if (queryString.Length == 0) return await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync();
|
||||
|
||||
return await _unitOfWork.CollectionTagRepository.SearchTagDtosAsync(queryString);
|
||||
|
|
|
@ -6,6 +6,7 @@ using System.Threading.Tasks;
|
|||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Search;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
|
@ -224,17 +225,19 @@ namespace API.Controllers
|
|||
}
|
||||
|
||||
[HttpGet("search")]
|
||||
public async Task<ActionResult<IEnumerable<SearchResultDto>>> Search(string queryString)
|
||||
public async Task<ActionResult<SearchResultGroupDto>> Search(string queryString)
|
||||
{
|
||||
queryString = Uri.UnescapeDataString(queryString).Trim().Replace(@"%", string.Empty);
|
||||
|
||||
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||
// Get libraries user has access to
|
||||
var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId)).ToList();
|
||||
var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).ToList();
|
||||
|
||||
if (!libraries.Any()) return BadRequest("User does not have access to any libraries");
|
||||
if (!libraries.Any()) return BadRequest("User does not have access to any libraries");
|
||||
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
|
||||
|
||||
var series = await _unitOfWork.SeriesRepository.SearchSeries(libraries.Select(l => l.Id).ToArray(), queryString);
|
||||
var series = await _unitOfWork.SeriesRepository.SearchSeries(user.Id, isAdmin, libraries.Select(l => l.Id).ToArray(), queryString);
|
||||
|
||||
return Ok(series);
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ using API.DTOs;
|
|||
using API.DTOs.CollectionTags;
|
||||
using API.DTOs.Filtering;
|
||||
using API.DTOs.OPDS;
|
||||
using API.DTOs.Search;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
|
@ -424,6 +425,8 @@ public class OpdsController : BaseApiController
|
|||
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
||||
return BadRequest("OPDS is not enabled on this server");
|
||||
var userId = await GetUser(apiKey);
|
||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
|
||||
|
||||
if (string.IsNullOrEmpty(query))
|
||||
{
|
||||
return BadRequest("You must pass a query parameter");
|
||||
|
@ -434,15 +437,51 @@ public class OpdsController : BaseApiController
|
|||
|
||||
if (!libraries.Any()) return BadRequest("User does not have access to any libraries");
|
||||
|
||||
var series = await _unitOfWork.SeriesRepository.SearchSeries(libraries.Select(l => l.Id).ToArray(), query);
|
||||
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
|
||||
|
||||
var series = await _unitOfWork.SeriesRepository.SearchSeries(userId, isAdmin, libraries.Select(l => l.Id).ToArray(), query);
|
||||
|
||||
var feed = CreateFeed(query, $"{apiKey}/series?query=" + query, apiKey);
|
||||
SetFeedId(feed, "search-series");
|
||||
foreach (var seriesDto in series)
|
||||
foreach (var seriesDto in series.Series)
|
||||
{
|
||||
feed.Entries.Add(CreateSeries(seriesDto, apiKey));
|
||||
}
|
||||
|
||||
foreach (var collection in series.Collections)
|
||||
{
|
||||
feed.Entries.Add(new FeedEntry()
|
||||
{
|
||||
Id = collection.Id.ToString(),
|
||||
Title = collection.Title,
|
||||
Summary = collection.Summary,
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation,
|
||||
Prefix + $"{apiKey}/collections/{collection.Id}"),
|
||||
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image,
|
||||
$"/api/image/collection-cover?collectionId={collection.Id}"),
|
||||
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image,
|
||||
$"/api/image/collection-cover?collectionId={collection.Id}")
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var readingListDto in series.ReadingLists)
|
||||
{
|
||||
feed.Entries.Add(new FeedEntry()
|
||||
{
|
||||
Id = readingListDto.Id.ToString(),
|
||||
Title = readingListDto.Title,
|
||||
Summary = readingListDto.Summary,
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/reading-list/{readingListDto.Id}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
return CreateXmlResult(SerializeXml(feed));
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace API.DTOs
|
||||
namespace API.DTOs.Reader
|
||||
{
|
||||
public class BookChapterItem
|
||||
{
|
||||
|
@ -16,6 +16,6 @@ namespace API.DTOs
|
|||
/// Page Number to load for the chapter
|
||||
/// </summary>
|
||||
public int Page { get; set; }
|
||||
public ICollection<BookChapterItem> Children { get; set; }
|
||||
public ICollection<BookChapterItem> Children { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
using API.Entities.Enums;
|
||||
|
||||
namespace API.DTOs
|
||||
namespace API.DTOs.Search
|
||||
{
|
||||
public class SearchResultDto
|
||||
{
|
21
API/DTOs/Search/SearchResultGroupDto.cs
Normal file
21
API/DTOs/Search/SearchResultGroupDto.cs
Normal file
|
@ -0,0 +1,21 @@
|
|||
using System.Collections.Generic;
|
||||
using API.DTOs.CollectionTags;
|
||||
using API.DTOs.Metadata;
|
||||
using API.DTOs.ReadingLists;
|
||||
using API.Entities;
|
||||
|
||||
namespace API.DTOs.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Represents all Search results for a query
|
||||
/// </summary>
|
||||
public class SearchResultGroupDto
|
||||
{
|
||||
public IEnumerable<SearchResultDto> Series { get; set; }
|
||||
public IEnumerable<CollectionTagDto> Collections { get; set; }
|
||||
public IEnumerable<ReadingListDto> ReadingLists { get; set; }
|
||||
public IEnumerable<PersonDto> Persons { get; set; }
|
||||
public IEnumerable<GenreTagDto> Genres { get; set; }
|
||||
public IEnumerable<TagDto> Tags { get; set; }
|
||||
|
||||
}
|
|
@ -3,12 +3,13 @@ using System.Collections.Generic;
|
|||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data.Migrations;
|
||||
using API.Data.Scanner;
|
||||
using API.DTOs;
|
||||
using API.DTOs.CollectionTags;
|
||||
using API.DTOs.Filtering;
|
||||
using API.DTOs.Metadata;
|
||||
using API.DTOs.ReadingLists;
|
||||
using API.DTOs.Search;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Metadata;
|
||||
|
@ -60,10 +61,12 @@ public interface ISeriesRepository
|
|||
/// <summary>
|
||||
/// Does not add user information like progress, ratings, etc.
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="isAdmin"></param>
|
||||
/// <param name="libraryIds"></param>
|
||||
/// <param name="searchQuery">Series name to search for</param>
|
||||
/// <param name="searchQuery"></param>
|
||||
/// <returns></returns>
|
||||
Task<IEnumerable<SearchResultDto>> SearchSeries(int[] libraryIds, string searchQuery);
|
||||
Task<SearchResultGroupDto> SearchSeries(int userId, bool isAdmin, int[] libraryIds, string searchQuery);
|
||||
Task<IEnumerable<Series>> GetSeriesForLibraryIdAsync(int libraryId);
|
||||
Task<SeriesDto> GetSeriesDtoByIdAsync(int seriesId, int userId);
|
||||
Task<bool> DeleteSeriesAsync(int seriesId);
|
||||
|
@ -147,6 +150,7 @@ public class SeriesRepository : ISeriesRepository
|
|||
.CountAsync() > 1;
|
||||
}
|
||||
|
||||
|
||||
public async Task<IEnumerable<Series>> GetSeriesForLibraryIdAsync(int libraryId)
|
||||
{
|
||||
return await _context.Series
|
||||
|
@ -267,9 +271,17 @@ public class SeriesRepository : ISeriesRepository
|
|||
};
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<SearchResultDto>> SearchSeries(int[] libraryIds, string searchQuery)
|
||||
public async Task<SearchResultGroupDto> SearchSeries(int userId, bool isAdmin, int[] libraryIds, string searchQuery)
|
||||
{
|
||||
return await _context.Series
|
||||
|
||||
var result = new SearchResultGroupDto();
|
||||
|
||||
var seriesIds = _context.Series
|
||||
.Where(s => libraryIds.Contains(s.LibraryId))
|
||||
.Select(s => s.Id)
|
||||
.ToList();
|
||||
|
||||
result.Series = await _context.Series
|
||||
.Where(s => libraryIds.Contains(s.LibraryId))
|
||||
.Where(s => EF.Functions.Like(s.Name, $"%{searchQuery}%")
|
||||
|| EF.Functions.Like(s.OriginalName, $"%{searchQuery}%")
|
||||
|
@ -277,17 +289,55 @@ public class SeriesRepository : ISeriesRepository
|
|||
.Include(s => s.Library)
|
||||
.OrderBy(s => s.SortName)
|
||||
.AsNoTracking()
|
||||
.AsSplitQuery()
|
||||
.ProjectTo<SearchResultDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
|
||||
result.ReadingLists = await _context.ReadingList
|
||||
.Where(rl => rl.AppUserId == userId || rl.Promoted)
|
||||
.Where(rl => EF.Functions.Like(rl.Title, $"%{searchQuery}%"))
|
||||
.AsSplitQuery()
|
||||
.ProjectTo<ReadingListDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
|
||||
result.Collections = await _context.CollectionTag
|
||||
.Where(s => EF.Functions.Like(s.Title, $"%{searchQuery}%")
|
||||
|| EF.Functions.Like(s.NormalizedTitle, $"%{searchQuery}%"))
|
||||
.Where(s => s.Promoted || isAdmin)
|
||||
.OrderBy(s => s.Title)
|
||||
.AsNoTracking()
|
||||
.OrderBy(c => c.NormalizedTitle)
|
||||
.ProjectTo<CollectionTagDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
|
||||
result.Persons = await _context.SeriesMetadata
|
||||
.Where(sm => seriesIds.Contains(sm.SeriesId))
|
||||
.SelectMany(sm => sm.People.Where(t => EF.Functions.Like(t.Name, $"%{searchQuery}%")))
|
||||
.AsSplitQuery()
|
||||
.ProjectTo<PersonDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
|
||||
result.Genres = await _context.SeriesMetadata
|
||||
.Where(sm => seriesIds.Contains(sm.SeriesId))
|
||||
.SelectMany(sm => sm.Genres.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%")))
|
||||
.AsSplitQuery()
|
||||
.OrderBy(t => t.Title)
|
||||
.Distinct()
|
||||
.ProjectTo<GenreTagDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
|
||||
result.Tags = await _context.SeriesMetadata
|
||||
.Where(sm => seriesIds.Contains(sm.SeriesId))
|
||||
.SelectMany(sm => sm.Tags.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%")))
|
||||
.AsSplitQuery()
|
||||
.OrderBy(t => t.Title)
|
||||
.Distinct()
|
||||
.ProjectTo<TagDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
public async Task<SeriesDto> GetSeriesDtoByIdAsync(int seriesId, int userId)
|
||||
{
|
||||
var series = await _context.Series.Where(x => x.Id == seriesId)
|
||||
|
@ -300,9 +350,6 @@ public class SeriesRepository : ISeriesRepository
|
|||
return seriesList[0];
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
public async Task<bool> DeleteSeriesAsync(int seriesId)
|
||||
{
|
||||
var series = await _context.Series.Where(s => s.Id == seriesId).SingleOrDefaultAsync();
|
||||
|
|
|
@ -5,6 +5,7 @@ using API.DTOs.CollectionTags;
|
|||
using API.DTOs.Metadata;
|
||||
using API.DTOs.Reader;
|
||||
using API.DTOs.ReadingLists;
|
||||
using API.DTOs.Search;
|
||||
using API.DTOs.Settings;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue