Lots of Bugfixes (#2977)

This commit is contained in:
Joe Milazzo 2024-06-04 17:43:15 -05:00 committed by GitHub
parent 8c629695ef
commit 616ed7a75d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 427 additions and 244 deletions

View File

@ -200,6 +200,8 @@ public class ParsingTests
[InlineData("카비타", "카비타")] [InlineData("카비타", "카비타")]
[InlineData("06", "06")] [InlineData("06", "06")]
[InlineData("", "")] [InlineData("", "")]
[InlineData("不安の種+", "不安の種+")]
[InlineData("不安の種*", "不安の種*")]
public void NormalizeTest(string input, string expected) public void NormalizeTest(string input, string expected)
{ {
Assert.Equal(expected, Normalize(input)); Assert.Equal(expected, Normalize(input));

View File

@ -22,6 +22,7 @@ using API.Extensions;
using API.Helpers; using API.Helpers;
using API.Services; using API.Services;
using API.Services.Tasks.Scanner.Parser; using API.Services.Tasks.Scanner.Parser;
using AutoMapper;
using Kavita.Common; using Kavita.Common;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -42,6 +43,7 @@ public class OpdsController : BaseApiController
private readonly ISeriesService _seriesService; private readonly ISeriesService _seriesService;
private readonly IAccountService _accountService; private readonly IAccountService _accountService;
private readonly ILocalizationService _localizationService; private readonly ILocalizationService _localizationService;
private readonly IMapper _mapper;
private readonly XmlSerializer _xmlSerializer; private readonly XmlSerializer _xmlSerializer;
@ -78,7 +80,8 @@ public class OpdsController : BaseApiController
public OpdsController(IUnitOfWork unitOfWork, IDownloadService downloadService, public OpdsController(IUnitOfWork unitOfWork, IDownloadService downloadService,
IDirectoryService directoryService, ICacheService cacheService, IDirectoryService directoryService, ICacheService cacheService,
IReaderService readerService, ISeriesService seriesService, IReaderService readerService, ISeriesService seriesService,
IAccountService accountService, ILocalizationService localizationService) IAccountService accountService, ILocalizationService localizationService,
IMapper mapper)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_downloadService = downloadService; _downloadService = downloadService;
@ -88,6 +91,7 @@ public class OpdsController : BaseApiController
_seriesService = seriesService; _seriesService = seriesService;
_accountService = accountService; _accountService = accountService;
_localizationService = localizationService; _localizationService = localizationService;
_mapper = mapper;
_xmlSerializer = new XmlSerializer(typeof(Feed)); _xmlSerializer = new XmlSerializer(typeof(Feed));
_xmlOpenSearchSerializer = new XmlSerializer(typeof(OpenSearchDescription)); _xmlOpenSearchSerializer = new XmlSerializer(typeof(OpenSearchDescription));
@ -289,7 +293,7 @@ public class OpdsController : BaseApiController
{ {
var baseUrl = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BaseUrl)).Value; var baseUrl = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BaseUrl)).Value;
var prefix = "/api/opds/"; var prefix = "/api/opds/";
if (!Configuration.DefaultBaseUrl.Equals(baseUrl)) if (!Configuration.DefaultBaseUrl.Equals(baseUrl, StringComparison.InvariantCultureIgnoreCase))
{ {
// We need to update the Prefix to account for baseUrl // We need to update the Prefix to account for baseUrl
prefix = baseUrl + "api/opds/"; prefix = baseUrl + "api/opds/";
@ -849,16 +853,15 @@ public class OpdsController : BaseApiController
var seriesDetail = await _seriesService.GetSeriesDetail(seriesId, userId); var seriesDetail = await _seriesService.GetSeriesDetail(seriesId, userId);
foreach (var volume in seriesDetail.Volumes) foreach (var volume in seriesDetail.Volumes)
{ {
var chapters = (await _unitOfWork.ChapterRepository.GetChaptersAsync(volume.Id)) var chapters = await _unitOfWork.ChapterRepository.GetChaptersAsync(volume.Id, ChapterIncludes.Files);
.OrderBy(x => x.MinNumber, _chapterSortComparerDefaultLast);
foreach (var chapterId in chapters.Select(c => c.Id)) foreach (var chapter in chapters)
{ {
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId); var chapterId = chapter.Id;
var chapterTest = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId); var chapterDto = _mapper.Map<ChapterDto>(chapter);
foreach (var mangaFile in files) foreach (var mangaFile in chapter.Files)
{ {
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volume.Id, chapterId, mangaFile, series, chapterTest, apiKey, prefix, baseUrl)); feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volume.Id, chapterId, mangaFile, series, chapterDto, apiKey, prefix, baseUrl));
} }
} }
@ -867,20 +870,20 @@ public class OpdsController : BaseApiController
foreach (var storylineChapter in seriesDetail.StorylineChapters.Where(c => !c.IsSpecial)) foreach (var storylineChapter in seriesDetail.StorylineChapters.Where(c => !c.IsSpecial))
{ {
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(storylineChapter.Id); var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(storylineChapter.Id);
var chapterTest = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(storylineChapter.Id); var chapterDto = _mapper.Map<ChapterDto>(storylineChapter);
foreach (var mangaFile in files) foreach (var mangaFile in files)
{ {
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, storylineChapter.VolumeId, storylineChapter.Id, mangaFile, series, chapterTest, apiKey, prefix, baseUrl)); feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, storylineChapter.VolumeId, storylineChapter.Id, mangaFile, series, chapterDto, apiKey, prefix, baseUrl));
} }
} }
foreach (var special in seriesDetail.Specials) foreach (var special in seriesDetail.Specials)
{ {
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(special.Id); var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(special.Id);
var chapterTest = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(special.Id); var chapterDto = _mapper.Map<ChapterDto>(special);
foreach (var mangaFile in files) foreach (var mangaFile in files)
{ {
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, special.VolumeId, special.Id, mangaFile, series, chapterTest, apiKey, prefix, baseUrl)); feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, special.VolumeId, special.Id, mangaFile, series, chapterDto, apiKey, prefix, baseUrl));
} }
} }
@ -1127,14 +1130,15 @@ public class OpdsController : BaseApiController
? string.Empty ? string.Empty
: $" Summary: {chapter.Summary}"), : $" Summary: {chapter.Summary}"),
Format = mangaFile.Format.ToString(), Format = mangaFile.Format.ToString(),
Links = new List<FeedLink>() Links =
{ [
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/chapter-cover?chapterId={chapterId}&apiKey={apiKey}"), CreateLink(FeedLinkRelation.Image, FeedLinkType.Image,
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/chapter-cover?chapterId={chapterId}&apiKey={apiKey}"), $"{baseUrl}api/image/chapter-cover?chapterId={chapterId}&apiKey={apiKey}"),
// We can't not include acc link in the feed, panels doesn't work with just page streaming option. We have to block download directly CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image,
accLink, $"{baseUrl}api/image/chapter-cover?chapterId={chapterId}&apiKey={apiKey}"),
await CreatePageStreamLink(series.LibraryId, seriesId, volumeId, chapterId, mangaFile, apiKey, prefix) // We MUST include acc link in the feed, panels doesn't work with just page streaming option. We have to block download directly
}, accLink
],
Content = new FeedEntryContent() Content = new FeedEntryContent()
{ {
Text = fileType, Text = fileType,
@ -1142,6 +1146,12 @@ public class OpdsController : BaseApiController
} }
}; };
var canPageStream = mangaFile.Extension != ".epub";
if (canPageStream)
{
entry.Links.Add(await CreatePageStreamLink(series.LibraryId, seriesId, volumeId, chapterId, mangaFile, apiKey, prefix));
}
return entry; return entry;
} }
@ -1162,7 +1172,7 @@ public class OpdsController : BaseApiController
{ {
var userId = await GetUser(apiKey); var userId = await GetUser(apiKey);
if (pageNumber < 0) return BadRequest(await _localizationService.Translate(userId, "greater-0", "Page")); if (pageNumber < 0) return BadRequest(await _localizationService.Translate(userId, "greater-0", "Page"));
var chapter = await _cacheService.Ensure(chapterId); var chapter = await _cacheService.Ensure(chapterId, true);
if (chapter == null) return BadRequest(await _localizationService.Translate(userId, "cache-file-find")); if (chapter == null) return BadRequest(await _localizationService.Translate(userId, "cache-file-find"));
try try
@ -1188,10 +1198,9 @@ public class OpdsController : BaseApiController
SeriesId = seriesId, SeriesId = seriesId,
VolumeId = volumeId, VolumeId = volumeId,
LibraryId =libraryId LibraryId =libraryId
}, await GetUser(apiKey)); }, userId);
} }
return File(content, MimeTypeMap.GetMimeType(format)); return File(content, MimeTypeMap.GetMimeType(format));
} }
catch (Exception) catch (Exception)
@ -1223,8 +1232,7 @@ public class OpdsController : BaseApiController
{ {
try try
{ {
var user = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); return await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
return user;
} }
catch catch
{ {
@ -1242,12 +1250,14 @@ public class OpdsController : BaseApiController
var link = CreateLink(FeedLinkRelation.Stream, "image/jpeg", var link = CreateLink(FeedLinkRelation.Stream, "image/jpeg",
$"{prefix}{apiKey}/image?libraryId={libraryId}&seriesId={seriesId}&volumeId={volumeId}&chapterId={chapterId}&pageNumber=" + "{pageNumber}"); $"{prefix}{apiKey}/image?libraryId={libraryId}&seriesId={seriesId}&volumeId={volumeId}&chapterId={chapterId}&pageNumber=" + "{pageNumber}");
link.TotalPages = mangaFile.Pages; link.TotalPages = mangaFile.Pages;
link.IsPageStream = true;
if (progress != null) if (progress != null)
{ {
link.LastRead = progress.PageNum; link.LastRead = progress.PageNum;
link.LastReadDate = progress.LastModifiedUtc.ToString("s"); // Adhere to ISO 8601 link.LastReadDate = progress.LastModifiedUtc.ToString("s"); // Adhere to ISO 8601
} }
link.IsPageStream = true;
return link; return link;
} }
@ -1272,20 +1282,22 @@ public class OpdsController : BaseApiController
{ {
Title = title, Title = title,
Icon = $"{prefix}{apiKey}/favicon", Icon = $"{prefix}{apiKey}/favicon",
Links = new List<FeedLink>() Links =
{ [
link, link,
CreateLink(FeedLinkRelation.Start, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}"), CreateLink(FeedLinkRelation.Start, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}"),
CreateLink(FeedLinkRelation.Search, FeedLinkType.AtomSearch, $"{prefix}{apiKey}/search") CreateLink(FeedLinkRelation.Search, FeedLinkType.AtomSearch, $"{prefix}{apiKey}/search")
}, ],
}; };
} }
private string SerializeXml(Feed? feed) private string SerializeXml(Feed? feed)
{ {
if (feed == null) return string.Empty; if (feed == null) return string.Empty;
using var sm = new StringWriter(); using var sm = new StringWriter();
_xmlSerializer.Serialize(sm, feed); _xmlSerializer.Serialize(sm, feed);
return sm.ToString().Replace("utf-16", "utf-8"); // Chunky cannot accept UTF-16 feeds return sm.ToString().Replace("utf-16", "utf-8"); // Chunky cannot accept UTF-16 feeds
} }
} }

