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
This commit is contained in:
Joseph Milazzo 2022-04-30 12:09:54 -05:00 committed by GitHub
parent a2d5ee18a0
commit d411ab03f2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 244 additions and 49 deletions

View File

@ -1,5 +1,6 @@
using System.IO; using System.IO;
using System.IO.Abstractions; using System.IO.Abstractions;
using System.Threading.Tasks;
using API.Entities.Enums; using API.Entities.Enums;
using API.Parser; using API.Parser;
using API.Services; using API.Services;
@ -46,7 +47,7 @@ namespace API.Benchmark
/// Generate a list of Series and another list with /// Generate a list of Series and another list with
/// </summary> /// </summary>
[Benchmark] [Benchmark]
public void MergeName() public async Task MergeName()
{ {
var libraryPath = Path.Join(Directory.GetCurrentDirectory(), var libraryPath = Path.Join(Directory.GetCurrentDirectory(),
"../../../Services/Test Data/ScannerService/Manga"); "../../../Services/Test Data/ScannerService/Manga");
@ -61,7 +62,7 @@ namespace API.Benchmark
Title = "A Town Where You Live", Title = "A Town Where You Live",
Volumes = "1" Volumes = "1"
}; };
_parseScannedFiles.ScanLibrariesForSeries(LibraryType.Manga, new [] {libraryPath}, "Manga"); await _parseScannedFiles.ScanLibrariesForSeries(LibraryType.Manga, new [] {libraryPath}, "Manga");
_parseScannedFiles.MergeName(p1); _parseScannedFiles.MergeName(p1);
} }
} }

View File

@ -122,7 +122,7 @@ public class DefaultParserTests
filepath = @"E:\Manga\Tenjo Tenge (Color)\Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz"; filepath = @"E:\Manga\Tenjo Tenge (Color)\Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz";
expected.Add(filepath, new ParserInfo 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, Chapters = "0", Filename = "Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz", Format = MangaFormat.Archive,
FullFilePath = filepath FullFilePath = filepath
}); });

View File

@ -170,6 +170,7 @@ namespace API.Tests.Parser
[InlineData("It's Witching Time! 001 (Digital) (Anonymous1234)", "It's Witching Time!")] [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("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("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) public void ParseSeriesTest(string filename, string expected)
{ {
Assert.Equal(expected, API.Parser.Parser.ParseSeries(filename)); Assert.Equal(expected, API.Parser.Parser.ParseSeries(filename));
@ -253,13 +254,13 @@ namespace API.Tests.Parser
[Theory] [Theory]
[InlineData("Tenjou Tenge Omnibus", "Omnibus")] [InlineData("Tenjou Tenge Omnibus", "Omnibus")]
[InlineData("Tenjou Tenge {Full Contact Edition}", "Full Contact Edition")] [InlineData("Tenjou Tenge {Full Contact Edition}", "")]
[InlineData("Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz", "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("Wotakoi - Love is Hard for Otaku Omnibus v01 (2018) (Digital) (danke-Empire)", "Omnibus")]
[InlineData("To Love Ru v01 Uncensored (Ch.001-007)", "Uncensored")] [InlineData("To Love Ru v01 Uncensored (Ch.001-007)", "Uncensored")]
[InlineData("Chobits Omnibus Edition v01 [Dark Horse]", "Omnibus Edition")] [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("[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")] [InlineData("Love Hina Omnibus v05 (2015) (Digital-HD) (Asgard-Empire).cbz", "Omnibus")]
public void ParseEditionTest(string input, string expected) public void ParseEditionTest(string input, string expected)
{ {

View File

@ -63,6 +63,7 @@ namespace API.Tests.Parser
[InlineData("- The Title", false, "The Title")] [InlineData("- The Title", false, "The Title")]
[InlineData("[Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.1", false, "Kasumi Otoko no Ko v1.1")] [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("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) public void CleanTitleTest(string input, bool isComic, string expected)
{ {
Assert.Equal(expected, CleanTitle(input, isComic)); Assert.Equal(expected, CleanTitle(input, isComic));

View File

@ -364,7 +364,7 @@ public class CleanupServiceTests
#region CleanupBackups #region CleanupBackups
[Fact] [Fact]
public void CleanupBackups_LeaveOneFile_SinceAllAreExpired() public async Task CleanupBackups_LeaveOneFile_SinceAllAreExpired()
{ {
var filesystem = CreateFileSystem(); var filesystem = CreateFileSystem();
var filesystemFile = new MockFileData("") var filesystemFile = new MockFileData("")
@ -378,12 +378,12 @@ public class CleanupServiceTests
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem); var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub,
ds); ds);
cleanupService.CleanupBackups(); await cleanupService.CleanupBackups();
Assert.Single(ds.GetFiles(BackupDirectory, searchOption: SearchOption.AllDirectories)); Assert.Single(ds.GetFiles(BackupDirectory, searchOption: SearchOption.AllDirectories));
} }
[Fact] [Fact]
public void CleanupBackups_LeaveLestExpired() public async Task CleanupBackups_LeaveLestExpired()
{ {
var filesystem = CreateFileSystem(); var filesystem = CreateFileSystem();
var filesystemFile = new MockFileData("") var filesystemFile = new MockFileData("")
@ -400,7 +400,7 @@ public class CleanupServiceTests
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem); var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub,
ds); ds);
cleanupService.CleanupBackups(); await cleanupService.CleanupBackups();
Assert.True(filesystem.File.Exists($"{BackupDirectory}randomfile.zip")); Assert.True(filesystem.File.Exists($"{BackupDirectory}randomfile.zip"));
} }

View File

@ -30,6 +30,7 @@ public class RecommendedController : BaseApiController
userParams ??= new UserParams(); userParams ??= new UserParams();
var series = await _unitOfWork.SeriesRepository.GetQuickReads(user.Id, libraryId, userParams); var series = await _unitOfWork.SeriesRepository.GetQuickReads(user.Id, libraryId, userParams);
Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages);
return Ok(series); return Ok(series);
} }
@ -46,6 +47,7 @@ public class RecommendedController : BaseApiController
userParams ??= new UserParams(); userParams ??= new UserParams();
var series = await _unitOfWork.SeriesRepository.GetHighlyRated(user.Id, libraryId, 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); Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages);
return Ok(series); return Ok(series);
} }
@ -62,6 +64,8 @@ public class RecommendedController : BaseApiController
userParams ??= new UserParams(); userParams ??= new UserParams();
var series = await _unitOfWork.SeriesRepository.GetMoreIn(user.Id, libraryId, genreId, 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); Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages);
return Ok(series); return Ok(series);
} }

