Compare commits

...
Sign in to create a new pull request.

6 commits

16 changed files with 284 additions and 80 deletions

View file

@ -2,6 +2,7 @@
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Search;
using API.Extensions;
@ -47,20 +48,25 @@ public class SearchController : BaseApiController
return Ok(await _unitOfWork.SeriesRepository.GetSeriesForChapter(chapterId, userId));
}
/// <summary>
/// Search for different entities against the query string
/// </summary>
/// <param name="queryString"></param>
/// <param name="maxRecords">Defaults to 15, if 0 will not apply any limit to search results and may result in longer response times</param>
/// <returns></returns>
[HttpGet("search")]
public async Task<ActionResult<SearchResultGroupDto>> Search(string queryString)
public async Task<ActionResult<SearchResultGroupDto>> Search(string queryString, int maxRecords = 15)
{
queryString = Uri.UnescapeDataString(queryString).Trim().Replace(@"%", string.Empty).Replace(":", string.Empty);
queryString = Uri.UnescapeDataString(queryString)
.Trim()
.Replace(@"%", string.Empty)
.Replace(":", string.Empty);
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Library);
if (!user.Libraries.Any()) return BadRequest("User does not have access to any libraries");
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
// Get libraries user has access to
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(user.Id, isAdmin, libraries.Select(l => l.Id).ToArray(), queryString);
var series = await _unitOfWork.SeriesRepository.SearchSeries(user.Id, isAdmin,
user.Libraries.Select(l => l.Id).ToArray(), queryString, maxRecords);
return Ok(series);
}

View file