View File

@ -50,8 +50,14 @@ public class SearchController : BaseApiController
return Ok(await _unitOfWork.SeriesRepository.GetSeriesForChapter(chapterId, User.GetUserId())); return Ok(await _unitOfWork.SeriesRepository.GetSeriesForChapter(chapterId, User.GetUserId()));
} }
/// <summary>
/// Searches against different entities in the system against a query string
/// </summary>
/// <param name="queryString"></param>
/// <param name="includeChapterAndFiles">Include Chapter and Filenames in the entities. This can slow down the search on larger systems</param>
/// <returns></returns>
[HttpGet("search")] [HttpGet("search")]
public async Task<ActionResult<SearchResultGroupDto>> Search(string queryString) public async Task<ActionResult<SearchResultGroupDto>> Search(string queryString, [FromQuery] bool includeChapterAndFiles = true)
{ {
queryString = Services.Tasks.Scanner.Parser.Parser.CleanQuery(queryString); queryString = Services.Tasks.Scanner.Parser.Parser.CleanQuery(queryString);
@ -63,7 +69,7 @@ public class SearchController : BaseApiController
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
var series = await _unitOfWork.SeriesRepository.SearchSeries(user.Id, isAdmin, var series = await _unitOfWork.SeriesRepository.SearchSeries(user.Id, isAdmin,
libraries, queryString); libraries, queryString, includeChapterAndFiles);
return Ok(series); return Ok(series);
} }

View File

@ -34,7 +34,7 @@ public interface IChapterRepository
Task<ChapterDto?> GetChapterDtoAsync(int chapterId, ChapterIncludes includes = ChapterIncludes.Files); Task<ChapterDto?> GetChapterDtoAsync(int chapterId, ChapterIncludes includes = ChapterIncludes.Files);
Task<ChapterMetadataDto?> GetChapterMetadataDtoAsync(int chapterId, ChapterIncludes includes = ChapterIncludes.Files); Task<ChapterMetadataDto?> GetChapterMetadataDtoAsync(int chapterId, ChapterIncludes includes = ChapterIncludes.Files);
Task<IList<MangaFile>> GetFilesForChapterAsync(int chapterId); Task<IList<MangaFile>> GetFilesForChapterAsync(int chapterId);
Task<IList<Chapter>> GetChaptersAsync(int volumeId); Task<IList<Chapter>> GetChaptersAsync(int volumeId, ChapterIncludes includes = ChapterIncludes.None);
Task<IList<MangaFile>> GetFilesForChaptersAsync(IReadOnlyList<int> chapterIds); Task<IList<MangaFile>> GetFilesForChaptersAsync(IReadOnlyList<int> chapterIds);
Task<string?> GetChapterCoverImageAsync(int chapterId); Task<string?> GetChapterCoverImageAsync(int chapterId);
Task<IList<string>> GetAllCoverImagesAsync(); Task<IList<string>> GetAllCoverImagesAsync();
@ -184,10 +184,11 @@ public class ChapterRepository : IChapterRepository
/// </summary> /// </summary>
/// <param name="volumeId"></param> /// <param name="volumeId"></param>
/// <returns></returns> /// <returns></returns>
public async Task<IList<Chapter>> GetChaptersAsync(int volumeId) public async Task<IList<Chapter>> GetChaptersAsync(int volumeId, ChapterIncludes includes = ChapterIncludes.None)
{ {
return await _context.Chapter return await _context.Chapter
.Where(c => c.VolumeId == volumeId) .Where(c => c.VolumeId == volumeId)
.Includes(includes)
.OrderBy(c => c.SortOrder) .OrderBy(c => c.SortOrder)
.ToListAsync(); .ToListAsync();
} }

View File