View File

@ -342,6 +342,32 @@ namespace API.Controllers
return await _seriesService.GetSeriesDetail(seriesId, userId); return await _seriesService.GetSeriesDetail(seriesId, userId);
} }
/// <summary>
/// Returns the series for the MangaFile id. If the user does not have access (shouldn't happen by the UI),
/// then null is returned
/// </summary>
/// <param name="mangaFileId"></param>
/// <returns></returns>
[HttpGet("series-for-mangafile")]
public async Task<ActionResult<SeriesDto>> GetSeriesForMangaFile(int mangaFileId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.SeriesRepository.GetSeriesForMangaFile(mangaFileId, userId));
}
/// <summary>
/// Returns the series for the Chapter id. If the user does not have access (shouldn't happen by the UI),
/// then null is returned
/// </summary>
/// <param name="chapterId"></param>
/// <returns></returns>
[HttpGet("series-for-chapter")]
public async Task<ActionResult<SeriesDto>> GetSeriesForChapter(int chapterId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.SeriesRepository.GetSeriesForChapter(chapterId, userId));
}
/// <summary> /// <summary>
/// Fetches the related series for a given series /// Fetches the related series for a given series
/// </summary> /// </summary>

View File

@ -4,9 +4,10 @@ namespace API.DTOs
{ {
public class MangaFileDto public class MangaFileDto
{ {
public int Id { get; init; }
public string FilePath { get; init; } public string FilePath { get; init; }
public int Pages { get; init; } public int Pages { get; init; }
public MangaFormat Format { get; init; } public MangaFormat Format { get; init; }
} }
} }

View File

@ -17,5 +17,8 @@ public class SearchResultGroupDto
public IEnumerable<PersonDto> Persons { get; set; } public IEnumerable<PersonDto> Persons { get; set; }
public IEnumerable<GenreTagDto> Genres { get; set; } public IEnumerable<GenreTagDto> Genres { get; set; }
public IEnumerable<TagDto> Tags { get; set; } public IEnumerable<TagDto> Tags { get; set; }
public IEnumerable<MangaFileDto> Files { get; set; }
public IEnumerable<ChapterDto> Chapters { get; set; }
} }

View File