@ -62,7 +62,7 @@ public interface ISeriesRepository
/// <param name="libraryIds"></param>
/// <param name="searchQuery"></param>
/// <returns></returns>
Task<SearchResultGroupDto> SearchSeries(int userId, bool isAdmin, int[] libraryIds, string searchQuery);
Task<SearchResultGroupDto> SearchSeries(int userId, bool isAdmin, int[] libraryIds, string searchQuery, int maxRecords = 15);
Task<IEnumerable<Series>> GetSeriesForLibraryIdAsync(int libraryId, SeriesIncludes includes = SeriesIncludes.None);
Task<SeriesDto> GetSeriesDtoByIdAsync(int seriesId, int userId);
Task<Series> GetSeriesByIdAsync(int seriesId, SeriesIncludes includes = SeriesIncludes.Volumes | SeriesIncludes.Metadata);
@ -287,27 +287,35 @@ public class SeriesRepository : ISeriesRepository
};
}
public async Task<SearchResultGroupDto> SearchSeries(int userId, bool isAdmin, int[] libraryIds, string searchQuery)
/// <summary>
///
/// </summary>
/// <param name="userId"></param>
/// <param name="isAdmin"></param>
/// <param name="libraryIds"></param>
/// <param name="searchQuery"></param>
/// <param name="maxRecords">If 0 or less, will not apply any LIMIT</param>
/// <returns></returns>
public async Task<SearchResultGroupDto> SearchSeries(int userId, bool isAdmin, int[] libraryIds, string searchQuery, int maxRecords = 15)
{
const int maxRecords = 15;
var result = new SearchResultGroupDto();
var searchQueryNormalized = Services.Tasks.Scanner.Parser.Parser.Normalize(searchQuery);
var userRating = await _context.AppUser.GetUserAgeRestriction(userId);
var seriesIds = _context.Series
var seriesIds = await _context.Series
.Where(s => libraryIds.Contains(s.LibraryId))
.RestrictAgainstAgeRestriction(userRating)
.Select(s => s.Id)
.ToList();
.ToListAsync();
result.Libraries = await _context.Library
result.Libraries = _context.Library
.Where(l => libraryIds.Contains(l.Id))
.Where(l => EF.Functions.Like(l.Name, $"%{searchQuery}%"))
.OrderBy(l => l.Name)
.AsSplitQuery()
.Take(maxRecords)
.TakeIfGreaterThan0(maxRecords)
.ProjectTo<LibraryDto>(_mapper.ConfigurationProvider)
.ToListAsync();
.AsEnumerable();
var justYear = Regex.Match(searchQuery, @"\d{4}").Value;
var hasYearInQuery = !string.IsNullOrEmpty(justYear);
@ -325,60 +333,61 @@ public class SeriesRepository : ISeriesRepository
.OrderBy(s => s.SortName)
.AsNoTracking()
.AsSplitQuery()
.Take(maxRecords)
.TakeIfGreaterThan0(maxRecords)
.ProjectTo<SearchResultDto>(_mapper.ConfigurationProvider)
.AsEnumerable();
result.ReadingLists = await _context.ReadingList
result.ReadingLists = _context.ReadingList
.Where(rl => rl.AppUserId == userId || rl.Promoted)
.Where(rl => EF.Functions.Like(rl.Title, $"%{searchQuery}%"))
.RestrictAgainstAgeRestriction(userRating)
.AsSplitQuery()
.Take(maxRecords)
.OrderBy(c => c.NormalizedTitle)
.TakeIfGreaterThan0(maxRecords)
.ProjectTo<ReadingListDto>(_mapper.ConfigurationProvider)
.ToListAsync();
.AsEnumerable();
result.Collections = await _context.CollectionTag
result.Collections = _context.CollectionTag
.Where(c => EF.Functions.Like(c.Title, $"%{searchQuery}%")
|| EF.Functions.Like(c.NormalizedTitle, $"%{searchQueryNormalized}%"))
.Where(c => c.Promoted || isAdmin)
.RestrictAgainstAgeRestriction(userRating)
.OrderBy(s => s.Title)
.AsNoTracking()
.AsSplitQuery()
.Take(maxRecords)
.OrderBy(c => c.NormalizedTitle)
.OrderBy(s => s.Title)
.TakeIfGreaterThan0(maxRecords)
.ProjectTo<CollectionTagDto>(_mapper.ConfigurationProvider)
.ToListAsync();
.AsEnumerable();
result.Persons = await _context.SeriesMetadata
result.Persons = _context.SeriesMetadata
.Where(sm => seriesIds.Contains(sm.SeriesId))
.SelectMany(sm => sm.People.Where(t => EF.Functions.Like(t.Name, $"%{searchQuery}%")))
.AsSplitQuery()
.Take(maxRecords)
.Distinct()
.OrderBy(p => p.NormalizedName)
.TakeIfGreaterThan0(maxRecords)
.ProjectTo<PersonDto>(_mapper.ConfigurationProvider)
.ToListAsync();
.AsEnumerable();
result.Genres = await _context.SeriesMetadata
result.Genres = _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()
.Take(maxRecords)
.OrderBy(t => t.Title)
.TakeIfGreaterThan0(maxRecords)
.ProjectTo<GenreTagDto>(_mapper.ConfigurationProvider)
.ToListAsync();
.AsEnumerable();
result.Tags = await _context.SeriesMetadata
result.Tags = _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()
.Take(maxRecords)
.OrderBy(t => t.Title)
.TakeIfGreaterThan0(maxRecords)
.ProjectTo<TagDto>(_mapper.ConfigurationProvider)
.ToListAsync();
.AsEnumerable();
var fileIds = _context.Series
.Where(s => seriesIds.Contains(s.Id))
@ -387,22 +396,24 @@ public class SeriesRepository : ISeriesRepository
.SelectMany(v => v.Chapters)
.SelectMany(c => c.Files.Select(f => f.Id));
result.Files = await _context.MangaFile
result.Files = _context.MangaFile
.Where(m => EF.Functions.Like(m.FilePath, $"%{searchQuery}%") && fileIds.Contains(m.Id))
.AsSplitQuery()
.Take(maxRecords)
.OrderBy(f => f.Id)
.TakeIfGreaterThan0(maxRecords)
.ProjectTo<MangaFileDto>(_mapper.ConfigurationProvider)
.ToListAsync();
.AsEnumerable();
result.Chapters = await _context.Chapter
result.Chapters = _context.Chapter
.Include(c => c.Files)
.Where(c => EF.Functions.Like(c.TitleName, $"%{searchQuery}%"))
.Where(c => c.Files.All(f => fileIds.Contains(f.Id)))
.AsSplitQuery()
.Take(maxRecords)
.OrderBy(c => c.Id)
.TakeIfGreaterThan0(maxRecords)
.ProjectTo<ChapterDto>(_mapper.ConfigurationProvider)
.ToListAsync();
.AsEnumerable();
return result;
}

View file

@ -29,7 +29,7 @@ public enum AppUserIncludes
WantToRead = 64,
ReadingListsWithItems = 128,
Devices = 256,
Library = 512,
}
public interface IUserRepository
@ -202,9 +202,12 @@ public class UserRepository : IUserRepository
query = query.Include(u => u.Devices);
}
if (includeFlags.HasFlag(AppUserIncludes.Library))
{
query = query.Include(u => u.Libraries);
}
return query;
return query.AsSplitQuery();
}

View file