@ -91,8 +91,9 @@ public interface ISeriesRepository
/// <param name="isAdmin"></param> /// <param name="isAdmin"></param>
/// <param name="libraryIds"></param> /// <param name="libraryIds"></param>
/// <param name="searchQuery"></param> /// <param name="searchQuery"></param>
/// <param name="includeChapterAndFiles">Includes Files in the Search</param>
/// <returns></returns> /// <returns></returns>
Task<SearchResultGroupDto> SearchSeries(int userId, bool isAdmin, IList<int> libraryIds, string searchQuery); Task<SearchResultGroupDto> SearchSeries(int userId, bool isAdmin, IList<int> libraryIds, string searchQuery, bool includeChapterAndFiles = true);
Task<IEnumerable<Series>> GetSeriesForLibraryIdAsync(int libraryId, SeriesIncludes includes = SeriesIncludes.None); Task<IEnumerable<Series>> GetSeriesForLibraryIdAsync(int libraryId, SeriesIncludes includes = SeriesIncludes.None);
Task<SeriesDto?> GetSeriesDtoByIdAsync(int seriesId, int userId); Task<SeriesDto?> GetSeriesDtoByIdAsync(int seriesId, int userId);
Task<Series?> GetSeriesByIdAsync(int seriesId, SeriesIncludes includes = SeriesIncludes.Volumes | SeriesIncludes.Metadata); Task<Series?> GetSeriesByIdAsync(int seriesId, SeriesIncludes includes = SeriesIncludes.Volumes | SeriesIncludes.Metadata);
@ -353,7 +354,7 @@ public class SeriesRepository : ISeriesRepository
return [libraryId]; return [libraryId];
} }
public async Task<SearchResultGroupDto> SearchSeries(int userId, bool isAdmin, IList<int> libraryIds, string searchQuery) public async Task<SearchResultGroupDto> SearchSeries(int userId, bool isAdmin, IList<int> libraryIds, string searchQuery, bool includeChapterAndFiles = true)
{ {
const int maxRecords = 15; const int maxRecords = 15;
var result = new SearchResultGroupDto(); var result = new SearchResultGroupDto();
@ -452,42 +453,45 @@ public class SeriesRepository : ISeriesRepository
.ProjectTo<TagDto>(_mapper.ConfigurationProvider) .ProjectTo<TagDto>(_mapper.ConfigurationProvider)
.ToListAsync(); .ToListAsync();
var fileIds = _context.Series result.Files = new List<MangaFileDto>();
.Where(s => seriesIds.Contains(s.Id)) result.Chapters = new List<ChapterDto>();
.AsSplitQuery()
.SelectMany(s => s.Volumes)
.SelectMany(v => v.Chapters)
.SelectMany(c => c.Files.Select(f => f.Id));
// Need to check if an admin
var user = await _context.AppUser.FirstAsync(u => u.Id == userId); if (includeChapterAndFiles)
if (await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole))
{ {
result.Files = await _context.MangaFile var fileIds = _context.Series
.Where(m => EF.Functions.Like(m.FilePath, $"%{searchQuery}%") && fileIds.Contains(m.Id)) .Where(s => seriesIds.Contains(s.Id))
.AsSplitQuery() .AsSplitQuery()
.OrderBy(f => f.FilePath) .SelectMany(s => s.Volumes)
.SelectMany(v => v.Chapters)
.SelectMany(c => c.Files.Select(f => f.Id));
// Need to check if an admin
var user = await _context.AppUser.FirstAsync(u => u.Id == userId);
if (await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole))
{
result.Files = await _context.MangaFile
.Where(m => EF.Functions.Like(m.FilePath, $"%{searchQuery}%") && fileIds.Contains(m.Id))
.AsSplitQuery()
.OrderBy(f => f.FilePath)
.Take(maxRecords)
.ProjectTo<MangaFileDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
result.Chapters = await _context.Chapter
.Include(c => c.Files)
.Where(c => EF.Functions.Like(c.TitleName, $"%{searchQuery}%")
|| EF.Functions.Like(c.ISBN, $"%{searchQuery}%")
|| EF.Functions.Like(c.Range, $"%{searchQuery}%")
)
.Where(c => c.Files.All(f => fileIds.Contains(f.Id)))
.AsSplitQuery()
.OrderBy(c => c.TitleName)
.Take(maxRecords) .Take(maxRecords)
.ProjectTo<MangaFileDto>(_mapper.ConfigurationProvider) .ProjectTo<ChapterDto>(_mapper.ConfigurationProvider)
.ToListAsync(); .ToListAsync();
} }
else
{
result.Files = new List<MangaFileDto>();
}
result.Chapters = await _context.Chapter
.Include(c => c.Files)
.Where(c => EF.Functions.Like(c.TitleName, $"%{searchQuery}%")
|| EF.Functions.Like(c.ISBN, $"%{searchQuery}%")
|| EF.Functions.Like(c.Range, $"%{searchQuery}%")
)
.Where(c => c.Files.All(f => fileIds.Contains(f.Id)))
.AsSplitQuery()
.OrderBy(c => c.TitleName)
.Take(maxRecords)
.ProjectTo<ChapterDto>(_mapper.ConfigurationProvider)
.ToListAsync();
return result; return result;
} }
@ -2094,6 +2098,7 @@ public class SeriesRepository : ISeriesRepository
LastScanned = s.LastFolderScanned, LastScanned = s.LastFolderScanned,
SeriesName = s.Name, SeriesName = s.Name,
FolderPath = s.FolderPath, FolderPath = s.FolderPath,
LowestFolderPath = s.LowestFolderPath,
Format = s.Format, Format = s.Format,
LibraryRoots = s.Library.Folders.Select(f => f.Path) LibraryRoots = s.Library.Folders.Select(f => f.Path)
}).ToListAsync(); }).ToListAsync();
@ -2101,7 +2106,7 @@ public class SeriesRepository : ISeriesRepository
var map = new Dictionary<string, IList<SeriesModified>>(); var map = new Dictionary<string, IList<SeriesModified>>();
foreach (var series in info) foreach (var series in info)
{ {
if (series.FolderPath == null) continue; if (string.IsNullOrEmpty(series.FolderPath)) continue;
if (!map.TryGetValue(series.FolderPath, out var value)) if (!map.TryGetValue(series.FolderPath, out var value))
{ {
map.Add(series.FolderPath, new List<SeriesModified>() map.Add(series.FolderPath, new List<SeriesModified>()
@ -2113,6 +2118,20 @@ public class SeriesRepository : ISeriesRepository
{ {
value.Add(series); value.Add(series);
} }
if (string.IsNullOrEmpty(series.LowestFolderPath)) continue;
if (!map.TryGetValue(series.LowestFolderPath, out var value2))
{
map.Add(series.LowestFolderPath, new List<SeriesModified>()
{
series
});
}
else
{
value2.Add(series);
}
} }
return map; return map;

View File

@ -7,17 +7,23 @@ namespace API.Extensions;
public static class ClaimsPrincipalExtensions public static class ClaimsPrincipalExtensions
{ {
private const string NotAuthenticatedMessage = "User is not authenticated";
/// <summary>
/// Get's the authenticated user's username
/// </summary>
/// <remarks>Warning! Username's can contain .. and /, do not use folders or filenames explicitly with the Username</remarks>
/// <param name="user"></param>
/// <returns></returns>
/// <exception cref="KavitaException"></exception>
public static string GetUsername(this ClaimsPrincipal user) public static string GetUsername(this ClaimsPrincipal user)
{ {
var userClaim = user.FindFirst(JwtRegisteredClaimNames.Name); var userClaim = user.FindFirst(JwtRegisteredClaimNames.Name) ?? throw new KavitaException(NotAuthenticatedMessage);
if (userClaim == null) throw new KavitaException("User is not authenticated");
return userClaim.Value; return userClaim.Value;
} }
public static int GetUserId(this ClaimsPrincipal user) public static int GetUserId(this ClaimsPrincipal user)
{ {
var userClaim = user.FindFirst(ClaimTypes.NameIdentifier); var userClaim = user.FindFirst(ClaimTypes.NameIdentifier) ?? throw new KavitaException(NotAuthenticatedMessage);
if (userClaim == null) throw new KavitaException("User is not authenticated");
return int.Parse(userClaim.Value); return int.Parse(userClaim.Value);
} }
} }

View File

@ -847,7 +847,7 @@ public class BookService : IBookService
Filename = Path.GetFileName(filePath), Filename = Path.GetFileName(filePath),
Title = specialName?.Trim() ?? string.Empty, Title = specialName?.Trim() ?? string.Empty,
FullFilePath = Parser.NormalizePath(filePath), FullFilePath = Parser.NormalizePath(filePath),
IsSpecial = false, IsSpecial = Parser.HasSpecialMarker(filePath),
Series = series.Trim(), Series = series.Trim(),
SeriesSort = series.Trim(), SeriesSort = series.Trim(),
Volumes = seriesIndex Volumes = seriesIndex
@ -869,7 +869,7 @@ public class BookService : IBookService
Filename = Path.GetFileName(filePath), Filename = Path.GetFileName(filePath),
Title = epubBook.Title.Trim(), Title = epubBook.Title.Trim(),
FullFilePath = Parser.NormalizePath(filePath), FullFilePath = Parser.NormalizePath(filePath),
IsSpecial = false, IsSpecial = Parser.HasSpecialMarker(filePath),
Series = epubBook.Title.Trim(), Series = epubBook.Title.Trim(),
Volumes = Parser.LooseLeafVolume, Volumes = Parser.LooseLeafVolume,
}; };

View File

@ -322,7 +322,7 @@ public class CacheService : ICacheService
var path = GetCachePath(chapterId); var path = GetCachePath(chapterId);
// NOTE: We can optimize this by extracting and renaming, so we don't need to scan for the files and can do a direct access // NOTE: We can optimize this by extracting and renaming, so we don't need to scan for the files and can do a direct access
var files = _directoryService.GetFilesWithExtension(path, Tasks.Scanner.Parser.Parser.ImageFileExtensions) var files = _directoryService.GetFilesWithExtension(path, Tasks.Scanner.Parser.Parser.ImageFileExtensions)
.OrderByNatural(Path.GetFileNameWithoutExtension) //.OrderByNatural(Path.GetFileNameWithoutExtension) // This is already done in GetPageFromFiles
.ToArray(); .ToArray();
return GetPageFromFiles(files, page); return GetPageFromFiles(files, page);

View File

@ -228,7 +228,7 @@ public class ScrobblingService : IScrobblingService
LibraryId = series.LibraryId, LibraryId = series.LibraryId,
ScrobbleEventType = ScrobbleEventType.Review, ScrobbleEventType = ScrobbleEventType.Review,
AniListId = ExtractId<int?>(series.Metadata.WebLinks, AniListWeblinkWebsite), AniListId = ExtractId<int?>(series.Metadata.WebLinks, AniListWeblinkWebsite),
MalId = ExtractId<long?>(series.Metadata.WebLinks, MalWeblinkWebsite), MalId = GetMalId(series),
AppUserId = userId, AppUserId = userId,
Format = LibraryTypeHelper.GetFormat(series.Library.Type), Format = LibraryTypeHelper.GetFormat(series.Library.Type),
ReviewBody = reviewBody, ReviewBody = reviewBody,
@ -250,7 +250,7 @@ public class ScrobblingService : IScrobblingService
{ {
if (!await _licenseService.HasActiveLicense()) return; if (!await _licenseService.HasActiveLicense()) return;
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library); var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library | SeriesIncludes.ExternalMetadata);
if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist")); if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist"));
_logger.LogInformation("Processing Scrobbling rating event for {UserId} on {SeriesName}", userId, series.Name); _logger.LogInformation("Processing Scrobbling rating event for {UserId} on {SeriesName}", userId, series.Name);
@ -274,22 +274,34 @@ public class ScrobblingService : IScrobblingService
SeriesId = series.Id, SeriesId = series.Id,
LibraryId = series.LibraryId, LibraryId = series.LibraryId,
ScrobbleEventType = ScrobbleEventType.ScoreUpdated, ScrobbleEventType = ScrobbleEventType.ScoreUpdated,
AniListId = ExtractId<int?>(series.Metadata.WebLinks, AniListWeblinkWebsite), // TODO: We can get this also from ExternalSeriesMetadata AniListId = GetAniListId(series),
MalId = ExtractId<long?>(series.Metadata.WebLinks, MalWeblinkWebsite), MalId = GetMalId(series),
AppUserId = userId, AppUserId = userId,
Format = LibraryTypeHelper.GetFormat(series.Library.Type), Format = LibraryTypeHelper.GetFormat(series.Library.Type),
Rating = rating Rating = rating
}; };
_unitOfWork.ScrobbleRepository.Attach(evt); _unitOfWork.ScrobbleRepository.Attach(evt);
await _unitOfWork.CommitAsync(); await _unitOfWork.CommitAsync();
_logger.LogDebug("Added Scrobbling Rating update on {SeriesName} with Userid {UserId} ", series.Name, userId); _logger.LogDebug("Added Scrobbling Rating update on {SeriesName} with Userid {UserId}", series.Name, userId);
}
private static long? GetMalId(Series series)
{
var malId = ExtractId<long?>(series.Metadata.WebLinks, MalWeblinkWebsite);
return malId ?? series.ExternalSeriesMetadata.MalId;
}
private static int? GetAniListId(Series series)
{
var aniListId = ExtractId<int?>(series.Metadata.WebLinks, AniListWeblinkWebsite);
return aniListId ?? series.ExternalSeriesMetadata.AniListId;
} }
public async Task ScrobbleReadingUpdate(int userId, int seriesId) public async Task ScrobbleReadingUpdate(int userId, int seriesId)
{ {
if (!await _licenseService.HasActiveLicense()) return; if (!await _licenseService.HasActiveLicense()) return;
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library); var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library | SeriesIncludes.ExternalMetadata);
if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist")); if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist"));
_logger.LogInformation("Processing Scrobbling reading event for {UserId} on {SeriesName}", userId, series.Name); _logger.LogInformation("Processing Scrobbling reading event for {UserId} on {SeriesName}", userId, series.Name);
@ -321,8 +333,8 @@ public class ScrobblingService : IScrobblingService
SeriesId = series.Id, SeriesId = series.Id,
LibraryId = series.LibraryId, LibraryId = series.LibraryId,
ScrobbleEventType = ScrobbleEventType.ChapterRead, ScrobbleEventType = ScrobbleEventType.ChapterRead,
AniListId = ExtractId<int?>(series.Metadata.WebLinks, AniListWeblinkWebsite), AniListId = GetAniListId(series),
MalId = ExtractId<long?>(series.Metadata.WebLinks, MalWeblinkWebsite), MalId = GetMalId(series),
AppUserId = userId, AppUserId = userId,
VolumeNumber = VolumeNumber =
(int) await _unitOfWork.AppUserProgressRepository.GetHighestFullyReadVolumeForSeries(seriesId, userId), (int) await _unitOfWork.AppUserProgressRepository.GetHighestFullyReadVolumeForSeries(seriesId, userId),
@ -345,7 +357,7 @@ public class ScrobblingService : IScrobblingService
{ {
if (!await _licenseService.HasActiveLicense()) return; if (!await _licenseService.HasActiveLicense()) return;
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library); var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library | SeriesIncludes.ExternalMetadata);
if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist")); if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist"));
_logger.LogInformation("Processing Scrobbling want-to-read event for {UserId} on {SeriesName}", userId, series.Name); _logger.LogInformation("Processing Scrobbling want-to-read event for {UserId} on {SeriesName}", userId, series.Name);
@ -360,8 +372,8 @@ public class ScrobblingService : IScrobblingService
SeriesId = series.Id, SeriesId = series.Id,
LibraryId = series.LibraryId, LibraryId = series.LibraryId,
ScrobbleEventType = onWantToRead ? ScrobbleEventType.AddWantToRead : ScrobbleEventType.RemoveWantToRead, ScrobbleEventType = onWantToRead ? ScrobbleEventType.AddWantToRead : ScrobbleEventType.RemoveWantToRead,
AniListId = ExtractId<int?>(series.Metadata.WebLinks, AniListWeblinkWebsite), AniListId = GetAniListId(series),
MalId = ExtractId<long?>(series.Metadata.WebLinks, MalWeblinkWebsite), MalId = GetMalId(series),
AppUserId = userId, AppUserId = userId,
Format = LibraryTypeHelper.GetFormat(series.Library.Type), Format = LibraryTypeHelper.GetFormat(series.Library.Type),
}; };