@ -21,7 +21,9 @@ using API.Services.Tasks;
using AutoMapper; using AutoMapper;
using AutoMapper.QueryableExtensions; using AutoMapper.QueryableExtensions;
using Kavita.Common.Extensions; using Kavita.Common.Extensions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using SQLitePCL;
namespace API.Data.Repositories; namespace API.Data.Repositories;
@ -115,6 +117,8 @@ public interface ISeriesRepository
Task<PagedList<SeriesDto>> GetHighlyRated(int userId, int libraryId, UserParams userParams); Task<PagedList<SeriesDto>> GetHighlyRated(int userId, int libraryId, UserParams userParams);
Task<PagedList<SeriesDto>> GetMoreIn(int userId, int libraryId, int genreId, UserParams userParams); Task<PagedList<SeriesDto>> GetMoreIn(int userId, int libraryId, int genreId, UserParams userParams);
Task<PagedList<SeriesDto>> GetRediscover(int userId, int libraryId, UserParams userParams); Task<PagedList<SeriesDto>> GetRediscover(int userId, int libraryId, UserParams userParams);
Task<SeriesDto> GetSeriesForMangaFile(int mangaFileId, int userId);
Task<SeriesDto> GetSeriesForChapter(int chapterId, int userId);
} }
public class SeriesRepository : ISeriesRepository public class SeriesRepository : ISeriesRepository
@ -287,7 +291,7 @@ public class SeriesRepository : ISeriesRepository
public async Task<SearchResultGroupDto> SearchSeries(int userId, bool isAdmin, int[] libraryIds, string searchQuery) public async Task<SearchResultGroupDto> SearchSeries(int userId, bool isAdmin, int[] libraryIds, string searchQuery)
{ {
const int maxRecords = 15;
var result = new SearchResultGroupDto(); var result = new SearchResultGroupDto();
var searchQueryNormalized = Parser.Parser.Normalize(searchQuery); var searchQueryNormalized = Parser.Parser.Normalize(searchQuery);
@ -301,6 +305,7 @@ public class SeriesRepository : ISeriesRepository
.Where(l => EF.Functions.Like(l.Name, $"%{searchQuery}%")) .Where(l => EF.Functions.Like(l.Name, $"%{searchQuery}%"))
.OrderBy(l => l.Name) .OrderBy(l => l.Name)
.AsSplitQuery() .AsSplitQuery()
.Take(maxRecords)
.ProjectTo<LibraryDto>(_mapper.ConfigurationProvider) .ProjectTo<LibraryDto>(_mapper.ConfigurationProvider)
.ToListAsync(); .ToListAsync();
@ -308,7 +313,7 @@ public class SeriesRepository : ISeriesRepository
var hasYearInQuery = !string.IsNullOrEmpty(justYear); var hasYearInQuery = !string.IsNullOrEmpty(justYear);
var yearComparison = hasYearInQuery ? int.Parse(justYear) : 0; var yearComparison = hasYearInQuery ? int.Parse(justYear) : 0;
result.Series = await _context.Series result.Series = _context.Series
.Where(s => libraryIds.Contains(s.LibraryId)) .Where(s => libraryIds.Contains(s.LibraryId))
.Where(s => EF.Functions.Like(s.Name, $"%{searchQuery}%") .Where(s => EF.Functions.Like(s.Name, $"%{searchQuery}%")
|| EF.Functions.Like(s.OriginalName, $"%{searchQuery}%") || EF.Functions.Like(s.OriginalName, $"%{searchQuery}%")
@ -319,14 +324,16 @@ public class SeriesRepository : ISeriesRepository
.OrderBy(s => s.SortName) .OrderBy(s => s.SortName)
.AsNoTracking() .AsNoTracking()
.AsSplitQuery() .AsSplitQuery()
.Take(maxRecords)
.ProjectTo<SearchResultDto>(_mapper.ConfigurationProvider) .ProjectTo<SearchResultDto>(_mapper.ConfigurationProvider)
.ToListAsync(); .AsEnumerable();
result.ReadingLists = await _context.ReadingList result.ReadingLists = await _context.ReadingList
.Where(rl => rl.AppUserId == userId || rl.Promoted) .Where(rl => rl.AppUserId == userId || rl.Promoted)
.Where(rl => EF.Functions.Like(rl.Title, $"%{searchQuery}%")) .Where(rl => EF.Functions.Like(rl.Title, $"%{searchQuery}%"))
.AsSplitQuery() .AsSplitQuery()
.Take(maxRecords)
.ProjectTo<ReadingListDto>(_mapper.ConfigurationProvider) .ProjectTo<ReadingListDto>(_mapper.ConfigurationProvider)
.ToListAsync(); .ToListAsync();
@ -336,6 +343,8 @@ public class SeriesRepository : ISeriesRepository
.Where(s => s.Promoted || isAdmin) .Where(s => s.Promoted || isAdmin)
.OrderBy(s => s.Title) .OrderBy(s => s.Title)
.AsNoTracking() .AsNoTracking()
.AsSplitQuery()
.Take(maxRecords)
.OrderBy(c => c.NormalizedTitle) .OrderBy(c => c.NormalizedTitle)
.ProjectTo<CollectionTagDto>(_mapper.ConfigurationProvider) .ProjectTo<CollectionTagDto>(_mapper.ConfigurationProvider)
.ToListAsync(); .ToListAsync();
@ -344,6 +353,7 @@ public class SeriesRepository : ISeriesRepository
.Where(sm => seriesIds.Contains(sm.SeriesId)) .Where(sm => seriesIds.Contains(sm.SeriesId))
.SelectMany(sm => sm.People.Where(t => EF.Functions.Like(t.Name, $"%{searchQuery}%"))) .SelectMany(sm => sm.People.Where(t => EF.Functions.Like(t.Name, $"%{searchQuery}%")))
.AsSplitQuery() .AsSplitQuery()
.Take(maxRecords)
.Distinct() .Distinct()
.ProjectTo<PersonDto>(_mapper.ConfigurationProvider) .ProjectTo<PersonDto>(_mapper.ConfigurationProvider)
.ToListAsync(); .ToListAsync();
@ -354,6 +364,7 @@ public class SeriesRepository : ISeriesRepository
.AsSplitQuery() .AsSplitQuery()
.OrderBy(t => t.Title) .OrderBy(t => t.Title)
.Distinct() .Distinct()
.Take(maxRecords)
.ProjectTo<GenreTagDto>(_mapper.ConfigurationProvider) .ProjectTo<GenreTagDto>(_mapper.ConfigurationProvider)
.ToListAsync(); .ToListAsync();
@ -363,9 +374,34 @@ public class SeriesRepository : ISeriesRepository
.AsSplitQuery() .AsSplitQuery()
.OrderBy(t => t.Title) .OrderBy(t => t.Title)
.Distinct() .Distinct()
.Take(maxRecords)
.ProjectTo<TagDto>(_mapper.ConfigurationProvider) .ProjectTo<TagDto>(_mapper.ConfigurationProvider)
.ToListAsync(); .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<MangaFileDto>(_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<ChapterDto>(_mapper.ConfigurationProvider)
.ToListAsync();
return result; return result;
} }
@ -1044,6 +1080,33 @@ public class SeriesRepository : ISeriesRepository
return await PagedList<SeriesDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize); return await PagedList<SeriesDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
} }
public async Task<SeriesDto> 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<SeriesDto>(_mapper.ConfigurationProvider)
.SingleOrDefaultAsync();
}
public async Task<SeriesDto> 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<SeriesDto>(_mapper.ConfigurationProvider)
.SingleOrDefaultAsync();
}
public async Task<PagedList<SeriesDto>> GetHighlyRated(int userId, int libraryId, UserParams userParams) public async Task<PagedList<SeriesDto>> GetHighlyRated(int userId, int libraryId, UserParams userParams)
{ {

View File

@ -452,10 +452,6 @@ namespace API.Parser
}; };
private static readonly Regex[] MangaEditionRegex = { private static readonly Regex[] MangaEditionRegex = {
// Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz // Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz
new Regex(
@"(?<Edition>({|\(|\[).* Edition(}|\)|\]))",
MatchOptions, RegexTimeout),
// Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz
new Regex( new Regex(
@"(\b|_)(?<Edition>Omnibus(( |_)?Edition)?)(\b|_)?", @"(\b|_)(?<Edition>Omnibus(( |_)?Edition)?)(\b|_)?",
MatchOptions, RegexTimeout), MatchOptions, RegexTimeout),
@ -463,10 +459,6 @@ namespace API.Parser
new Regex( new Regex(
@"(\b|_)(?<Edition>Uncensored)(\b|_)", @"(\b|_)(?<Edition>Uncensored)(\b|_)",
MatchOptions, RegexTimeout), MatchOptions, RegexTimeout),
// AKIRA - c003 (v01) [Full Color] [Darkhorse].cbz
new Regex(
@"(\b|_)(?<Edition>Full(?: |_)Color)(\b|_)?",
MatchOptions, RegexTimeout),
}; };
private static readonly Regex[] CleanupRegex = private static readonly Regex[] CleanupRegex =

