From d411ab03f2b4801414fabbb7a61209d97c67b5ac Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Sat, 30 Apr 2022 12:09:54 -0500 Subject: [PATCH] Major Search Enhancements (#1238) * Pull progress information for some of the recommended stuff. * Fixed some redirection code from last PR * Implemented the ability to search for files in the search and open the series directly. * Fixed nav search bar expanding too much * Fixed a bug in nav module not having router so some links broke * Fixed an issue where with new localized series tag, merging could fail if the user had 2 series with the series and localized series. Added extra error handling for tracking series parsed from disk. * Fixed the slowness when typing in a typeahead by using auditTime vs debounceTime * Removed some cleaning of Edition tags from the Parser. Only Omnibus and Uncensored will be ignored when cleaning titles, Full Color, Full Contact, etc will now stay in the title for Series name. * Implemented ability to search against chapter's title (from epub or title in comicinfo). This should help users search for books in a series a lot easier. * Restrict each search type to 15 records only to keep query performant and UI useful. * Wrote some extra messaging on invite user flow around email. * Messaging update --- API.Benchmark/ParseScannedFilesBenchmarks.cs | 5 +- API.Tests/Parser/DefaultParserTests.cs | 2 +- API.Tests/Parser/MangaParserTests.cs | 7 +- API.Tests/Parser/ParserTest.cs | 1 + API.Tests/Services/CleanupServiceTests.cs | 8 +-- API/Controllers/RecommendedController.cs | 4 ++ API/Controllers/SeriesController.cs | 26 +++++++ API/DTOs/MangaFileDto.cs | 5 +- API/DTOs/Search/SearchResultGroupDto.cs | 3 + API/Data/Repositories/SeriesRepository.cs | 69 ++++++++++++++++++- API/Parser/Parser.cs | 8 --- API/Services/ArchiveService.cs | 1 + .../Tasks/Scanner/ParseScannedFiles.cs | 12 +++- API/Services/Tasks/ScannerService.cs | 3 +- UI/Web/src/app/_models/manga-file.ts | 1 + .../app/_models/search/search-result-group.ts | 6 ++ UI/Web/src/app/_services/metadata.service.ts | 6 +- UI/Web/src/app/_services/series.service.ts | 8 ++- .../invite-user/invite-user.component.html | 9 ++- .../manage-settings.component.html | 2 +- UI/Web/src/app/app-routing.module.ts | 2 +- .../library-recommended.component.html | 3 + .../library-recommended.component.ts | 2 + .../events-widget.component.html | 2 +- .../grouped-typeahead.component.html | 23 ++++++- .../grouped-typeahead.component.scss | 2 +- .../grouped-typeahead.component.ts | 8 ++- .../nav/nav-header/nav-header.component.html | 26 ++++++- .../nav/nav-header/nav-header.component.ts | 23 ++++++- UI/Web/src/app/nav/nav.module.ts | 2 + .../src/app/typeahead/typeahead.component.ts | 14 ++-- 31 files changed, 244 insertions(+), 49 deletions(-) diff --git a/API.Benchmark/ParseScannedFilesBenchmarks.cs b/API.Benchmark/ParseScannedFilesBenchmarks.cs index 7c244a5d4..1dcca79b9 100644 --- a/API.Benchmark/ParseScannedFilesBenchmarks.cs +++ b/API.Benchmark/ParseScannedFilesBenchmarks.cs @@ -1,5 +1,6 @@ using System.IO; using System.IO.Abstractions; +using System.Threading.Tasks; using API.Entities.Enums; using API.Parser; using API.Services; @@ -46,7 +47,7 @@ namespace API.Benchmark /// Generate a list of Series and another list with /// [Benchmark] - public void MergeName() + public async Task MergeName() { var libraryPath = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/Manga"); @@ -61,7 +62,7 @@ namespace API.Benchmark Title = "A Town Where You Live", Volumes = "1" }; - _parseScannedFiles.ScanLibrariesForSeries(LibraryType.Manga, new [] {libraryPath}, "Manga"); + await _parseScannedFiles.ScanLibrariesForSeries(LibraryType.Manga, new [] {libraryPath}, "Manga"); _parseScannedFiles.MergeName(p1); } } diff --git a/API.Tests/Parser/DefaultParserTests.cs b/API.Tests/Parser/DefaultParserTests.cs index 200a6b16a..f32838dd3 100644 --- a/API.Tests/Parser/DefaultParserTests.cs +++ b/API.Tests/Parser/DefaultParserTests.cs @@ -122,7 +122,7 @@ public class DefaultParserTests filepath = @"E:\Manga\Tenjo Tenge (Color)\Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz"; expected.Add(filepath, new ParserInfo { - Series = "Tenjo Tenge", Volumes = "1", Edition = "Full Contact Edition", + Series = "Tenjo Tenge {Full Contact Edition}", Volumes = "1", Edition = "", Chapters = "0", Filename = "Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz", Format = MangaFormat.Archive, FullFilePath = filepath }); diff --git a/API.Tests/Parser/MangaParserTests.cs b/API.Tests/Parser/MangaParserTests.cs index 78934be7d..a3d298e82 100644 --- a/API.Tests/Parser/MangaParserTests.cs +++ b/API.Tests/Parser/MangaParserTests.cs @@ -170,6 +170,7 @@ namespace API.Tests.Parser [InlineData("It's Witching Time! 001 (Digital) (Anonymous1234)", "It's Witching Time!")] [InlineData("Zettai Karen Children v02 c003 - The Invisible Guardian (2) [JS Scans]", "Zettai Karen Children")] [InlineData("My Charms Are Wasted on Kuroiwa Medaka - Ch. 37.5 - Volume Extras", "My Charms Are Wasted on Kuroiwa Medaka")] + [InlineData("Highschool of the Dead - Full Color Edition v02 [Uasaha] (Yen Press)", "Highschool of the Dead - Full Color Edition")] public void ParseSeriesTest(string filename, string expected) { Assert.Equal(expected, API.Parser.Parser.ParseSeries(filename)); @@ -253,13 +254,13 @@ namespace API.Tests.Parser [Theory] [InlineData("Tenjou Tenge Omnibus", "Omnibus")] - [InlineData("Tenjou Tenge {Full Contact Edition}", "Full Contact Edition")] - [InlineData("Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz", "Full Contact Edition")] + [InlineData("Tenjou Tenge {Full Contact Edition}", "")] + [InlineData("Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz", "")] [InlineData("Wotakoi - Love is Hard for Otaku Omnibus v01 (2018) (Digital) (danke-Empire)", "Omnibus")] [InlineData("To Love Ru v01 Uncensored (Ch.001-007)", "Uncensored")] [InlineData("Chobits Omnibus Edition v01 [Dark Horse]", "Omnibus Edition")] [InlineData("[dmntsf.net] One Piece - Digital Colored Comics Vol. 20 Ch. 177 - 30 Million vs 81 Million.cbz", "")] - [InlineData("AKIRA - c003 (v01) [Full Color] [Darkhorse].cbz", "Full Color")] + [InlineData("AKIRA - c003 (v01) [Full Color] [Darkhorse].cbz", "")] [InlineData("Love Hina Omnibus v05 (2015) (Digital-HD) (Asgard-Empire).cbz", "Omnibus")] public void ParseEditionTest(string input, string expected) { diff --git a/API.Tests/Parser/ParserTest.cs b/API.Tests/Parser/ParserTest.cs index d64e71cea..d5dd233de 100644 --- a/API.Tests/Parser/ParserTest.cs +++ b/API.Tests/Parser/ParserTest.cs @@ -63,6 +63,7 @@ namespace API.Tests.Parser [InlineData("- The Title", false, "The Title")] [InlineData("[Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.1", false, "Kasumi Otoko no Ko v1.1")] [InlineData("Batman - Detective Comics - Rebirth Deluxe Edition Book 04 (2019) (digital) (Son of Ultron-Empire)", true, "Batman - Detective Comics - Rebirth Deluxe Edition")] + [InlineData("Something - Full Color Edition", false, "Something - Full Color Edition")] public void CleanTitleTest(string input, bool isComic, string expected) { Assert.Equal(expected, CleanTitle(input, isComic)); diff --git a/API.Tests/Services/CleanupServiceTests.cs b/API.Tests/Services/CleanupServiceTests.cs index 2cd7da805..08d5f29a7 100644 --- a/API.Tests/Services/CleanupServiceTests.cs +++ b/API.Tests/Services/CleanupServiceTests.cs @@ -364,7 +364,7 @@ public class CleanupServiceTests #region CleanupBackups [Fact] - public void CleanupBackups_LeaveOneFile_SinceAllAreExpired() + public async Task CleanupBackups_LeaveOneFile_SinceAllAreExpired() { var filesystem = CreateFileSystem(); var filesystemFile = new MockFileData("") @@ -378,12 +378,12 @@ public class CleanupServiceTests var ds = new DirectoryService(Substitute.For>(), filesystem); var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, ds); - cleanupService.CleanupBackups(); + await cleanupService.CleanupBackups(); Assert.Single(ds.GetFiles(BackupDirectory, searchOption: SearchOption.AllDirectories)); } [Fact] - public void CleanupBackups_LeaveLestExpired() + public async Task CleanupBackups_LeaveLestExpired() { var filesystem = CreateFileSystem(); var filesystemFile = new MockFileData("") @@ -400,7 +400,7 @@ public class CleanupServiceTests var ds = new DirectoryService(Substitute.For>(), filesystem); var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, ds); - cleanupService.CleanupBackups(); + await cleanupService.CleanupBackups(); Assert.True(filesystem.File.Exists($"{BackupDirectory}randomfile.zip")); } diff --git a/API/Controllers/RecommendedController.cs b/API/Controllers/RecommendedController.cs index 85842692a..acd200b97 100644 --- a/API/Controllers/RecommendedController.cs +++ b/API/Controllers/RecommendedController.cs @@ -30,6 +30,7 @@ public class RecommendedController : BaseApiController userParams ??= new UserParams(); var series = await _unitOfWork.SeriesRepository.GetQuickReads(user.Id, libraryId, userParams); + Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); return Ok(series); } @@ -46,6 +47,7 @@ public class RecommendedController : BaseApiController userParams ??= new UserParams(); var series = await _unitOfWork.SeriesRepository.GetHighlyRated(user.Id, libraryId, userParams); + await _unitOfWork.SeriesRepository.AddSeriesModifiers(user.Id, series); Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); return Ok(series); } @@ -62,6 +64,8 @@ public class RecommendedController : BaseApiController userParams ??= new UserParams(); var series = await _unitOfWork.SeriesRepository.GetMoreIn(user.Id, libraryId, genreId, userParams); + await _unitOfWork.SeriesRepository.AddSeriesModifiers(user.Id, series); + Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); return Ok(series); } diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index a473e1fd7..39fd10938 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -342,6 +342,32 @@ namespace API.Controllers return await _seriesService.GetSeriesDetail(seriesId, userId); } + /// + /// Returns the series for the MangaFile id. If the user does not have access (shouldn't happen by the UI), + /// then null is returned + /// + /// + /// + [HttpGet("series-for-mangafile")] + public async Task> GetSeriesForMangaFile(int mangaFileId) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + return Ok(await _unitOfWork.SeriesRepository.GetSeriesForMangaFile(mangaFileId, userId)); + } + + /// + /// Returns the series for the Chapter id. If the user does not have access (shouldn't happen by the UI), + /// then null is returned + /// + /// + /// + [HttpGet("series-for-chapter")] + public async Task> GetSeriesForChapter(int chapterId) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + return Ok(await _unitOfWork.SeriesRepository.GetSeriesForChapter(chapterId, userId)); + } + /// /// Fetches the related series for a given series /// diff --git a/API/DTOs/MangaFileDto.cs b/API/DTOs/MangaFileDto.cs index 786f85df7..c1220e28d 100644 --- a/API/DTOs/MangaFileDto.cs +++ b/API/DTOs/MangaFileDto.cs @@ -4,9 +4,10 @@ namespace API.DTOs { public class MangaFileDto { + public int Id { get; init; } public string FilePath { get; init; } public int Pages { get; init; } public MangaFormat Format { get; init; } - + } -} \ No newline at end of file +} diff --git a/API/DTOs/Search/SearchResultGroupDto.cs b/API/DTOs/Search/SearchResultGroupDto.cs index b21209dca..0a1fac402 100644 --- a/API/DTOs/Search/SearchResultGroupDto.cs +++ b/API/DTOs/Search/SearchResultGroupDto.cs @@ -17,5 +17,8 @@ public class SearchResultGroupDto public IEnumerable Persons { get; set; } public IEnumerable Genres { get; set; } public IEnumerable Tags { get; set; } + public IEnumerable Files { get; set; } + public IEnumerable Chapters { get; set; } + } diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index b0090f3c2..15384b8ed 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -21,7 +21,9 @@ using API.Services.Tasks; using AutoMapper; using AutoMapper.QueryableExtensions; using Kavita.Common.Extensions; +using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using SQLitePCL; namespace API.Data.Repositories; @@ -115,6 +117,8 @@ public interface ISeriesRepository Task> GetHighlyRated(int userId, int libraryId, UserParams userParams); Task> GetMoreIn(int userId, int libraryId, int genreId, UserParams userParams); Task> GetRediscover(int userId, int libraryId, UserParams userParams); + Task GetSeriesForMangaFile(int mangaFileId, int userId); + Task GetSeriesForChapter(int chapterId, int userId); } public class SeriesRepository : ISeriesRepository @@ -287,7 +291,7 @@ public class SeriesRepository : ISeriesRepository public async Task SearchSeries(int userId, bool isAdmin, int[] libraryIds, string searchQuery) { - + const int maxRecords = 15; var result = new SearchResultGroupDto(); var searchQueryNormalized = Parser.Parser.Normalize(searchQuery); @@ -301,6 +305,7 @@ public class SeriesRepository : ISeriesRepository .Where(l => EF.Functions.Like(l.Name, $"%{searchQuery}%")) .OrderBy(l => l.Name) .AsSplitQuery() + .Take(maxRecords) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); @@ -308,7 +313,7 @@ public class SeriesRepository : ISeriesRepository var hasYearInQuery = !string.IsNullOrEmpty(justYear); var yearComparison = hasYearInQuery ? int.Parse(justYear) : 0; - result.Series = await _context.Series + result.Series = _context.Series .Where(s => libraryIds.Contains(s.LibraryId)) .Where(s => EF.Functions.Like(s.Name, $"%{searchQuery}%") || EF.Functions.Like(s.OriginalName, $"%{searchQuery}%") @@ -319,14 +324,16 @@ public class SeriesRepository : ISeriesRepository .OrderBy(s => s.SortName) .AsNoTracking() .AsSplitQuery() + .Take(maxRecords) .ProjectTo(_mapper.ConfigurationProvider) - .ToListAsync(); + .AsEnumerable(); result.ReadingLists = await _context.ReadingList .Where(rl => rl.AppUserId == userId || rl.Promoted) .Where(rl => EF.Functions.Like(rl.Title, $"%{searchQuery}%")) .AsSplitQuery() + .Take(maxRecords) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); @@ -336,6 +343,8 @@ public class SeriesRepository : ISeriesRepository .Where(s => s.Promoted || isAdmin) .OrderBy(s => s.Title) .AsNoTracking() + .AsSplitQuery() + .Take(maxRecords) .OrderBy(c => c.NormalizedTitle) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); @@ -344,6 +353,7 @@ public class SeriesRepository : ISeriesRepository .Where(sm => seriesIds.Contains(sm.SeriesId)) .SelectMany(sm => sm.People.Where(t => EF.Functions.Like(t.Name, $"%{searchQuery}%"))) .AsSplitQuery() + .Take(maxRecords) .Distinct() .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); @@ -354,6 +364,7 @@ public class SeriesRepository : ISeriesRepository .AsSplitQuery() .OrderBy(t => t.Title) .Distinct() + .Take(maxRecords) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); @@ -363,9 +374,34 @@ public class SeriesRepository : ISeriesRepository .AsSplitQuery() .OrderBy(t => t.Title) .Distinct() + .Take(maxRecords) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); + var fileIds = _context.Series + .Where(s => libraryIds.Contains(s.LibraryId)) + .AsSplitQuery() + .SelectMany(s => s.Volumes) + .SelectMany(v => v.Chapters) + .SelectMany(c => c.Files.Select(f => f.Id)); + + result.Files = await _context.MangaFile + .Where(m => EF.Functions.Like(m.FilePath, $"%{searchQuery}%") && fileIds.Contains(m.Id)) + .AsSplitQuery() + .Take(maxRecords) + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + + + result.Chapters = await _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) + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + return result; } @@ -1044,6 +1080,33 @@ public class SeriesRepository : ISeriesRepository return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); } + public async Task GetSeriesForMangaFile(int mangaFileId, int userId) + { + var libraryIds = GetLibraryIdsForUser(userId); + return await _context.MangaFile + .Where(m => m.Id == mangaFileId) + .AsSplitQuery() + .Select(f => f.Chapter) + .Select(c => c.Volume) + .Select(v => v.Series) + .Where(s => libraryIds.Contains(s.LibraryId)) + .ProjectTo(_mapper.ConfigurationProvider) + .SingleOrDefaultAsync(); + } + + public async Task GetSeriesForChapter(int chapterId, int userId) + { + var libraryIds = GetLibraryIdsForUser(userId); + return await _context.Chapter + .Where(m => m.Id == chapterId) + .AsSplitQuery() + .Select(c => c.Volume) + .Select(v => v.Series) + .Where(s => libraryIds.Contains(s.LibraryId)) + .ProjectTo(_mapper.ConfigurationProvider) + .SingleOrDefaultAsync(); + } + public async Task> GetHighlyRated(int userId, int libraryId, UserParams userParams) { diff --git a/API/Parser/Parser.cs b/API/Parser/Parser.cs index 57fe51526..67b97f9c2 100644 --- a/API/Parser/Parser.cs +++ b/API/Parser/Parser.cs @@ -452,10 +452,6 @@ namespace API.Parser }; private static readonly Regex[] MangaEditionRegex = { // Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz - new Regex( - @"(?({|\(|\[).* Edition(}|\)|\]))", - MatchOptions, RegexTimeout), - // Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz new Regex( @"(\b|_)(?Omnibus(( |_)?Edition)?)(\b|_)?", MatchOptions, RegexTimeout), @@ -463,10 +459,6 @@ namespace API.Parser new Regex( @"(\b|_)(?Uncensored)(\b|_)", MatchOptions, RegexTimeout), - // AKIRA - c003 (v01) [Full Color] [Darkhorse].cbz - new Regex( - @"(\b|_)(?Full(?: |_)Color)(\b|_)?", - MatchOptions, RegexTimeout), }; private static readonly Regex[] CleanupRegex = diff --git a/API/Services/ArchiveService.cs b/API/Services/ArchiveService.cs index fd29ea07d..4320c9d0c 100644 --- a/API/Services/ArchiveService.cs +++ b/API/Services/ArchiveService.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using System.IO; using System.IO.Compression; using System.Linq; +using System.Text; using System.Threading.Tasks; using System.Xml.Serialization; using API.Archive; diff --git a/API/Services/Tasks/Scanner/ParseScannedFiles.cs b/API/Services/Tasks/Scanner/ParseScannedFiles.cs index e2106049f..92c0d6e1d 100644 --- a/API/Services/Tasks/Scanner/ParseScannedFiles.cs +++ b/API/Services/Tasks/Scanner/ParseScannedFiles.cs @@ -133,7 +133,14 @@ namespace API.Services.Tasks.Scanner } } - TrackSeries(info); + try + { + TrackSeries(info); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception that occurred during tracking {FilePath}. Skipping this file", info.FullFilePath); + } } @@ -183,8 +190,9 @@ namespace API.Services.Tasks.Scanner { var normalizedSeries = Parser.Parser.Normalize(info.Series); var normalizedLocalSeries = Parser.Parser.Normalize(info.LocalizedSeries); + // We use FirstOrDefault because this was introduced late in development and users might have 2 series with both names var existingName = - _scannedSeries.SingleOrDefault(p => + _scannedSeries.FirstOrDefault(p => (Parser.Parser.Normalize(p.Key.NormalizedName) == normalizedSeries || Parser.Parser.Normalize(p.Key.NormalizedName) == normalizedLocalSeries) && p.Key.Format == info.Format) .Key; diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index 9b17dfaa2..a5c0f0d5d 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -195,7 +195,7 @@ public class ScannerService : IScannerService // Check if any of the folder roots are not available (ie disconnected from network, etc) and fail if any of them are if (folders.Any(f => !_directoryService.IsDriveMounted(f))) { - _logger.LogError("Some of the root folders for library ({LibraryName} are not accessible. Please check that drives are connected and rescan. Scan will be aborted", libraryName); + _logger.LogCritical("Some of the root folders for library ({LibraryName} are not accessible. Please check that drives are connected and rescan. Scan will be aborted", libraryName); await _eventHub.SendMessageAsync(MessageFactory.Error, MessageFactory.ErrorEvent("Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted", @@ -267,6 +267,7 @@ public class ScannerService : IScannerService if (!await CheckMounts(library.Name, library.Folders.Select(f => f.Path).ToList())) { _logger.LogCritical("Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted"); + return; } diff --git a/UI/Web/src/app/_models/manga-file.ts b/UI/Web/src/app/_models/manga-file.ts index 159656807..5ae56ae2b 100644 --- a/UI/Web/src/app/_models/manga-file.ts +++ b/UI/Web/src/app/_models/manga-file.ts @@ -1,6 +1,7 @@ import { MangaFormat } from './manga-format'; export interface MangaFile { + id: number; filePath: string; pages: number; format: MangaFormat; diff --git a/UI/Web/src/app/_models/search/search-result-group.ts b/UI/Web/src/app/_models/search/search-result-group.ts index 377593669..901e63548 100644 --- a/UI/Web/src/app/_models/search/search-result-group.ts +++ b/UI/Web/src/app/_models/search/search-result-group.ts @@ -1,4 +1,6 @@ +import { Chapter } from "../chapter"; import { Library } from "../library"; +import { MangaFile } from "../manga-file"; import { SearchResult } from "../search-result"; import { Tag } from "../tag"; @@ -10,6 +12,8 @@ export class SearchResultGroup { persons: Array = []; genres: Array = []; tags: Array = []; + files: Array = []; + chapters: Array = []; reset() { this.libraries = []; @@ -19,5 +23,7 @@ export class SearchResultGroup { this.persons = []; this.genres = []; this.tags = []; + this.files = []; + this.chapters = []; } } \ No newline at end of file diff --git a/UI/Web/src/app/_services/metadata.service.ts b/UI/Web/src/app/_services/metadata.service.ts index 878dba34b..f99410894 100644 --- a/UI/Web/src/app/_services/metadata.service.ts +++ b/UI/Web/src/app/_services/metadata.service.ts @@ -1,17 +1,15 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { Observable, of } from 'rxjs'; +import { of } from 'rxjs'; import { map } from 'rxjs/operators'; import { environment } from 'src/environments/environment'; import { UtilityService } from '../shared/_services/utility.service'; -import { TypeaheadSettings } from '../typeahead/typeahead-settings'; -import { ChapterMetadata } from '../_models/chapter-metadata'; import { Genre } from '../_models/genre'; import { AgeRating } from '../_models/metadata/age-rating'; import { AgeRatingDto } from '../_models/metadata/age-rating-dto'; import { Language } from '../_models/metadata/language'; import { PublicationStatusDto } from '../_models/metadata/publication-status-dto'; -import { Person, PersonRole } from '../_models/person'; +import { Person } from '../_models/person'; import { Tag } from '../_models/tag'; @Injectable({ diff --git a/UI/Web/src/app/_services/series.service.ts b/UI/Web/src/app/_services/series.service.ts index 6250a4f99..e62ec97a9 100644 --- a/UI/Web/src/app/_services/series.service.ts +++ b/UI/Web/src/app/_services/series.service.ts @@ -76,8 +76,12 @@ export class SeriesService { return this.httpClient.get(this.baseUrl + 'series/chapter-metadata?chapterId=' + chapterId); } - getData(id: number) { - return of(id); + getSeriesForMangaFile(mangaFileId: number) { + return this.httpClient.get(this.baseUrl + 'series/series-for-mangafile?mangaFileId=' + mangaFileId); + } + + getSeriesForChapter(chapterId: number) { + return this.httpClient.get(this.baseUrl + 'series/series-for-chapter?chapterId=' + chapterId); } delete(seriesId: number) { diff --git a/UI/Web/src/app/admin/invite-user/invite-user.component.html b/UI/Web/src/app/admin/invite-user/invite-user.component.html index 495b3f6e9..2010ee353 100644 --- a/UI/Web/src/app/admin/invite-user/invite-user.component.html +++ b/UI/Web/src/app/admin/invite-user/invite-user.component.html @@ -6,7 +6,8 @@ diff --git a/UI/Web/src/app/app-routing.module.ts b/UI/Web/src/app/app-routing.module.ts index 381c183ca..f3461be1d 100644 --- a/UI/Web/src/app/app-routing.module.ts +++ b/UI/Web/src/app/app-routing.module.ts @@ -77,7 +77,7 @@ const routes: Routes = [ loadChildren: () => import('../app/dev-only/dev-only.module').then(m => m.DevOnlyModule) }, {path: 'login', loadChildren: () => import('../app/registration/registration.module').then(m => m.RegistrationModule)}, - {path: '**', loadChildren: () => import('../app/dashboard/dashboard.module').then(m => m.DashboardModule), pathMatch: 'full'}, + {path: '**', pathMatch: 'full', redirectTo: 'libraries'}, ]; @NgModule({ diff --git a/UI/Web/src/app/library-detail/library-recommended/library-recommended.component.html b/UI/Web/src/app/library-detail/library-recommended/library-recommended.component.html index 10c0e85b2..1fb9f4573 100644 --- a/UI/Web/src/app/library-detail/library-recommended/library-recommended.component.html +++ b/UI/Web/src/app/library-detail/library-recommended/library-recommended.component.html @@ -1,4 +1,7 @@ + + + diff --git a/UI/Web/src/app/library-detail/library-recommended/library-recommended.component.ts b/UI/Web/src/app/library-detail/library-recommended/library-recommended.component.ts index 56e95a428..61576f72a 100644 --- a/UI/Web/src/app/library-detail/library-recommended/library-recommended.component.ts +++ b/UI/Web/src/app/library-detail/library-recommended/library-recommended.component.ts @@ -45,6 +45,8 @@ export class LibraryRecommendedComponent implements OnInit { this.genre$.subscribe(genre => { this.moreIn$ = this.recommendationService.getMoreIn(this.libraryId, genre.id).pipe(map(p => p.result), shareReplay()); }); + + } diff --git a/UI/Web/src/app/nav/events-widget/events-widget.component.html b/UI/Web/src/app/nav/events-widget/events-widget.component.html index 119b5c32f..447b9a3e9 100644 --- a/UI/Web/src/app/nav/events-widget/events-widget.component.html +++ b/UI/Web/src/app/nav/events-widget/events-widget.component.html @@ -48,7 +48,7 @@
  • {{message.title}}
    -
    {{message.subTitle}}
    +
    {{message.subTitle}}
    diff --git a/UI/Web/src/app/nav/grouped-typeahead/grouped-typeahead.component.html b/UI/Web/src/app/nav/grouped-typeahead/grouped-typeahead.component.html index 30ec66ce2..5944a5ced 100644 --- a/UI/Web/src/app/nav/grouped-typeahead/grouped-typeahead.component.html +++ b/UI/Web/src/app/nav/grouped-typeahead/grouped-typeahead.component.html @@ -85,8 +85,27 @@ - + +
  • Chapters
  • +
      +
    • + +
    • +
    +
    + + +
  • Files
  • +
      +
    • + +
    • +
    +
    + +
    • diff --git a/UI/Web/src/app/nav/grouped-typeahead/grouped-typeahead.component.scss b/UI/Web/src/app/nav/grouped-typeahead/grouped-typeahead.component.scss index fbbf72206..684f24954 100644 --- a/UI/Web/src/app/nav/grouped-typeahead/grouped-typeahead.component.scss +++ b/UI/Web/src/app/nav/grouped-typeahead/grouped-typeahead.component.scss @@ -68,7 +68,7 @@ form { } &.focused { - width: 100%; + width: 99%; border-color: var(--input-focused-border-color); } diff --git a/UI/Web/src/app/nav/grouped-typeahead/grouped-typeahead.component.ts b/UI/Web/src/app/nav/grouped-typeahead/grouped-typeahead.component.ts index 7c986de57..90d07f555 100644 --- a/UI/Web/src/app/nav/grouped-typeahead/grouped-typeahead.component.ts +++ b/UI/Web/src/app/nav/grouped-typeahead/grouped-typeahead.component.ts @@ -59,6 +59,8 @@ export class GroupedTypeaheadComponent implements OnInit, OnDestroy { @ContentChild('noResultsTemplate') noResultsTemplate!: TemplateRef; @ContentChild('libraryTemplate') libraryTemplate!: TemplateRef; @ContentChild('readingListTemplate') readingListTemplate!: TemplateRef; + @ContentChild('fileTemplate') fileTemplate!: TemplateRef; + @ContentChild('chapterTemplate') chapterTemplate!: TemplateRef; hasFocus: boolean = false; @@ -74,7 +76,11 @@ export class GroupedTypeaheadComponent implements OnInit, OnDestroy { } get hasData() { - 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; + return !(this.noResultsTemplate != undefined && !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); + + //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; } diff --git a/UI/Web/src/app/nav/nav-header/nav-header.component.html b/UI/Web/src/app/nav/nav-header/nav-header.component.html index 187b59643..e5c64466a 100644 --- a/UI/Web/src/app/nav/nav-header/nav-header.component.html +++ b/UI/Web/src/app/nav/nav-header/nav-header.component.html @@ -39,7 +39,7 @@ -
      in {{item.libraryName}}
      +
      in {{item.libraryName}}
      @@ -84,7 +84,7 @@
      -
      {{item.role | personRole}}
      +
      {{item.role | personRole}}
      @@ -97,6 +97,28 @@ + + + +
      +
      + + + + {{item.titleName}} +
      +
      +
      + + +
      +
      + + {{item.filePath}} +
      +
      +
      + No results found diff --git a/UI/Web/src/app/nav/nav-header/nav-header.component.ts b/UI/Web/src/app/nav/nav-header/nav-header.component.ts index ed86bdf01..6d8f35b7e 100644 --- a/UI/Web/src/app/nav/nav-header/nav-header.component.ts +++ b/UI/Web/src/app/nav/nav-header/nav-header.component.ts @@ -3,7 +3,10 @@ import { Component, HostListener, Inject, OnDestroy, OnInit, ViewChild } from '@ import { Router } from '@angular/router'; import { Subject } from 'rxjs'; import { takeUntil } 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'; +import { SeriesService } from 'src/app/_services/series.service'; import { FilterQueryParam } from '../../shared/_services/filter-utilities.service'; import { CollectionTag } from '../../_models/collection-tag'; import { Library } from '../../_models/library'; @@ -48,7 +51,7 @@ export class NavHeaderComponent implements OnInit, OnDestroy { constructor(public accountService: AccountService, private router: Router, public navService: NavService, private libraryService: LibraryService, public imageService: ImageService, @Inject(DOCUMENT) private document: Document, - private scrollService: ScrollService) { } + private scrollService: ScrollService, private seriesService: SeriesService) { } ngOnInit(): void {} @@ -154,6 +157,24 @@ export class NavHeaderComponent implements OnInit, OnDestroy { this.router.navigate(['library', libraryId, 'series', seriesId]); } + clickFileSearchResult(item: MangaFile) { + this.clearSearch(); + this.seriesService.getSeriesForMangaFile(item.id).subscribe(series => { + if (series !== undefined && series !== null) { + this.router.navigate(['library', series.libraryId, 'series', series.id]); + } + }) + } + + clickChapterSearchResult(item: Chapter) { + this.clearSearch(); + this.seriesService.getSeriesForChapter(item.id).subscribe(series => { + if (series !== undefined && series !== null) { + this.router.navigate(['library', series.libraryId, 'series', series.id]); + } + }) + } + clickLibraryResult(item: Library) { this.router.navigate(['library', item.id]); } diff --git a/UI/Web/src/app/nav/nav.module.ts b/UI/Web/src/app/nav/nav.module.ts index 5e2053164..97e77b9f3 100644 --- a/UI/Web/src/app/nav/nav.module.ts +++ b/UI/Web/src/app/nav/nav.module.ts @@ -8,6 +8,7 @@ import { SharedModule } from '../shared/shared.module'; import { PipeModule } from '../pipe/pipe.module'; import { TypeaheadModule } from '../typeahead/typeahead.module'; import { ReactiveFormsModule } from '@angular/forms'; +import { RouterModule } from '@angular/router'; @@ -20,6 +21,7 @@ import { ReactiveFormsModule } from '@angular/forms'; imports: [ CommonModule, ReactiveFormsModule, + RouterModule, NgbDropdownModule, NgbPopoverModule, diff --git a/UI/Web/src/app/typeahead/typeahead.component.ts b/UI/Web/src/app/typeahead/typeahead.component.ts index a46201f28..4233f80c6 100644 --- a/UI/Web/src/app/typeahead/typeahead.component.ts +++ b/UI/Web/src/app/typeahead/typeahead.component.ts @@ -2,7 +2,7 @@ import { DOCUMENT } from '@angular/common'; import { Component, ContentChild, ElementRef, EventEmitter, HostListener, Inject, Input, OnDestroy, OnInit, Output, Renderer2, RendererStyleFlags2, TemplateRef, ViewChild } from '@angular/core'; import { FormControl, FormGroup } from '@angular/forms'; import { Observable, of, ReplaySubject, Subject } from 'rxjs'; -import { debounceTime, distinctUntilChanged, filter, map, shareReplay, switchMap, take, takeUntil, tap } from 'rxjs/operators'; +import { auditTime, distinctUntilChanged, filter, map, shareReplay, switchMap, take, takeUntil, tap } from 'rxjs/operators'; import { KEY_CODES } from '../shared/_services/utility.service'; import { SelectionCompareFn, TypeaheadSettings } from './typeahead-settings'; @@ -206,26 +206,30 @@ export class TypeaheadComponent implements OnInit, OnDestroy { 'typeahead': this.typeaheadControl }); + this.filteredOptions = this.typeaheadForm.get('typeahead')!.valueChanges .pipe( // Adjust input box to grow tap(val => { if (this.inputElem != null && this.inputElem.nativeElement != null) { - this.renderer2.setStyle(this.inputElem.nativeElement, 'width', 15 * ((this.typeaheadControl.value + '').length + 1) + 'px'); + this.renderer2.setStyle(this.inputElem.nativeElement, 'width', 15 * (val.trim().length + 1) + 'px'); this.focusedIndex = 0; } }), - debounceTime(this.settings.debounce), + map(val => val.trim()), + auditTime(this.settings.debounce), + distinctUntilChanged(), filter(val => { // If minimum filter characters not met, do not filter if (this.settings.minCharacters === 0) return true; - if (!val || val.trim().length < this.settings.minCharacters) { + if (!val || val.length < this.settings.minCharacters) { return false; } return true; }), + switchMap(val => { this.isLoadingOptions = true; let results: Observable; @@ -241,12 +245,12 @@ export class TypeaheadComponent implements OnInit, OnDestroy { tap((filteredOptions) => { this.isLoadingOptions = false; this.focusedIndex = 0; - //this.updateShowAddItem(filteredOptions); setTimeout(() => { this.updateShowAddItem(filteredOptions); this.updateHighlight(); }, 10); setTimeout(() => this.updateHighlight(), 20); + }), shareReplay(), takeUntil(this.onDestroy)