View File

@ -394,7 +394,7 @@ public class ReadingListService : IReadingListService
var existingChapterExists = readingList.Items.Select(rli => rli.ChapterId).ToHashSet(); var existingChapterExists = readingList.Items.Select(rli => rli.ChapterId).ToHashSet();
var chaptersForSeries = (await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(chapterIds, ChapterIncludes.Volumes)) var chaptersForSeries = (await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(chapterIds, ChapterIncludes.Volumes))
.OrderBy(c => c.Volume.MinNumber) .OrderBy(c => c.Volume.MinNumber)
.ThenBy(x => x.MinNumber, _chapterSortComparerForInChapterSorting) .ThenBy(x => x.SortOrder)
.ToList(); .ToList();
var index = readingList.Items.Count == 0 ? 0 : lastOrder + 1; var index = readingList.Items.Count == 0 ? 0 : lastOrder + 1;

View File

@ -79,6 +79,7 @@ public class ScannedSeriesResult
public class SeriesModified public class SeriesModified
{ {
public required string? FolderPath { get; set; } public required string? FolderPath { get; set; }
public required string? LowestFolderPath { get; set; }
public required string SeriesName { get; set; } public required string SeriesName { get; set; }
public DateTime LastScanned { get; set; } public DateTime LastScanned { get; set; }
public MangaFormat Format { get; set; } public MangaFormat Format { get; set; }
@ -151,16 +152,28 @@ public class ParseScannedFiles
HasChanged = false HasChanged = false
}); });
} }
else if (seriesPaths.TryGetValue(normalizedPath, out var series) && series.All(s => !string.IsNullOrEmpty(s.LowestFolderPath)))
{
// If there are multiple series inside this path, let's check each of them to see which was modified and only scan those
// This is very helpful for ComicVine libraries by Publisher
foreach (var seriesModified in series)
{
if (HasSeriesFolderNotChangedSinceLastScan(seriesModified, seriesModified.LowestFolderPath!))
{
result.Add(CreateScanResult(directory, folderPath, false, ArraySegment<string>.Empty));
}
else
{
result.Add(CreateScanResult(directory, folderPath, true,
_directoryService.ScanFiles(seriesModified.LowestFolderPath!, fileExtensions, matcher)));
}
}
}
else else
{ {
// For a scan, this is doing everything in the directory loop before the folder Action is called...which leads to no progress indication // For a scan, this is doing everything in the directory loop before the folder Action is called...which leads to no progress indication
result.Add(new ScanResult() result.Add(CreateScanResult(directory, folderPath, true,
{ _directoryService.ScanFiles(directory, fileExtensions)));
Files = _directoryService.ScanFiles(directory, fileExtensions, matcher),
Folder = directory,
LibraryRoot = folderPath,
HasChanged = true
});
} }
} }
@ -175,26 +188,30 @@ public class ParseScannedFiles
if (HasSeriesFolderNotChangedSinceLastScan(seriesPaths, normalizedPath, forceCheck)) if (HasSeriesFolderNotChangedSinceLastScan(seriesPaths, normalizedPath, forceCheck))
{ {
result.Add(new ScanResult() result.Add(CreateScanResult(folderPath, libraryRoot, false, ArraySegment<string>.Empty));
{ }
Files = ArraySegment<string>.Empty, else
Folder = folderPath, {
LibraryRoot = libraryRoot, result.Add(CreateScanResult(folderPath, libraryRoot, true,
HasChanged = false _directoryService.ScanFiles(folderPath, fileExtensions)));
});
} }
result.Add(new ScanResult()
{
Files = _directoryService.ScanFiles(folderPath, fileExtensions),
Folder = folderPath,
LibraryRoot = libraryRoot,
HasChanged = true
});
return result; return result;
} }
private static ScanResult CreateScanResult(string folderPath, string libraryRoot, bool hasChanged,
IList<string> files)
{
return new ScanResult()
{
Files = files,
Folder = folderPath,
LibraryRoot = libraryRoot,
HasChanged = hasChanged
};
}
/// <summary> /// <summary>
/// Attempts to either add a new instance of a series mapping to the _scannedSeries bag or adds to an existing. /// Attempts to either add a new instance of a series mapping to the _scannedSeries bag or adds to an existing.
@ -535,10 +552,29 @@ public class ParseScannedFiles
{ {
if (forceCheck) return false; if (forceCheck) return false;
return seriesPaths.ContainsKey(normalizedFolder) && seriesPaths[normalizedFolder].All(f => f.LastScanned.Truncate(TimeSpan.TicksPerSecond) >= if (seriesPaths.TryGetValue(normalizedFolder, out var v))
_directoryService.GetLastWriteTime(normalizedFolder).Truncate(TimeSpan.TicksPerSecond)); {
return HasAllSeriesFolderNotChangedSinceLastScan(v, normalizedFolder);
}
return false;
} }
private bool HasAllSeriesFolderNotChangedSinceLastScan(IList<SeriesModified> seriesFolders,
string normalizedFolder)
{
return seriesFolders.All(f => HasSeriesFolderNotChangedSinceLastScan(f, normalizedFolder));
}
private bool HasSeriesFolderNotChangedSinceLastScan(SeriesModified seriesModified, string normalizedFolder)
{
return seriesModified.LastScanned.Truncate(TimeSpan.TicksPerSecond) >=
_directoryService.GetLastWriteTime(normalizedFolder)
.Truncate(TimeSpan.TicksPerSecond);
}
/// <summary> /// <summary>
/// Checks if there are any ParserInfos that have a Series that matches the LocalizedSeries field in any other info. If so, /// Checks if there are any ParserInfos that have a Series that matches the LocalizedSeries field in any other info. If so,
/// rewrites the infos with series name instead of the localized name, so they stack. /// rewrites the infos with series name instead of the localized name, so they stack.