View File

@ -4,6 +4,7 @@ using System.Diagnostics;
using System.IO; using System.IO;
using System.IO.Compression; using System.IO.Compression;
using System.Linq; using System.Linq;
using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Xml.Serialization; using System.Xml.Serialization;
using API.Archive; using API.Archive;

View File

@ -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 normalizedSeries = Parser.Parser.Normalize(info.Series);
var normalizedLocalSeries = Parser.Parser.Normalize(info.LocalizedSeries); 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 = var existingName =
_scannedSeries.SingleOrDefault(p => _scannedSeries.FirstOrDefault(p =>
(Parser.Parser.Normalize(p.Key.NormalizedName) == normalizedSeries || (Parser.Parser.Normalize(p.Key.NormalizedName) == normalizedSeries ||
Parser.Parser.Normalize(p.Key.NormalizedName) == normalizedLocalSeries) && p.Key.Format == info.Format) Parser.Parser.Normalize(p.Key.NormalizedName) == normalizedLocalSeries) && p.Key.Format == info.Format)
.Key; .Key;

View File

@ -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 // 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))) 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, 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", 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())) 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"); _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; return;
} }

View File

@ -1,6 +1,7 @@
import { MangaFormat } from './manga-format'; import { MangaFormat } from './manga-format';
export interface MangaFile { export interface MangaFile {
id: number;
filePath: string; filePath: string;
pages: number; pages: number;
format: MangaFormat; format: MangaFormat;

View File

@ -1,4 +1,6 @@
import { Chapter } from "../chapter";
import { Library } from "../library"; import { Library } from "../library";
import { MangaFile } from "../manga-file";
import { SearchResult } from "../search-result"; import { SearchResult } from "../search-result";
import { Tag } from "../tag"; import { Tag } from "../tag";
@ -10,6 +12,8 @@ export class SearchResultGroup {
persons: Array<Tag> = []; persons: Array<Tag> = [];
genres: Array<Tag> = []; genres: Array<Tag> = [];
tags: Array<Tag> = []; tags: Array<Tag> = [];
files: Array<MangaFile> = [];
chapters: Array<Chapter> = [];
reset() { reset() {
this.libraries = []; this.libraries = [];
@ -19,5 +23,7 @@ export class SearchResultGroup {
this.persons = []; this.persons = [];
this.genres = []; this.genres = [];
this.tags = []; this.tags = [];
this.files = [];
this.chapters = [];
} }
} }

View File

@ -1,17 +1,15 @@
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs'; import { of } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { environment } from 'src/environments/environment'; import { environment } from 'src/environments/environment';
import { UtilityService } from '../shared/_services/utility.service'; 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 { Genre } from '../_models/genre';
import { AgeRating } from '../_models/metadata/age-rating'; import { AgeRating } from '../_models/metadata/age-rating';
import { AgeRatingDto } from '../_models/metadata/age-rating-dto'; import { AgeRatingDto } from '../_models/metadata/age-rating-dto';
import { Language } from '../_models/metadata/language'; import { Language } from '../_models/metadata/language';
import { PublicationStatusDto } from '../_models/metadata/publication-status-dto'; import { PublicationStatusDto } from '../_models/metadata/publication-status-dto';
import { Person, PersonRole } from '../_models/person'; import { Person } from '../_models/person';
import { Tag } from '../_models/tag'; import { Tag } from '../_models/tag';
@Injectable({ @Injectable({

View File

@ -76,8 +76,12 @@ export class SeriesService {
return this.httpClient.get<ChapterMetadata>(this.baseUrl + 'series/chapter-metadata?chapterId=' + chapterId); return this.httpClient.get<ChapterMetadata>(this.baseUrl + 'series/chapter-metadata?chapterId=' + chapterId);
} }
getData(id: number) { getSeriesForMangaFile(mangaFileId: number) {
return of(id); return this.httpClient.get<Series | null>(this.baseUrl + 'series/series-for-mangafile?mangaFileId=' + mangaFileId);
}
getSeriesForChapter(chapterId: number) {
return this.httpClient.get<Series | null>(this.baseUrl + 'series/series-for-chapter?chapterId=' + chapterId);
} }
delete(seriesId: number) { delete(seriesId: number) {

View File

@ -6,7 +6,8 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p> <p>
Invite a user to your server. Enter their email in and we will send them an email to create an account. Invite a user to your server. Enter their email in and we will send them an email to create an account. If you do not want to use our email service, you can host your own
email service or use a fake email (Forgot User will not work). A link will be presented regardless and can be used to setup the email account manually.
</p> </p>
<form [formGroup]="inviteForm" *ngIf="emailLink === ''"> <form [formGroup]="inviteForm" *ngIf="emailLink === ''">
@ -36,7 +37,7 @@
<ng-container *ngIf="emailLink !== ''"> <ng-container *ngIf="emailLink !== ''">
<h4>User invited</h4> <h4>User invited</h4>
<p>You can use the following link below to setup the account for your user or use the copy button. You may need to log out before using the link to register a new user. <p>You can use the following link below to setup the account for your user or use the copy button. You may need to log out before using the link to register a new user.
If your server is externallyaccessible, an email will have been sent to the user and the links can be used by them to finish setting up their account. If your server is externally accessible, an email will have been sent to the user and the links can be used by them to finish setting up their account.
</p> </p>
<a class="email-link" href="{{emailLink}}" target="_blank">Setup user's account</a> <a class="email-link" href="{{emailLink}}" target="_blank">Setup user's account</a>
<app-api-key title="Invite Url" tooltipText="Copy this and paste in a new tab. You may need to log out." [showRefresh]="false" [transform]="makeLink"></app-api-key> <app-api-key title="Invite Url" tooltipText="Copy this and paste in a new tab. You may need to log out." [showRefresh]="false" [transform]="makeLink"></app-api-key>
@ -44,6 +45,10 @@
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<!-- <div class="form-check form-switch">
<input id="stat-collection" type="checkbox" aria-label="Stat Collection" class="form-check-input" formControlName="allowStatCollection" role="switch">
<label for="stat-collection" class="form-check-label">Send Data</label>
</div> -->
<button type="button" class="btn btn-secondary" (click)="close()"> <button type="button" class="btn btn-secondary" (click)="close()">
Cancel Cancel
</button> </button>

View File

@ -42,7 +42,7 @@
<label for="stat-collection" class="form-label" aria-describedby="collection-info">Allow Anonymous Usage Collection</label> <label for="stat-collection" class="form-label" aria-describedby="collection-info">Allow Anonymous Usage Collection</label>
<p class="accent" id="collection-info">Send anonymous usage and error information to Kavita's servers. This includes information on your browser, error reporting as well as OS and runtime version. We will use this information to prioritize features, bug fixes, and preformance tuning. Requires restart to take effect. See <a href="https://wiki.kavitareader.com/en/faq" target="_blank" referrerpolicy="no-refer">wiki</a> for what is collected.</p> <p class="accent" id="collection-info">Send anonymous usage and error information to Kavita's servers. This includes information on your browser, error reporting as well as OS and runtime version. We will use this information to prioritize features, bug fixes, and preformance tuning. Requires restart to take effect. See <a href="https://wiki.kavitareader.com/en/faq" target="_blank" referrerpolicy="no-refer">wiki</a> for what is collected.</p>
<div class="form-check form-switch"> <div class="form-check form-switch">
<input id="stat-collection" type="checkbox" aria-label="Stat Collection" class="form-check-input" formControlName="allowStatCollection"> <input id="stat-collection" type="checkbox" aria-label="Stat Collection" class="form-check-input" formControlName="allowStatCollection" role="switch">
<label for="stat-collection" class="form-check-label">Send Data</label> <label for="stat-collection" class="form-check-label">Send Data</label>
</div> </div>
</div> </div>

View File

@ -77,7 +77,7 @@ const routes: Routes = [
loadChildren: () => import('../app/dev-only/dev-only.module').then(m => m.DevOnlyModule) 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: '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({ @NgModule({

View File

@ -1,4 +1,7 @@
<!-- TODO: If there is nothing, then show a message -->
<ng-container *ngIf="onDeck$ | async as onDeck"> <ng-container *ngIf="onDeck$ | async as onDeck">
<app-carousel-reel [items]="onDeck" title="On Deck"> <app-carousel-reel [items]="onDeck" title="On Deck">
<ng-template #carouselItem let-item let-position="idx"> <ng-template #carouselItem let-item let-position="idx">

View File

@ -45,6 +45,8 @@ export class LibraryRecommendedComponent implements OnInit {
this.genre$.subscribe(genre => { this.genre$.subscribe(genre => {
this.moreIn$ = this.recommendationService.getMoreIn(this.libraryId, genre.id).pipe(map(p => p.result), shareReplay()); this.moreIn$ = this.recommendationService.getMoreIn(this.libraryId, genre.id).pipe(map(p => p.result), shareReplay());
}); });
} }

View File

@ -48,7 +48,7 @@
<ng-container *ngFor="let message of progressUpdates"> <ng-container *ngFor="let message of progressUpdates">
<li class="list-group-item dark-menu-item" *ngIf="message.progress === 'indeterminate' || message.progress === 'none'; else progressEvent"> <li class="list-group-item dark-menu-item" *ngIf="message.progress === 'indeterminate' || message.progress === 'none'; else progressEvent">
<div class="h6 mb-1">{{message.title}}</div> <div class="h6 mb-1">{{message.title}}</div>
<div class="accent-text mb-1" *ngIf="message.subTitle !== ''">{{message.subTitle}}</div> <div class="accent-text mb-1" *ngIf="message.subTitle !== ''" [title]="message.subTitle">{{message.subTitle}}</div>
<div class="progress-container row g-0 align-items-center"> <div class="progress-container row g-0 align-items-center">
<div class="progress" style="height: 5px;" *ngIf="message.progress === 'indeterminate'"> <div class="progress" style="height: 5px;" *ngIf="message.progress === 'indeterminate'">
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 100%" [attr.aria-valuenow]="100" aria-valuemin="0" aria-valuemax="100"></div> <div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 100%" [attr.aria-valuenow]="100" aria-valuemin="0" aria-valuemax="100"></div>

View File

@ -85,8 +85,27 @@
</ul> </ul>
</ng-container> </ng-container>
<ng-container *ngIf="noResultsTemplate != undefined && searchTerm.length > 0 && !grouppedData.persons.length && !grouppedData.collections.length <ng-container *ngIf="chapterTemplate !== undefined && grouppedData.chapters.length > 0">
&& !grouppedData.series.length && !grouppedData.persons.length && !grouppedData.tags.length && !grouppedData.genres.length && !grouppedData.libraries.length"> <li class="list-group-item section-header"><h5>Chapters</h5></li>
<ul class="list-group results">
<li *ngFor="let option of grouppedData.chapters; let index = index;" (click)="handleResultlick(option)" tabindex="0"
class="list-group-item" role="option">
<ng-container [ngTemplateOutlet]="chapterTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
</li>
</ul>
</ng-container>
<ng-container *ngIf="fileTemplate !== undefined && grouppedData.files.length > 0">
<li class="list-group-item section-header"><h5>Files</h5></li>
<ul class="list-group results">
<li *ngFor="let option of grouppedData.files; let index = index;" (click)="handleResultlick(option)" tabindex="0"
class="list-group-item" role="option">
<ng-container [ngTemplateOutlet]="fileTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
</li>
</ul>
</ng-container>
<ng-container *ngIf="!hasData && searchTerm.length > 0">
<ul class="list-group results"> <ul class="list-group results">
<li class="list-group-item"> <li class="list-group-item">
<ng-container [ngTemplateOutlet]="noResultsTemplate"></ng-container> <ng-container [ngTemplateOutlet]="noResultsTemplate"></ng-container>

View File

@ -68,7 +68,7 @@ form {
} }
&.focused { &.focused {
width: 100%; width: 99%;
border-color: var(--input-focused-border-color); border-color: var(--input-focused-border-color);
} }

View File

@ -59,6 +59,8 @@ export class GroupedTypeaheadComponent implements OnInit, OnDestroy {
@ContentChild('noResultsTemplate') noResultsTemplate!: TemplateRef<any>; @ContentChild('noResultsTemplate') noResultsTemplate!: TemplateRef<any>;
@ContentChild('libraryTemplate') libraryTemplate!: TemplateRef<any>; @ContentChild('libraryTemplate') libraryTemplate!: TemplateRef<any>;
@ContentChild('readingListTemplate') readingListTemplate!: TemplateRef<any>; @ContentChild('readingListTemplate') readingListTemplate!: TemplateRef<any>;
@ContentChild('fileTemplate') fileTemplate!: TemplateRef<any>;
@ContentChild('chapterTemplate') chapterTemplate!: TemplateRef<any>;
hasFocus: boolean = false; hasFocus: boolean = false;
@ -74,7 +76,11 @@ export class GroupedTypeaheadComponent implements OnInit, OnDestroy {
} }
get hasData() { 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;
} }

View File

@ -39,7 +39,7 @@
<ng-template #localizedName> <ng-template #localizedName>
<span [innerHTML]="item.localizedName"></span> <span [innerHTML]="item.localizedName"></span>
</ng-template> </ng-template>
<div class="form-text" style="font-size: 0.8rem;">in {{item.libraryName}}</div> <div class="text-light fst-italic" style="font-size: 0.8rem;">in {{item.libraryName}}</div>
</div> </div>
</div> </div>
</ng-template> </ng-template>
@ -84,7 +84,7 @@
<div class="ms-1"> <div class="ms-1">
<div [innerHTML]="item.name"></div> <div [innerHTML]="item.name"></div>
<div>{{item.role | personRole}}</div> <div class="text-light fst-italic">{{item.role | personRole}}</div>
</div> </div>
</div> </div>
</ng-template> </ng-template>
@ -97,6 +97,28 @@
</div> </div>
</ng-template> </ng-template>
<ng-template #chapterTemplate let-item>
<div style="display: flex;padding: 5px;" class="clickable" (click)="clickChapterSearchResult(item)">
<div class="ms-1">
<ng-container *ngIf="item.files.length > 0">
<app-series-format [format]="item.files[0].format"></app-series-format>
</ng-container>
<span>{{item.titleName}}</span>
</div>
</div>
</ng-template>
<ng-template #fileTemplate let-item>
<div style="display: flex;padding: 5px;" (click)="clickFileSearchResult(item)">
<div class="ms-1">
<app-series-format [format]="item.format"></app-series-format>
<span>{{item.filePath}}</span>
</div>
</div>
</ng-template>
<ng-template #noResultsTemplate let-notFound> <ng-template #noResultsTemplate let-notFound>
No results found No results found
</ng-template> </ng-template>

View File

@ -3,7 +3,10 @@ import { Component, HostListener, Inject, OnDestroy, OnInit, ViewChild } from '@
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; 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 { ScrollService } from 'src/app/_services/scroll.service';
import { SeriesService } from 'src/app/_services/series.service';
import { FilterQueryParam } from '../../shared/_services/filter-utilities.service'; import { FilterQueryParam } from '../../shared/_services/filter-utilities.service';
import { CollectionTag } from '../../_models/collection-tag'; import { CollectionTag } from '../../_models/collection-tag';
import { Library } from '../../_models/library'; 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, constructor(public accountService: AccountService, private router: Router, public navService: NavService,
private libraryService: LibraryService, public imageService: ImageService, @Inject(DOCUMENT) private document: Document, private libraryService: LibraryService, public imageService: ImageService, @Inject(DOCUMENT) private document: Document,
private scrollService: ScrollService) { } private scrollService: ScrollService, private seriesService: SeriesService) { }
ngOnInit(): void {} ngOnInit(): void {}
@ -154,6 +157,24 @@ export class NavHeaderComponent implements OnInit, OnDestroy {
this.router.navigate(['library', libraryId, 'series', seriesId]); 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) { clickLibraryResult(item: Library) {
this.router.navigate(['library', item.id]); this.router.navigate(['library', item.id]);
} }

View File

@ -8,6 +8,7 @@ import { SharedModule } from '../shared/shared.module';
import { PipeModule } from '../pipe/pipe.module'; import { PipeModule } from '../pipe/pipe.module';
import { TypeaheadModule } from '../typeahead/typeahead.module'; import { TypeaheadModule } from '../typeahead/typeahead.module';
import { ReactiveFormsModule } from '@angular/forms'; import { ReactiveFormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
@ -20,6 +21,7 @@ import { ReactiveFormsModule } from '@angular/forms';
imports: [ imports: [
CommonModule, CommonModule,
ReactiveFormsModule, ReactiveFormsModule,
RouterModule,
NgbDropdownModule, NgbDropdownModule,
NgbPopoverModule, NgbPopoverModule,

View File

@ -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 { Component, ContentChild, ElementRef, EventEmitter, HostListener, Inject, Input, OnDestroy, OnInit, Output, Renderer2, RendererStyleFlags2, TemplateRef, ViewChild } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms'; import { FormControl, FormGroup } from '@angular/forms';
import { Observable, of, ReplaySubject, Subject } from 'rxjs'; 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 { KEY_CODES } from '../shared/_services/utility.service';
import { SelectionCompareFn, TypeaheadSettings } from './typeahead-settings'; import { SelectionCompareFn, TypeaheadSettings } from './typeahead-settings';
@ -206,26 +206,30 @@ export class TypeaheadComponent implements OnInit, OnDestroy {
'typeahead': this.typeaheadControl 'typeahead': this.typeaheadControl
}); });
this.filteredOptions = this.typeaheadForm.get('typeahead')!.valueChanges this.filteredOptions = this.typeaheadForm.get('typeahead')!.valueChanges
.pipe( .pipe(
// Adjust input box to grow // Adjust input box to grow
tap(val => { tap(val => {
if (this.inputElem != null && this.inputElem.nativeElement != null) { 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; this.focusedIndex = 0;
} }
}), }),
debounceTime(this.settings.debounce), map(val => val.trim()),
auditTime(this.settings.debounce),
distinctUntilChanged(),
filter(val => { filter(val => {
// If minimum filter characters not met, do not filter // If minimum filter characters not met, do not filter
if (this.settings.minCharacters === 0) return true; 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 false;
} }
return true; return true;
}), }),
switchMap(val => { switchMap(val => {
this.isLoadingOptions = true; this.isLoadingOptions = true;
let results: Observable<any[]>; let results: Observable<any[]>;
@ -241,12 +245,12 @@ export class TypeaheadComponent implements OnInit, OnDestroy {
tap((filteredOptions) => { tap((filteredOptions) => {
this.isLoadingOptions = false; this.isLoadingOptions = false;
this.focusedIndex = 0; this.focusedIndex = 0;
//this.updateShowAddItem(filteredOptions);
setTimeout(() => { setTimeout(() => {
this.updateShowAddItem(filteredOptions); this.updateShowAddItem(filteredOptions);
this.updateHighlight(); this.updateHighlight();
}, 10); }, 10);
setTimeout(() => this.updateHighlight(), 20); setTimeout(() => this.updateHighlight(), 20);
}), }),
shareReplay(), shareReplay(),
takeUntil(this.onDestroy) takeUntil(this.onDestroy)