@ -110,4 +110,21 @@ public static class QueryableExtensions
})
.SingleAsync();
}
/// <summary>
/// Only applies the Take if it's greater than 0
/// </summary>
/// <param name="queryable"></param>
/// <param name="takeAmt"></param>
/// <typeparam name="T"></typeparam>
/// <returns></returns>
public static IQueryable<T> TakeIfGreaterThan0<T>(this IQueryable<T> queryable, int takeAmt)
{
if (takeAmt > 0)
{
return queryable.Take(takeAmt);
}
return queryable;
}
}

View file

@ -1,6 +1,6 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { of } from 'rxjs';
import { distinctUntilChanged, map, Observable, of, ReplaySubject, startWith, switchMap } from 'rxjs';
import { environment } from 'src/environments/environment';
import { SearchResultGroup } from '../_models/search/search-result-group';
import { Series } from '../_models/series';
@ -12,13 +12,24 @@ export class SearchService {
baseUrl = environment.apiUrl;
constructor(private httpClient: HttpClient) { }
private searchSubject: ReplaySubject<string> = new ReplaySubject(1);
searchResults$: Observable<SearchResultGroup>;
searchTerm$: Observable<string> = this.searchSubject.asObservable();
constructor(private httpClient: HttpClient) {
this.searchResults$ = this.searchSubject.pipe(
startWith(''),
map(val => val.trim()),
distinctUntilChanged(),
switchMap(term => {
if (term === '' || term === null || term === undefined) return of(new SearchResultGroup());
return this.httpClient.get<SearchResultGroup>(this.baseUrl + 'search/search?queryString=' + encodeURIComponent(term))
})
);
}
search(term: string) {
if (term === '') {
return of(new SearchResultGroup());
}
return this.httpClient.get<SearchResultGroup>(this.baseUrl + 'search/search?queryString=' + encodeURIComponent(term));
this.searchSubject.next(term);
}
getSeriesForMangaFile(mangaFileId: number) {

View file

@ -49,6 +49,10 @@ const routes: Routes = [
path: 'want-to-read',
loadChildren: () => import('../app/want-to-read/want-to-read.module').then(m => m.WantToReadModule)
},
{
path: 'search',
loadChildren: () => import('../app/search/search.module').then(m => m.SearchModule)
},
{
path: 'library',
runGuardsAndResolvers: 'always',

View file

@ -129,11 +129,14 @@ export class EditSeriesRelationComponent implements OnInit, OnDestroy {
seriesSettings.id = 'relation--' + index;
seriesSettings.unique = true;
seriesSettings.addIfNonExisting = false;
seriesSettings.fetchFn = (searchFilter: string) => this.searchService.search(searchFilter).pipe(
map(group => group.series),
map(items => seriesSettings.compareFn(items, searchFilter)),
map(series => series.filter(s => s.seriesId !== this.series.id)),
);
seriesSettings.fetchFn = (searchFilter: string) => {
this.searchService.search(searchFilter);
return this.searchService.searchResults$.pipe(
map(group => group.series),
map(items => seriesSettings.compareFn(items, searchFilter)),
map(series => series.filter(s => s.seriesId !== this.series.id)),
);
}
seriesSettings.compareFn = (options: SearchResult[], filter: string) => {
return options.filter(m => this.utilityService.filter(m.name, filter));
@ -144,7 +147,8 @@ export class EditSeriesRelationComponent implements OnInit, OnDestroy {
}
if (series !== undefined) {
return this.searchService.search(series.name).pipe(
this.searchService.search(series.name)
return this.searchService.searchResults$.pipe(
map(group => group.series), map(results => {
seriesSettings.savedData = results.filter(s => s.seriesId === series.id);
return seriesSettings;

View file

@ -13,6 +13,14 @@
</div>
<div class="dropdown" *ngIf="hasFocus">
<ul class="list-group" role="listbox" id="dropdown">
<ng-container *ngIf="seeMoreTemplate !== undefined && hasData && searchTerm.length > 0">
<li class="list-group-item section-header"><h5 id="seemore-group">See More</h5></li>
<ul class="list-group results">
<li class="list-group-item">
<ng-container [ngTemplateOutlet]="seeMoreTemplate" [ngTemplateOutletContext]="{ $implicit: searchTerm }"></ng-container>
</li>
</ul>
</ng-container>
<ng-container *ngIf="seriesTemplate !== undefined && grouppedData.series.length > 0">
<li class="list-group-item section-header"><h5 id="series-group">Series</h5></li>
<ul class="list-group results" role="group" aria-describedby="series-group">
@ -109,7 +117,6 @@
<ng-container [ngTemplateOutlet]="noResultsTemplate"></ng-container>
</li>
</ul>
</ng-container>
</ul>
</div>

View file

@ -58,6 +58,7 @@ export class GroupedTypeaheadComponent implements OnInit, OnDestroy {
@ContentChild('personTemplate') personTemplate: TemplateRef<any> | undefined;
@ContentChild('genreTemplate') genreTemplate!: TemplateRef<any>;
@ContentChild('noResultsTemplate') noResultsTemplate!: TemplateRef<any>;
@ContentChild('seeMoreTemplate') seeMoreTemplate!: TemplateRef<any>;
@ContentChild('libraryTemplate') libraryTemplate!: TemplateRef<any>;
@ContentChild('readingListTemplate') readingListTemplate!: TemplateRef<any>;
@ContentChild('fileTemplate') fileTemplate!: TemplateRef<any>;
@ -77,7 +78,7 @@ export class GroupedTypeaheadComponent implements OnInit, OnDestroy {
}
get hasData() {
return !(this.noResultsTemplate != undefined && !this.grouppedData.persons.length && !this.grouppedData.collections.length
return !(!this.grouppedData.persons.length && !this.grouppedData.collections.length
&& !this.grouppedData.series.length && !this.grouppedData.persons.length && !this.grouppedData.tags.length && !this.grouppedData.genres.length && !this.grouppedData.libraries.length
&& !this.grouppedData.files.length && !this.grouppedData.chapters.length);
}
@ -179,6 +180,7 @@ export class GroupedTypeaheadComponent implements OnInit, OnDestroy {
}
open(event?: FocusEvent) {
// TODO: Supress showing the groupped results when on the search page directly
this.hasFocus = true;
this.focusChanged.emit(this.hasFocus);
this.cdRef.markForCheck();

View file

@ -192,7 +192,7 @@ NZ0ZV4zm4/L1dfnYNCrjTFq9G03rmj5D+Y4i0OHuL3GFPJytaM54AAAAAElFTkSuQmCC
[minQueryLength]="2"
initialValue=""
placeholder="Search…"
[grouppedData]="searchResults"
[grouppedData]="(search$ | async) || emptyResult"
(inputChanged)="onChangeSearch($event)"
(clearField)="clearSearch()"
(focusChanged)="focusUpdate($event)"
@ -301,6 +301,9 @@ NZ0ZV4zm4/L1dfnYNCrjTFq9G03rmj5D+Y4i0OHuL3GFPJytaM54AAAAAElFTkSuQmCC
No results found
</ng-template>
<ng-template #seeMoreTemplate let-query>
<a routerLink="/search" [queryParams]="{'query': query}">See more</a>
</ng-template>
</app-grouped-typeahead>
</div>
</div>

View file

@ -1,8 +1,8 @@
import { DOCUMENT } from '@angular/common';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, Inject, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
import { fromEvent, Subject } from 'rxjs';
import { debounceTime, distinctUntilChanged, filter, takeUntil, tap } from 'rxjs/operators';
import { fromEvent, Observable, of, Subject } from 'rxjs';
import { catchError, debounceTime, distinctUntilChanged, filter, startWith, takeUntil, tap } from 'rxjs/operators';
import { Chapter } from 'src/app/_models/chapter';
import { MangaFile } from 'src/app/_models/manga-file';
import { ScrollService } from 'src/app/_services/scroll.service';
@ -16,7 +16,6 @@ import { SearchResult } from '../../_models/search-result';
import { SearchResultGroup } from '../../_models/search/search-result-group';
import { AccountService } from '../../_services/account.service';
import { ImageService } from '../../_services/image.service';
import { LibraryService } from '../../_services/library.service';
import { NavService } from '../../_services/nav.service';
@Component({
@ -32,7 +31,8 @@ export class NavHeaderComponent implements OnInit, OnDestroy {
isLoading = false;
debounceTime = 300;
imageStyles = {width: '24px', 'margin-top': '5px'};
searchResults: SearchResultGroup = new SearchResultGroup();
emptyResult: SearchResultGroup = new SearchResultGroup();
search$!: Observable<SearchResultGroup>;
searchTerm = '';
customFilter: (items: SearchResult[], query: string) => SearchResult[] = (items: SearchResult[], query: string) => {
const normalizedQuery = query.trim().toLowerCase();
@ -53,8 +53,23 @@ export class NavHeaderComponent implements OnInit, OnDestroy {
constructor(public accountService: AccountService, private router: Router, public navService: NavService,
public imageService: ImageService, @Inject(DOCUMENT) private document: Document,
private scrollService: ScrollService, private searchService: SearchService, private readonly cdRef: ChangeDetectorRef) {
private scrollService: ScrollService, public searchService: SearchService, private readonly cdRef: ChangeDetectorRef) {
this.scrollElem = this.document.body;
this.search$ = this.searchService.searchResults$.pipe(
startWith(new SearchResultGroup()),
takeUntil(this.onDestroy),
tap((_) => {
this.isLoading = false;
this.cdRef.markForCheck();
}),
catchError(() => {
this.isLoading = false;
this.searchTerm = '';
this.cdRef.markForCheck();
return of();
})
);
}
ngOnInit(): void {
@ -108,18 +123,8 @@ export class NavHeaderComponent implements OnInit, OnDestroy {
onChangeSearch(val: string) {
this.isLoading = true;
this.searchTerm = val.trim();
this.cdRef.markForCheck();
this.searchService.search(val.trim()).pipe(takeUntil(this.onDestroy)).subscribe(results => {
this.searchResults = results;
this.isLoading = false;
this.cdRef.markForCheck();
}, err => {
this.searchResults.reset();
this.isLoading = false;
this.searchTerm = '';
this.cdRef.markForCheck();
});
this.searchService.search(val);
this.cdRef.markForCheck();
}
goTo(queryParamName: string, filter: any) {
@ -172,7 +177,7 @@ export class NavHeaderComponent implements OnInit, OnDestroy {
clearSearch() {
this.searchViewRef.clear();
this.searchTerm = '';
this.searchResults = new SearchResultGroup();
this.searchService.search('');
this.cdRef.markForCheck();
}

View file

@ -0,0 +1,19 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SearchComponent } from './search/search.component';
import { SearchRoutingModule } from './search.router.module';
import { CardsModule } from '../cards/cards.module';
@NgModule({
declarations: [
SearchComponent
],
imports: [
CommonModule,
SearchRoutingModule,
CardsModule
]
})
export class SearchModule { }

View file

@ -0,0 +1,17 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { SearchComponent } from './search/search.component';
const routes: Routes = [
{
path: '',
component: SearchComponent
},
];
@NgModule({
imports: [RouterModule.forChild(routes), ],
exports: [RouterModule]
})
export class SearchRoutingModule { }

View file

@ -0,0 +1,13 @@
<h3>Search Results: {{searchService.searchTerm$ | async}}</h3>
<app-card-detail-layout
[isLoading]="isLoading"
[items]="(series$ | async) || []"
>
<ng-template #cardItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="item.libraryId" [] [suppressLibraryLink]="false" (reload)="loadPage()"[allowSelection]="false"></app-series-card>
</ng-template>
</app-card-detail-layout>
<!-- [pagination]="pagination"
[trackByIdentity]="trackByIdentity"
[refresh]="refresh"-->

View file

@ -0,0 +1,82 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { map, Observable, Subject, switchMap, takeUntil } from 'rxjs';
import { SearchResult } from 'src/app/_models/search-result';
import { Series } from 'src/app/_models/series';
import { SearchService } from 'src/app/_services/search.service';
@Component({
selector: 'app-search',
templateUrl: './search.component.html',
styleUrls: ['./search.component.scss']
})
export class SearchComponent implements OnInit, OnDestroy {
isLoading: boolean = false;
originalQueryString: string = '';
series$!: Observable<Series[]>;
private onDestroy: Subject<void> = new Subject();
constructor(private route: ActivatedRoute, private router: Router, public searchService: SearchService,) {
}
ngOnInit(): void {
const queryString = this.route.snapshot.queryParamMap.get('query');
console.log('query: ', queryString)
if (queryString === undefined || queryString === null || queryString === '') {
this.router.navigateByUrl('/libraries');
return;
}
this.originalQueryString = queryString;
// const searchResults$ = this.searchService.searchTerm$.pipe(
// takeUntil(this.onDestroy),
// switchMap(() =>)
// );
this.series$ = this.searchService.searchResults$.pipe(
takeUntil(this.onDestroy),
map(g => g.series.map(s => {
return {
id: s.seriesId,
sortName: s.sortName,
libraryName: s.libraryName,
libraryId: s.libraryId,
localizedName: s.localizedName,
name: s.name,
originalName: s.originalName,
format: s.format,
volumes: [],
pages: 0,
pagesRead: 0,
userRating: 0,
userReview: '',
coverImageLocked: false,
sortNameLocked: false,
localizedNameLocked: false,
nameLocked: false,
created: '',
latestReadDate: '',
lastChapterAdded: '',
lastFolderScanned: '',
wordCount: 0,
minHoursToRead: 0,
maxHoursToRead: 0,
avgHoursToRead: 0,
folderPath: '',
};
})),
);
}
ngOnDestroy() {
this.onDestroy.next();
this.onDestroy.complete();
}
loadPage() {}
}