View File

@ -12,8 +12,14 @@ public class BookParser(IDirectoryService directoryService, IBookService bookSer
info.ComicInfo = comicInfo; info.ComicInfo = comicInfo;
// We need a special piece of code to override the Series IF there is a special marker in the filename for epub files
if (info.IsSpecial && info.Volumes == "0" && info.ComicInfo.Series != info.Series)
{
info.Series = info.ComicInfo.Series;
}
// This catches when original library type is Manga/Comic and when parsing with non // This catches when original library type is Manga/Comic and when parsing with non
if (Parser.ParseVolume(info.Series, type) != Parser.LooseLeafVolume) // Shouldn't this be info.Volume != DefaultVolume? if (Parser.ParseVolume(info.Series, type) != Parser.LooseLeafVolume)
{ {
var hasVolumeInTitle = !Parser.ParseVolume(info.Title, type) var hasVolumeInTitle = !Parser.ParseVolume(info.Title, type)
.Equals(Parser.LooseLeafVolume); .Equals(Parser.LooseLeafVolume);

View File

@ -103,7 +103,7 @@ public static class Parser
private static readonly Regex CoverImageRegex = new Regex(@"(?<![[a-z]\d])(?:!?)(?<!back)(?<!back_)(?<!back-)(cover|folder)(?![\w\d])", private static readonly Regex CoverImageRegex = new Regex(@"(?<![[a-z]\d])(?:!?)(?<!back)(?<!back_)(?<!back-)(cover|folder)(?![\w\d])",
MatchOptions, RegexTimeout); MatchOptions, RegexTimeout);
private static readonly Regex NormalizeRegex = new Regex(@"[^\p{L}0-9\+!]", private static readonly Regex NormalizeRegex = new Regex(@"[^\p{L}0-9\+!\*]",
MatchOptions, RegexTimeout); MatchOptions, RegexTimeout);
/// <summary> /// <summary>

View File

@ -710,6 +710,12 @@ public class ProcessSeries : IProcessSeries
chapter.SortOrder = info.IssueOrder; chapter.SortOrder = info.IssueOrder;
} }
chapter.Range = chapter.GetNumberTitle(); chapter.Range = chapter.GetNumberTitle();
if (float.TryParse(chapter.Title, out var _))
{
// If we have float based chapters, first scan can have the chapter formatted as Chapter 0.2 - .2 as the title is wrong.
chapter.Title = chapter.GetNumberTitle();
}
} }

View File

@ -358,7 +358,14 @@ public class Startup
app.UseStaticFiles(new StaticFileOptions app.UseStaticFiles(new StaticFileOptions
{ {
ContentTypeProvider = new FileExtensionContentTypeProvider(), // bcmap files needed for PDF reader localizations (https://github.com/Kareadita/Kavita/issues/2970)
ContentTypeProvider = new FileExtensionContentTypeProvider
{
Mappings =
{
[".bcmap"] = "application/octet-stream"
}
},
HttpsCompression = HttpsCompressionMode.Compress, HttpsCompression = HttpsCompressionMode.Compress,
OnPrepareResponse = ctx => OnPrepareResponse = ctx =>
{ {

View File

@ -14,11 +14,11 @@ export class SearchService {
constructor(private httpClient: HttpClient) { } constructor(private httpClient: HttpClient) { }
search(term: string) { search(term: string, includeChapterAndFiles: boolean = false) {
if (term === '') { if (term === '') {
return of(new SearchResultGroup()); return of(new SearchResultGroup());
} }
return this.httpClient.get<SearchResultGroup>(this.baseUrl + 'search/search?queryString=' + encodeURIComponent(term)); return this.httpClient.get<SearchResultGroup>(this.baseUrl + `search/search?includeChapterAndFiles=${includeChapterAndFiles}&queryString=${encodeURIComponent(term)}`);
} }
getSeriesForMangaFile(mangaFileId: number) { getSeriesForMangaFile(mangaFileId: number) {

View File

@ -11,7 +11,6 @@ import {forkJoin} from "rxjs";
import {ToastrService} from "ngx-toastr"; import {ToastrService} from "ngx-toastr";
import {DecimalPipe} from "@angular/common"; import {DecimalPipe} from "@angular/common";
import {LoadingComponent} from "../../../shared/loading/loading.component"; import {LoadingComponent} from "../../../shared/loading/loading.component";
import {AccountService} from "../../../_services/account.service";
import {ConfirmService} from "../../../shared/confirm.service"; import {ConfirmService} from "../../../shared/confirm.service";
@Component({ @Component({

View File

@ -5,126 +5,166 @@
<input #input [id]="id" type="text" inputmode="search" autocomplete="off" formControlName="typeahead" [placeholder]="placeholder" <input #input [id]="id" type="text" inputmode="search" autocomplete="off" formControlName="typeahead" [placeholder]="placeholder"
aria-haspopup="listbox" aria-owns="dropdown" aria-haspopup="listbox" aria-owns="dropdown"
[attr.aria-expanded]="hasFocus && hasData" [attr.aria-expanded]="hasFocus && hasData"
aria-autocomplete="list" (focusout)="close($event)" (focus)="open($event)" role="search" aria-autocomplete="list" (focusout)="close($event)" (focus)="open($event)" role="searchbox"
> >
<div class="spinner-border spinner-border-sm" role="status" *ngIf="isLoading"> @if (searchTerm.length > 0) {
<span class="visually-hidden">{{t('loading')}}</span> @if (isLoading) {
</div> <div class="spinner-border spinner-border-sm" role="status">
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="resetField()" *ngIf="typeaheadForm.get('typeahead')?.value.length > 0"></button> <span class="visually-hidden">{{t('loading')}}</span>
</div>
} @else {
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="resetField()"></button>
}
}
</div> </div>
</div> </div>
<div class="dropdown" *ngIf="hasFocus"> @if (hasFocus) {
<ul class="list-group" role="listbox" id="dropdown"> <div class="dropdown">
<ng-container *ngIf="seriesTemplate !== undefined && groupedData.series.length > 0"> <ul class="list-group" role="listbox" id="dropdown">
<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">
<li *ngFor="let option of groupedData.series; let index = index;" (click)="handleResultlick(option)" tabindex="0"
class="list-group-item" aria-labelledby="series-group" role="option">
<ng-container [ngTemplateOutlet]="seriesTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
</li>
</ul>
</ng-container>
<ng-container *ngIf="collectionTemplate !== undefined && groupedData.collections.length > 0"> @if (seriesTemplate !== undefined && groupedData.series.length > 0) {
<li class="list-group-item section-header"><h5>{{t('collections')}}</h5></li> <li class="list-group-item section-header"><h5 id="series-group">Series</h5></li>
<ul class="list-group results"> <ul class="list-group results" role="group" aria-describedby="series-group">
<li *ngFor="let option of groupedData.collections; let index = index;" (click)="handleResultlick(option)" tabindex="0" @for(option of groupedData.series; track option; let index = $index) {
class="list-group-item" role="option"> <li (click)="handleResultClick(option)" tabindex="0"
<ng-container [ngTemplateOutlet]="collectionTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container> class="list-group-item" aria-labelledby="series-group" role="option">
</li> <ng-container [ngTemplateOutlet]="seriesTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
</ul> </li>
</ng-container> }
</ul>
}
<ng-container *ngIf="readingListTemplate !== undefined && groupedData.readingLists.length > 0">
<li class="list-group-item section-header"><h5>{{t('reading-lists')}}</h5></li>
<ul class="list-group results">
<li *ngFor="let option of groupedData.readingLists; let index = index;" (click)="handleResultlick(option)" tabindex="0"
class="list-group-item" role="option">
<ng-container [ngTemplateOutlet]="readingListTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
</li>
</ul>
</ng-container>
<ng-container *ngIf="bookmarkTemplate !== undefined && groupedData.bookmarks.length > 0"> @if (collectionTemplate !== undefined && groupedData.collections.length > 0) {
<li class="list-group-item section-header"><h5>{{t('bookmarks')}}</h5></li> <li class="list-group-item section-header"><h5>{{t('collections')}}</h5></li>
<ul class="list-group results"> <ul class="list-group results">
<li *ngFor="let option of groupedData.bookmarks; let index = index;" (click)="handleResultlick(option)" tabindex="0" @for(option of groupedData.collections; track option; let index = $index) {
class="list-group-item" role="option"> <li (click)="handleResultClick(option)" tabindex="0"
<ng-container [ngTemplateOutlet]="bookmarkTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container> class="list-group-item" role="option">
</li> <ng-container [ngTemplateOutlet]="collectionTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
</ul> </li>
</ng-container> }
</ul>
}
<ng-container *ngIf="libraryTemplate !== undefined && groupedData.libraries.length > 0">
<li class="list-group-item section-header"><h5 id="libraries-group">{{t('libraries')}}</h5></li>
<ul class="list-group results" role="group" aria-describedby="libraries-group">
<li *ngFor="let option of groupedData.libraries; let index = index;" (click)="handleResultlick(option)" tabindex="0"
class="list-group-item" aria-labelledby="libraries-group" role="option">
<ng-container [ngTemplateOutlet]="libraryTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
</li>
</ul>
</ng-container>
<ng-container *ngIf="genreTemplate !== undefined && groupedData.genres.length > 0"> @if (readingListTemplate !== undefined && groupedData.readingLists.length > 0) {
<li class="list-group-item section-header"><h5>{{t('genres')}}</h5></li> <li class="list-group-item section-header"><h5>{{t('reading-lists')}}</h5></li>
<ul class="list-group results"> <ul class="list-group results">
<li *ngFor="let option of groupedData.genres; let index = index;" (click)="handleResultlick(option)" tabindex="0" @for(option of groupedData.readingLists; track option; let index = $index) {
class="list-group-item" role="option"> <li (click)="handleResultClick(option)" tabindex="0"
<ng-container [ngTemplateOutlet]="genreTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container> class="list-group-item" role="option">
</li> <ng-container [ngTemplateOutlet]="readingListTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
</ul> </li>
</ng-container> }
</ul>
}
<ng-container *ngIf="tagTemplate !== undefined && groupedData.tags.length > 0"> @if (bookmarkTemplate !== undefined && groupedData.bookmarks.length > 0) {
<li class="list-group-item section-header"><h5>{{t('tags')}}</h5></li> <li class="list-group-item section-header"><h5>{{t('bookmarks')}}</h5></li>
<ul class="list-group results"> <ul class="list-group results">
<li *ngFor="let option of groupedData.tags; let index = index;" (click)="handleResultlick(option)" tabindex="0" @for(option of groupedData.bookmarks; track option; let index = $index) {
class="list-group-item" role="option"> <li (click)="handleResultClick(option)" tabindex="0"
<ng-container [ngTemplateOutlet]="tagTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container> class="list-group-item" role="option">
</li> <ng-container [ngTemplateOutlet]="bookmarkTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
</ul> </li>
</ng-container> }
</ul>
}
<ng-container *ngIf="personTemplate !== undefined && groupedData.persons.length > 0">
<li class="list-group-item section-header"><h5>{{t('people')}}</h5></li>
<ul class="list-group results">
<li *ngFor="let option of groupedData.persons; let index = index;" (click)="handleResultlick(option)" tabindex="0"
class="list-group-item" role="option">
<ng-container [ngTemplateOutlet]="personTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
</li>
</ul>
</ng-container>
<ng-container *ngIf="chapterTemplate !== undefined && groupedData.chapters.length > 0"> @if (libraryTemplate !== undefined && groupedData.libraries.length > 0) {
<li class="list-group-item section-header"><h5>{{t('chapters')}}</h5></li> <li class="list-group-item section-header"><h5 id="libraries-group">{{t('libraries')}}</h5></li>
<ul class="list-group results"> <ul class="list-group results" role="group" aria-describedby="libraries-group">
<li *ngFor="let option of groupedData.chapters; let index = index;" (click)="handleResultlick(option)" tabindex="0" @for(option of groupedData.libraries; track option; let index = $index) {
class="list-group-item" role="option"> <li (click)="handleResultClick(option)" tabindex="0"
<ng-container [ngTemplateOutlet]="chapterTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container> class="list-group-item" aria-labelledby="libraries-group" role="option">
</li> <ng-container [ngTemplateOutlet]="libraryTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
</ul> </li>
</ng-container> }
</ul>
}
<ng-container *ngIf="fileTemplate !== undefined && groupedData.files.length > 0"> @if (genreTemplate !== undefined && groupedData.genres.length > 0) {
<li class="list-group-item section-header"><h5>{{t('files')}}</h5></li> <li class="list-group-item section-header"><h5>{{t('genres')}}</h5></li>
<ul class="list-group results"> <ul class="list-group results">
<li *ngFor="let option of groupedData.files; let index = index;" (click)="handleResultlick(option)" tabindex="0" @for(option of groupedData.genres; track option; let index = $index) {
class="list-group-item" role="option"> <li (click)="handleResultClick(option)" tabindex="0"
<ng-container [ngTemplateOutlet]="fileTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container> class="list-group-item" role="option">
</li> <ng-container [ngTemplateOutlet]="genreTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
</ul> </li>
</ng-container> }
</ul>
}
<ng-container *ngIf="!hasData && searchTerm.length > 0"> @if (tagTemplate !== undefined && groupedData.tags.length > 0) {
<ul class="list-group results"> <li class="list-group-item section-header"><h5>{{t('tags')}}</h5></li>
<li class="list-group-item"> <ul class="list-group results">
<ng-container [ngTemplateOutlet]="noResultsTemplate"></ng-container> @for(option of groupedData.tags; track option; let index = $index) {
</li> <li (click)="handleResultClick(option)" tabindex="0"
</ul> class="list-group-item" role="option">
<ng-container [ngTemplateOutlet]="tagTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
</li>
}
</ul>
}
</ng-container> @if (personTemplate !== undefined && groupedData.persons.length > 0) {
</ul> <li class="list-group-item section-header"><h5>{{t('people')}}</h5></li>
</div> <ul class="list-group results">
@for(option of groupedData.persons; track option; let index = $index) {
<li (click)="handleResultClick(option)" tabindex="0"
class="list-group-item" role="option">
<ng-container [ngTemplateOutlet]="personTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
</li>
}
</ul>
}
@if (chapterTemplate !== undefined && groupedData.chapters.length > 0) {
<li class="list-group-item section-header"><h5>{{t('chapters')}}</h5></li>
<ul class="list-group results">
@for(option of groupedData.chapters; track option; let index = $index) {
<li (click)="handleResultClick(option)" tabindex="0"
class="list-group-item" role="option">
<ng-container [ngTemplateOutlet]="chapterTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
</li>
}
</ul>
}
@if (fileTemplate !== undefined && groupedData.files.length > 0) {
<li class="list-group-item section-header"><h5>{{t('files')}}</h5></li>
<ul class="list-group results">
@for(option of groupedData.files; track option; let index = $index) {
<li (click)="handleResultClick(option)" tabindex="0"
class="list-group-item" role="option">
<ng-container [ngTemplateOutlet]="fileTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
</li>
}
</ul>
}
@if (!hasData && searchTerm.length > 0 && !isLoading) {
<ul class="list-group results">
<li class="list-group-item">
<ng-container [ngTemplateOutlet]="noResultsTemplate"></ng-container>
</li>
</ul>
}
@if (hasData && !includeChapterAndFiles) {
<li class="list-group-item" style="min-height: 34px">
<ng-container [ngTemplateOutlet]="extraTemplate"></ng-container>
<a href="javascript:void(0)" (click)="toggleIncludeFiles()" class="float-end">
{{t('include-extras')}}
</a>
</li>
}
</ul>
</div>
}
</form> </form>

View File

@ -18,8 +18,14 @@ import { debounceTime } from 'rxjs/operators';
import { KEY_CODES } from 'src/app/shared/_services/utility.service'; import { KEY_CODES } from 'src/app/shared/_services/utility.service';
import { SearchResultGroup } from 'src/app/_models/search/search-result-group'; import { SearchResultGroup } from 'src/app/_models/search/search-result-group';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { NgClass, NgIf, NgFor, NgTemplateOutlet } from '@angular/common'; import { NgClass, NgTemplateOutlet } from '@angular/common';
import {TranslocoDirective} from "@ngneat/transloco"; import {TranslocoDirective} from "@ngneat/transloco";
import {LoadingComponent} from "../../../shared/loading/loading.component";
export interface SearchEvent {
value: string;
includeFiles: boolean;
}
@Component({ @Component({
selector: 'app-grouped-typeahead', selector: 'app-grouped-typeahead',
@ -27,9 +33,12 @@ import {TranslocoDirective} from "@ngneat/transloco";
styleUrls: ['./grouped-typeahead.component.scss'], styleUrls: ['./grouped-typeahead.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [ReactiveFormsModule, NgClass, NgIf, NgFor, NgTemplateOutlet, TranslocoDirective] imports: [ReactiveFormsModule, NgClass, NgTemplateOutlet, TranslocoDirective, LoadingComponent]
}) })
export class GroupedTypeaheadComponent implements OnInit { export class GroupedTypeaheadComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef);
private readonly cdRef = inject(ChangeDetectorRef);
/** /**
* Unique id to tie with a label element * Unique id to tie with a label element
*/ */
@ -47,6 +56,10 @@ export class GroupedTypeaheadComponent implements OnInit {
* Placeholder for the input * Placeholder for the input
*/ */
@Input() placeholder: string = ''; @Input() placeholder: string = '';
/**
* When the search is active
*/
@Input() isLoading: boolean = false;
/** /**
* Number of milliseconds after typing before triggering inputChanged for data fetching * Number of milliseconds after typing before triggering inputChanged for data fetching
*/ */
@ -54,7 +67,7 @@ export class GroupedTypeaheadComponent implements OnInit {
/** /**
* Emits when the input changes from user interaction * Emits when the input changes from user interaction
*/ */
@Output() inputChanged: EventEmitter<string> = new EventEmitter(); @Output() inputChanged: EventEmitter<SearchEvent> = new EventEmitter();
/** /**
* Emits when something is clicked/selected * Emits when something is clicked/selected
*/ */
@ -76,17 +89,18 @@ export class GroupedTypeaheadComponent implements OnInit {
@ContentChild('personTemplate') personTemplate: TemplateRef<any> | undefined; @ContentChild('personTemplate') personTemplate: TemplateRef<any> | undefined;
@ContentChild('genreTemplate') genreTemplate!: TemplateRef<any>; @ContentChild('genreTemplate') genreTemplate!: TemplateRef<any>;
@ContentChild('noResultsTemplate') noResultsTemplate!: TemplateRef<any>; @ContentChild('noResultsTemplate') noResultsTemplate!: TemplateRef<any>;
@ContentChild('extraTemplate') extraTemplate!: 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('fileTemplate') fileTemplate!: TemplateRef<any>;
@ContentChild('chapterTemplate') chapterTemplate!: TemplateRef<any>; @ContentChild('chapterTemplate') chapterTemplate!: TemplateRef<any>;
@ContentChild('bookmarkTemplate') bookmarkTemplate!: TemplateRef<any>; @ContentChild('bookmarkTemplate') bookmarkTemplate!: TemplateRef<any>;
private readonly destroyRef = inject(DestroyRef);
hasFocus: boolean = false; hasFocus: boolean = false;
isLoading: boolean = false;
typeaheadForm: FormGroup = new FormGroup({}); typeaheadForm: FormGroup = new FormGroup({});
includeChapterAndFiles: boolean = false;
prevSearchTerm: string = ''; prevSearchTerm: string = '';
@ -101,8 +115,6 @@ export class GroupedTypeaheadComponent implements OnInit {
} }
constructor(private readonly cdRef: ChangeDetectorRef) { }
@HostListener('window:click', ['$event']) @HostListener('window:click', ['$event'])
handleDocumentClick(event: any) { handleDocumentClick(event: any) {
this.close(); this.close();
@ -127,7 +139,10 @@ export class GroupedTypeaheadComponent implements OnInit {
this.typeaheadForm.addControl('typeahead', new FormControl(this.initialValue, [])); this.typeaheadForm.addControl('typeahead', new FormControl(this.initialValue, []));
this.cdRef.markForCheck(); this.cdRef.markForCheck();
this.typeaheadForm.valueChanges.pipe(debounceTime(this.debounceTime), takeUntilDestroyed(this.destroyRef)).subscribe(change => { this.typeaheadForm.valueChanges.pipe(
debounceTime(this.debounceTime),
takeUntilDestroyed(this.destroyRef)
).subscribe(change => {
const value = this.typeaheadForm.get('typeahead')?.value; const value = this.typeaheadForm.get('typeahead')?.value;
if (value != undefined && value != '' && !this.hasFocus) { if (value != undefined && value != '' && !this.hasFocus) {
@ -138,7 +153,7 @@ export class GroupedTypeaheadComponent implements OnInit {
if (value != undefined && value.length >= this.minQueryLength) { if (value != undefined && value.length >= this.minQueryLength) {
if (this.prevSearchTerm === value) return; if (this.prevSearchTerm === value) return;
this.inputChanged.emit(value); this.inputChanged.emit({value, includeFiles: this.includeChapterAndFiles});
this.prevSearchTerm = value; this.prevSearchTerm = value;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
} }
@ -164,10 +179,20 @@ export class GroupedTypeaheadComponent implements OnInit {
}); });
} }
handleResultlick(item: any) { handleResultClick(item: any) {
this.selected.emit(item); this.selected.emit(item);
} }
toggleIncludeFiles() {
this.includeChapterAndFiles = true;
this.inputChanged.emit({value: this.searchTerm, includeFiles: this.includeChapterAndFiles});
this.hasFocus = true;
this.inputElem.nativeElement.focus();
this.openDropdown();
this.cdRef.markForCheck();
}
resetField() { resetField() {
this.prevSearchTerm = ''; this.prevSearchTerm = '';
this.typeaheadForm.get('typeahead')?.setValue(this.initialValue); this.typeaheadForm.get('typeahead')?.setValue(this.initialValue);

View File

@ -16,6 +16,7 @@
#search #search
id="nav-search" id="nav-search"
[minQueryLength]="2" [minQueryLength]="2"
[isLoading]="isLoading"
initialValue="" initialValue=""
[placeholder]="t('search-alt')" [placeholder]="t('search-alt')"
[groupedData]="searchResults" [groupedData]="searchResults"
@ -147,7 +148,6 @@
<ng-template #noResultsTemplate let-notFound> <ng-template #noResultsTemplate let-notFound>
{{t('no-data')}} {{t('no-data')}}
</ng-template> </ng-template>
</app-grouped-typeahead> </app-grouped-typeahead>
</div> </div>
</div> </div>

View File

@ -33,7 +33,7 @@ import {NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle} from '
import {EventsWidgetComponent} from '../events-widget/events-widget.component'; import {EventsWidgetComponent} from '../events-widget/events-widget.component';
import {SeriesFormatComponent} from '../../../shared/series-format/series-format.component'; import {SeriesFormatComponent} from '../../../shared/series-format/series-format.component';
import {ImageComponent} from '../../../shared/image/image.component'; import {ImageComponent} from '../../../shared/image/image.component';
import {GroupedTypeaheadComponent} from '../grouped-typeahead/grouped-typeahead.component'; import {GroupedTypeaheadComponent, SearchEvent} from '../grouped-typeahead/grouped-typeahead.component';
import {TranslocoDirective} from "@ngneat/transloco"; import {TranslocoDirective} from "@ngneat/transloco";
import {FilterUtilitiesService} from "../../../shared/_services/filter-utilities.service"; import {FilterUtilitiesService} from "../../../shared/_services/filter-utilities.service";
import {FilterStatement} from "../../../_models/metadata/v2/filter-statement"; import {FilterStatement} from "../../../_models/metadata/v2/filter-statement";
@ -66,7 +66,6 @@ export class NavHeaderComponent implements OnInit {
searchResults: SearchResultGroup = new SearchResultGroup(); searchResults: SearchResultGroup = new SearchResultGroup();
searchTerm = ''; searchTerm = '';
backToTopNeeded = false; backToTopNeeded = false;
searchFocused: boolean = false; searchFocused: boolean = false;
scrollElem: HTMLElement; scrollElem: HTMLElement;
@ -121,12 +120,14 @@ export class NavHeaderComponent implements OnInit {
onChangeSearch(val: string) {
onChangeSearch(evt: SearchEvent) {
this.isLoading = true; this.isLoading = true;
this.searchTerm = val.trim(); this.searchTerm = evt.value.trim();
this.cdRef.markForCheck(); this.cdRef.markForCheck();
this.searchService.search(val.trim()).pipe(takeUntilDestroyed(this.destroyRef)).subscribe(results => { this.searchService.search(this.searchTerm, evt.includeFiles).pipe(takeUntilDestroyed(this.destroyRef)).subscribe(results => {
this.searchResults = results; this.searchResults = results;
this.isLoading = false; this.isLoading = false;
this.cdRef.markForCheck(); this.cdRef.markForCheck();

View File

@ -34,7 +34,6 @@ import {SpreadType} from "ngx-extended-pdf-viewer/lib/options/spread-type";
import {PdfLayoutModePipe} from "../../_pipe/pdf-layout-mode.pipe"; import {PdfLayoutModePipe} from "../../_pipe/pdf-layout-mode.pipe";
import {PdfScrollModePipe} from "../../_pipe/pdf-scroll-mode.pipe"; import {PdfScrollModePipe} from "../../_pipe/pdf-scroll-mode.pipe";
import {PdfSpreadModePipe} from "../../_pipe/pdf-spread-mode.pipe"; import {PdfSpreadModePipe} from "../../_pipe/pdf-spread-mode.pipe";
import {HandtoolChanged} from "ngx-extended-pdf-viewer/lib/events/handtool-changed";
@Component({ @Component({
selector: 'app-pdf-reader', selector: 'app-pdf-reader',

View File

@ -94,7 +94,7 @@ export class SeriesMetadataDetailComponent implements OnChanges, OnInit {
ngOnInit() { ngOnInit() {
// If on desktop, we can just have all the data expanded by default: // If on desktop, we can just have all the data expanded by default:
this.isCollapsed = this.utilityService.getActiveBreakpoint() < Breakpoint.Desktop; this.isCollapsed = true; // this.utilityService.getActiveBreakpoint() < Breakpoint.Desktop;
// Check if there is a lot of extended data, if so, re-collapse // Check if there is a lot of extended data, if so, re-collapse
const sum = (this.seriesMetadata.colorists.length + this.seriesMetadata.editors.length const sum = (this.seriesMetadata.colorists.length + this.seriesMetadata.editors.length
+ this.seriesMetadata.coverArtists.length + this.seriesMetadata.inkers.length + this.seriesMetadata.coverArtists.length + this.seriesMetadata.inkers.length

View File

@ -133,7 +133,7 @@ export class UtilityService {
); );
} }
deepEqual(object1: any, object2: any) { deepEqual(object1: any | undefined | null, object2: any | undefined | null) {
if ((object1 === null || object1 === undefined) && (object2 !== null || object2 !== undefined)) return false; if ((object1 === null || object1 === undefined) && (object2 !== null || object2 !== undefined)) return false;
if ((object2 === null || object2 === undefined) && (object1 !== null || object1 !== undefined)) return false; if ((object2 === null || object2 === undefined) && (object1 !== null || object1 !== undefined)) return false;
if (object1 === null && object2 === null) return true; if (object1 === null && object2 === null) return true;

View File

@ -65,7 +65,6 @@ export class SideNavComponent implements OnInit {
homeActions = [ homeActions = [
{action: Action.Edit, title: 'customize', children: [], requiresAdmin: false, callback: this.openCustomize.bind(this)}, {action: Action.Edit, title: 'customize', children: [], requiresAdmin: false, callback: this.openCustomize.bind(this)},
{action: Action.Import, title: 'import-cbl', children: [], requiresAdmin: true, callback: this.importCbl.bind(this)}, {action: Action.Import, title: 'import-cbl', children: [], requiresAdmin: true, callback: this.importCbl.bind(this)},
{action: Action.Import, title: 'import-mal-stack', children: [], requiresAdmin: true, callback: this.importMalCollection.bind(this)}, // This requires the Collection Rework (https://github.com/Kareadita/Kavita/issues/2810)
]; ];
filterQuery: string = ''; filterQuery: string = '';
@ -144,6 +143,13 @@ export class SideNavComponent implements OnInit {
this.navService.toggleSideNav(); this.navService.toggleSideNav();
this.cdRef.markForCheck(); this.cdRef.markForCheck();
}); });
this.accountService.hasValidLicense$.subscribe(res =>{
if (!res) return;
this.homeActions.push({action: Action.Import, title: 'import-mal-stack', children: [], requiresAdmin: true, callback: this.importMalCollection.bind(this)});
this.cdRef.markForCheck();
})
} }
ngOnInit(): void { ngOnInit(): void {

View File

@ -1537,8 +1537,8 @@
"reading-lists": "Reading Lists", "reading-lists": "Reading Lists",
"collections": "Collections", "collections": "Collections",
"close": "{{common.close}}", "close": "{{common.close}}",
"loading": "{{common.loading}}" "loading": "{{common.loading}}",
"include-extras": "Include Chapters & Files"
}, },
"nav-header": { "nav-header": {
@ -1605,7 +1605,7 @@
"description": "Import your MAL Interest Stacks and create Collections within Kavita", "description": "Import your MAL Interest Stacks and create Collections within Kavita",
"series-count": "{{common.series-count}}", "series-count": "{{common.series-count}}",
"restack-count": "{{num}} Restacks", "restack-count": "{{num}} Restacks",
"nothing-found": "" "nothing-found": "Nothing found"
}, },
"edit-chapter-progress": { "edit-chapter-progress": {