From 616ed7a75d33fe1952260a8847f30c9839b7c9cd Mon Sep 17 00:00:00 2001 From: Joe Milazzo Date: Tue, 4 Jun 2024 17:43:15 -0500 Subject: [PATCH] Lots of Bugfixes (#2977) --- API.Tests/Parsing/ParsingTests.cs | 2 + API/Controllers/OPDSController.cs | 72 ++--- API/Controllers/SearchController.cs | 10 +- API/Data/Repositories/ChapterRepository.cs | 5 +- API/Data/Repositories/SeriesRepository.cs | 85 +++--- API/Extensions/ClaimsPrincipalExtensions.cs | 14 +- API/Services/BookService.cs | 4 +- API/Services/CacheService.cs | 2 +- API/Services/Plus/ScrobblingService.cs | 34 ++- API/Services/ReadingListService.cs | 2 +- .../Tasks/Scanner/ParseScannedFiles.cs | 82 ++++-- .../Tasks/Scanner/Parser/BookParser.cs | 8 +- API/Services/Tasks/Scanner/Parser/Parser.cs | 2 +- API/Services/Tasks/Scanner/ProcessSeries.cs | 6 + API/Startup.cs | 9 +- UI/Web/src/app/_services/search.service.ts | 4 +- .../import-mal-collection-modal.component.ts | 1 - .../grouped-typeahead.component.html | 252 ++++++++++-------- .../grouped-typeahead.component.ts | 45 +++- .../nav-header/nav-header.component.html | 2 +- .../nav-header/nav-header.component.ts | 11 +- .../pdf-reader/pdf-reader.component.ts | 1 - .../series-metadata-detail.component.ts | 2 +- .../app/shared/_services/utility.service.ts | 2 +- .../side-nav/side-nav.component.ts | 8 +- UI/Web/src/assets/langs/en.json | 6 +- 26 files changed, 427 insertions(+), 244 deletions(-) diff --git a/API.Tests/Parsing/ParsingTests.cs b/API.Tests/Parsing/ParsingTests.cs index 82a2c4b81..c6f256de5 100644 --- a/API.Tests/Parsing/ParsingTests.cs +++ b/API.Tests/Parsing/ParsingTests.cs @@ -200,6 +200,8 @@ public class ParsingTests [InlineData("카비타", "카비타")] [InlineData("06", "06")] [InlineData("", "")] + [InlineData("不安の種+", "不安の種+")] + [InlineData("不安の種*", "不安の種*")] public void NormalizeTest(string input, string expected) { Assert.Equal(expected, Normalize(input)); diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index 8f9e51fc3..b72ce77b9 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -22,6 +22,7 @@ using API.Extensions; using API.Helpers; using API.Services; using API.Services.Tasks.Scanner.Parser; +using AutoMapper; using Kavita.Common; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -42,6 +43,7 @@ public class OpdsController : BaseApiController private readonly ISeriesService _seriesService; private readonly IAccountService _accountService; private readonly ILocalizationService _localizationService; + private readonly IMapper _mapper; private readonly XmlSerializer _xmlSerializer; @@ -78,7 +80,8 @@ public class OpdsController : BaseApiController public OpdsController(IUnitOfWork unitOfWork, IDownloadService downloadService, IDirectoryService directoryService, ICacheService cacheService, IReaderService readerService, ISeriesService seriesService, - IAccountService accountService, ILocalizationService localizationService) + IAccountService accountService, ILocalizationService localizationService, + IMapper mapper) { _unitOfWork = unitOfWork; _downloadService = downloadService; @@ -88,6 +91,7 @@ public class OpdsController : BaseApiController _seriesService = seriesService; _accountService = accountService; _localizationService = localizationService; + _mapper = mapper; _xmlSerializer = new XmlSerializer(typeof(Feed)); _xmlOpenSearchSerializer = new XmlSerializer(typeof(OpenSearchDescription)); @@ -289,7 +293,7 @@ public class OpdsController : BaseApiController { var baseUrl = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BaseUrl)).Value; 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 prefix = baseUrl + "api/opds/"; @@ -849,16 +853,15 @@ public class OpdsController : BaseApiController var seriesDetail = await _seriesService.GetSeriesDetail(seriesId, userId); foreach (var volume in seriesDetail.Volumes) { - var chapters = (await _unitOfWork.ChapterRepository.GetChaptersAsync(volume.Id)) - .OrderBy(x => x.MinNumber, _chapterSortComparerDefaultLast); + var chapters = await _unitOfWork.ChapterRepository.GetChaptersAsync(volume.Id, ChapterIncludes.Files); - foreach (var chapterId in chapters.Select(c => c.Id)) + foreach (var chapter in chapters) { - var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId); - var chapterTest = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId); - foreach (var mangaFile in files) + var chapterId = chapter.Id; + var chapterDto = _mapper.Map(chapter); + 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)) { var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(storylineChapter.Id); - var chapterTest = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(storylineChapter.Id); + var chapterDto = _mapper.Map(storylineChapter); 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) { var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(special.Id); - var chapterTest = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(special.Id); + var chapterDto = _mapper.Map(special); 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 : $" Summary: {chapter.Summary}"), Format = mangaFile.Format.ToString(), - Links = new List() - { - CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/chapter-cover?chapterId={chapterId}&apiKey={apiKey}"), - CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{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 - accLink, - await CreatePageStreamLink(series.LibraryId, seriesId, volumeId, chapterId, mangaFile, apiKey, prefix) - }, + Links = + [ + CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, + $"{baseUrl}api/image/chapter-cover?chapterId={chapterId}&apiKey={apiKey}"), + CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, + $"{baseUrl}api/image/chapter-cover?chapterId={chapterId}&apiKey={apiKey}"), + // 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() { 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; } @@ -1162,7 +1172,7 @@ public class OpdsController : BaseApiController { var userId = await GetUser(apiKey); 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")); try @@ -1188,10 +1198,9 @@ public class OpdsController : BaseApiController SeriesId = seriesId, VolumeId = volumeId, LibraryId =libraryId - }, await GetUser(apiKey)); + }, userId); } - return File(content, MimeTypeMap.GetMimeType(format)); } catch (Exception) @@ -1223,8 +1232,7 @@ public class OpdsController : BaseApiController { try { - var user = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); - return user; + return await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); } catch { @@ -1242,12 +1250,14 @@ public class OpdsController : BaseApiController var link = CreateLink(FeedLinkRelation.Stream, "image/jpeg", $"{prefix}{apiKey}/image?libraryId={libraryId}&seriesId={seriesId}&volumeId={volumeId}&chapterId={chapterId}&pageNumber=" + "{pageNumber}"); link.TotalPages = mangaFile.Pages; + link.IsPageStream = true; + if (progress != null) { link.LastRead = progress.PageNum; link.LastReadDate = progress.LastModifiedUtc.ToString("s"); // Adhere to ISO 8601 } - link.IsPageStream = true; + return link; } @@ -1272,20 +1282,22 @@ public class OpdsController : BaseApiController { Title = title, Icon = $"{prefix}{apiKey}/favicon", - Links = new List() - { + Links = + [ link, CreateLink(FeedLinkRelation.Start, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}"), CreateLink(FeedLinkRelation.Search, FeedLinkType.AtomSearch, $"{prefix}{apiKey}/search") - }, + ], }; } private string SerializeXml(Feed? feed) { if (feed == null) return string.Empty; + using var sm = new StringWriter(); _xmlSerializer.Serialize(sm, feed); + return sm.ToString().Replace("utf-16", "utf-8"); // Chunky cannot accept UTF-16 feeds } } diff --git a/API/Controllers/SearchController.cs b/API/Controllers/SearchController.cs index e01628dbd..5aa54d1db 100644 --- a/API/Controllers/SearchController.cs +++ b/API/Controllers/SearchController.cs @@ -50,8 +50,14 @@ public class SearchController : BaseApiController return Ok(await _unitOfWork.SeriesRepository.GetSeriesForChapter(chapterId, User.GetUserId())); } + /// + /// Searches against different entities in the system against a query string + /// + /// + /// Include Chapter and Filenames in the entities. This can slow down the search on larger systems + /// [HttpGet("search")] - public async Task> Search(string queryString) + public async Task> Search(string queryString, [FromQuery] bool includeChapterAndFiles = true) { queryString = Services.Tasks.Scanner.Parser.Parser.CleanQuery(queryString); @@ -63,7 +69,7 @@ public class SearchController : BaseApiController var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); var series = await _unitOfWork.SeriesRepository.SearchSeries(user.Id, isAdmin, - libraries, queryString); + libraries, queryString, includeChapterAndFiles); return Ok(series); } diff --git a/API/Data/Repositories/ChapterRepository.cs b/API/Data/Repositories/ChapterRepository.cs index 3f4f0f6c4..15ef74b2e 100644 --- a/API/Data/Repositories/ChapterRepository.cs +++ b/API/Data/Repositories/ChapterRepository.cs @@ -34,7 +34,7 @@ public interface IChapterRepository Task GetChapterDtoAsync(int chapterId, ChapterIncludes includes = ChapterIncludes.Files); Task GetChapterMetadataDtoAsync(int chapterId, ChapterIncludes includes = ChapterIncludes.Files); Task> GetFilesForChapterAsync(int chapterId); - Task> GetChaptersAsync(int volumeId); + Task> GetChaptersAsync(int volumeId, ChapterIncludes includes = ChapterIncludes.None); Task> GetFilesForChaptersAsync(IReadOnlyList chapterIds); Task GetChapterCoverImageAsync(int chapterId); Task> GetAllCoverImagesAsync(); @@ -184,10 +184,11 @@ public class ChapterRepository : IChapterRepository /// /// /// - public async Task> GetChaptersAsync(int volumeId) + public async Task> GetChaptersAsync(int volumeId, ChapterIncludes includes = ChapterIncludes.None) { return await _context.Chapter .Where(c => c.VolumeId == volumeId) + .Includes(includes) .OrderBy(c => c.SortOrder) .ToListAsync(); } diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index 73a52856e..f0b84f5f8 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -91,8 +91,9 @@ public interface ISeriesRepository /// /// /// + /// Includes Files in the Search /// - Task SearchSeries(int userId, bool isAdmin, IList libraryIds, string searchQuery); + Task SearchSeries(int userId, bool isAdmin, IList libraryIds, string searchQuery, bool includeChapterAndFiles = true); Task> GetSeriesForLibraryIdAsync(int libraryId, SeriesIncludes includes = SeriesIncludes.None); Task GetSeriesDtoByIdAsync(int seriesId, int userId); Task GetSeriesByIdAsync(int seriesId, SeriesIncludes includes = SeriesIncludes.Volumes | SeriesIncludes.Metadata); @@ -353,7 +354,7 @@ public class SeriesRepository : ISeriesRepository return [libraryId]; } - public async Task SearchSeries(int userId, bool isAdmin, IList libraryIds, string searchQuery) + public async Task SearchSeries(int userId, bool isAdmin, IList libraryIds, string searchQuery, bool includeChapterAndFiles = true) { const int maxRecords = 15; var result = new SearchResultGroupDto(); @@ -452,42 +453,45 @@ public class SeriesRepository : ISeriesRepository .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); - var fileIds = _context.Series - .Where(s => seriesIds.Contains(s.Id)) - .AsSplitQuery() - .SelectMany(s => s.Volumes) - .SelectMany(v => v.Chapters) - .SelectMany(c => c.Files.Select(f => f.Id)); + result.Files = new List(); + result.Chapters = new List(); - // Need to check if an admin - var user = await _context.AppUser.FirstAsync(u => u.Id == userId); - if (await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole)) + + if (includeChapterAndFiles) { - result.Files = await _context.MangaFile - .Where(m => EF.Functions.Like(m.FilePath, $"%{searchQuery}%") && fileIds.Contains(m.Id)) + var fileIds = _context.Series + .Where(s => seriesIds.Contains(s.Id)) .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(_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) - .ProjectTo(_mapper.ConfigurationProvider) + .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); } - else - { - result.Files = new List(); - } - - 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(_mapper.ConfigurationProvider) - .ToListAsync(); return result; } @@ -2094,6 +2098,7 @@ public class SeriesRepository : ISeriesRepository LastScanned = s.LastFolderScanned, SeriesName = s.Name, FolderPath = s.FolderPath, + LowestFolderPath = s.LowestFolderPath, Format = s.Format, LibraryRoots = s.Library.Folders.Select(f => f.Path) }).ToListAsync(); @@ -2101,7 +2106,7 @@ public class SeriesRepository : ISeriesRepository var map = new Dictionary>(); foreach (var series in info) { - if (series.FolderPath == null) continue; + if (string.IsNullOrEmpty(series.FolderPath)) continue; if (!map.TryGetValue(series.FolderPath, out var value)) { map.Add(series.FolderPath, new List() @@ -2113,6 +2118,20 @@ public class SeriesRepository : ISeriesRepository { value.Add(series); } + + + if (string.IsNullOrEmpty(series.LowestFolderPath)) continue; + if (!map.TryGetValue(series.LowestFolderPath, out var value2)) + { + map.Add(series.LowestFolderPath, new List() + { + series + }); + } + else + { + value2.Add(series); + } } return map; diff --git a/API/Extensions/ClaimsPrincipalExtensions.cs b/API/Extensions/ClaimsPrincipalExtensions.cs index 3355a7586..2e86f8bbd 100644 --- a/API/Extensions/ClaimsPrincipalExtensions.cs +++ b/API/Extensions/ClaimsPrincipalExtensions.cs @@ -7,17 +7,23 @@ namespace API.Extensions; public static class ClaimsPrincipalExtensions { + private const string NotAuthenticatedMessage = "User is not authenticated"; + /// + /// Get's the authenticated user's username + /// + /// Warning! Username's can contain .. and /, do not use folders or filenames explicitly with the Username + /// + /// + /// public static string GetUsername(this ClaimsPrincipal user) { - var userClaim = user.FindFirst(JwtRegisteredClaimNames.Name); - if (userClaim == null) throw new KavitaException("User is not authenticated"); + var userClaim = user.FindFirst(JwtRegisteredClaimNames.Name) ?? throw new KavitaException(NotAuthenticatedMessage); return userClaim.Value; } public static int GetUserId(this ClaimsPrincipal user) { - var userClaim = user.FindFirst(ClaimTypes.NameIdentifier); - if (userClaim == null) throw new KavitaException("User is not authenticated"); + var userClaim = user.FindFirst(ClaimTypes.NameIdentifier) ?? throw new KavitaException(NotAuthenticatedMessage); return int.Parse(userClaim.Value); } } diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index a6d978c67..24be99d92 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -847,7 +847,7 @@ public class BookService : IBookService Filename = Path.GetFileName(filePath), Title = specialName?.Trim() ?? string.Empty, FullFilePath = Parser.NormalizePath(filePath), - IsSpecial = false, + IsSpecial = Parser.HasSpecialMarker(filePath), Series = series.Trim(), SeriesSort = series.Trim(), Volumes = seriesIndex @@ -869,7 +869,7 @@ public class BookService : IBookService Filename = Path.GetFileName(filePath), Title = epubBook.Title.Trim(), FullFilePath = Parser.NormalizePath(filePath), - IsSpecial = false, + IsSpecial = Parser.HasSpecialMarker(filePath), Series = epubBook.Title.Trim(), Volumes = Parser.LooseLeafVolume, }; diff --git a/API/Services/CacheService.cs b/API/Services/CacheService.cs index 183dda6d3..c6e539348 100644 --- a/API/Services/CacheService.cs +++ b/API/Services/CacheService.cs @@ -322,7 +322,7 @@ public class CacheService : ICacheService 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 var files = _directoryService.GetFilesWithExtension(path, Tasks.Scanner.Parser.Parser.ImageFileExtensions) - .OrderByNatural(Path.GetFileNameWithoutExtension) + //.OrderByNatural(Path.GetFileNameWithoutExtension) // This is already done in GetPageFromFiles .ToArray(); return GetPageFromFiles(files, page); diff --git a/API/Services/Plus/ScrobblingService.cs b/API/Services/Plus/ScrobblingService.cs index 8383f1778..51939198b 100644 --- a/API/Services/Plus/ScrobblingService.cs +++ b/API/Services/Plus/ScrobblingService.cs @@ -228,7 +228,7 @@ public class ScrobblingService : IScrobblingService LibraryId = series.LibraryId, ScrobbleEventType = ScrobbleEventType.Review, AniListId = ExtractId(series.Metadata.WebLinks, AniListWeblinkWebsite), - MalId = ExtractId(series.Metadata.WebLinks, MalWeblinkWebsite), + MalId = GetMalId(series), AppUserId = userId, Format = LibraryTypeHelper.GetFormat(series.Library.Type), ReviewBody = reviewBody, @@ -250,7 +250,7 @@ public class ScrobblingService : IScrobblingService { 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")); _logger.LogInformation("Processing Scrobbling rating event for {UserId} on {SeriesName}", userId, series.Name); @@ -274,22 +274,34 @@ public class ScrobblingService : IScrobblingService SeriesId = series.Id, LibraryId = series.LibraryId, ScrobbleEventType = ScrobbleEventType.ScoreUpdated, - AniListId = ExtractId(series.Metadata.WebLinks, AniListWeblinkWebsite), // TODO: We can get this also from ExternalSeriesMetadata - MalId = ExtractId(series.Metadata.WebLinks, MalWeblinkWebsite), + AniListId = GetAniListId(series), + MalId = GetMalId(series), AppUserId = userId, Format = LibraryTypeHelper.GetFormat(series.Library.Type), Rating = rating }; _unitOfWork.ScrobbleRepository.Attach(evt); 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(series.Metadata.WebLinks, MalWeblinkWebsite); + return malId ?? series.ExternalSeriesMetadata.MalId; + } + + private static int? GetAniListId(Series series) + { + var aniListId = ExtractId(series.Metadata.WebLinks, AniListWeblinkWebsite); + return aniListId ?? series.ExternalSeriesMetadata.AniListId; } public async Task ScrobbleReadingUpdate(int userId, int seriesId) { 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")); _logger.LogInformation("Processing Scrobbling reading event for {UserId} on {SeriesName}", userId, series.Name); @@ -321,8 +333,8 @@ public class ScrobblingService : IScrobblingService SeriesId = series.Id, LibraryId = series.LibraryId, ScrobbleEventType = ScrobbleEventType.ChapterRead, - AniListId = ExtractId(series.Metadata.WebLinks, AniListWeblinkWebsite), - MalId = ExtractId(series.Metadata.WebLinks, MalWeblinkWebsite), + AniListId = GetAniListId(series), + MalId = GetMalId(series), AppUserId = userId, VolumeNumber = (int) await _unitOfWork.AppUserProgressRepository.GetHighestFullyReadVolumeForSeries(seriesId, userId), @@ -345,7 +357,7 @@ public class ScrobblingService : IScrobblingService { 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")); _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, LibraryId = series.LibraryId, ScrobbleEventType = onWantToRead ? ScrobbleEventType.AddWantToRead : ScrobbleEventType.RemoveWantToRead, - AniListId = ExtractId(series.Metadata.WebLinks, AniListWeblinkWebsite), - MalId = ExtractId(series.Metadata.WebLinks, MalWeblinkWebsite), + AniListId = GetAniListId(series), + MalId = GetMalId(series), AppUserId = userId, Format = LibraryTypeHelper.GetFormat(series.Library.Type), }; diff --git a/API/Services/ReadingListService.cs b/API/Services/ReadingListService.cs index 4c0a721a0..9d9c7cf6b 100644 --- a/API/Services/ReadingListService.cs +++ b/API/Services/ReadingListService.cs @@ -394,7 +394,7 @@ public class ReadingListService : IReadingListService var existingChapterExists = readingList.Items.Select(rli => rli.ChapterId).ToHashSet(); var chaptersForSeries = (await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(chapterIds, ChapterIncludes.Volumes)) .OrderBy(c => c.Volume.MinNumber) - .ThenBy(x => x.MinNumber, _chapterSortComparerForInChapterSorting) + .ThenBy(x => x.SortOrder) .ToList(); var index = readingList.Items.Count == 0 ? 0 : lastOrder + 1; diff --git a/API/Services/Tasks/Scanner/ParseScannedFiles.cs b/API/Services/Tasks/Scanner/ParseScannedFiles.cs index 080d9fa1c..395a2a781 100644 --- a/API/Services/Tasks/Scanner/ParseScannedFiles.cs +++ b/API/Services/Tasks/Scanner/ParseScannedFiles.cs @@ -79,6 +79,7 @@ public class ScannedSeriesResult public class SeriesModified { public required string? FolderPath { get; set; } + public required string? LowestFolderPath { get; set; } public required string SeriesName { get; set; } public DateTime LastScanned { get; set; } public MangaFormat Format { get; set; } @@ -151,16 +152,28 @@ public class ParseScannedFiles 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.Empty)); + } + else + { + result.Add(CreateScanResult(directory, folderPath, true, + _directoryService.ScanFiles(seriesModified.LowestFolderPath!, fileExtensions, matcher))); + } + } + } else { // 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() - { - Files = _directoryService.ScanFiles(directory, fileExtensions, matcher), - Folder = directory, - LibraryRoot = folderPath, - HasChanged = true - }); + result.Add(CreateScanResult(directory, folderPath, true, + _directoryService.ScanFiles(directory, fileExtensions))); } } @@ -175,26 +188,30 @@ public class ParseScannedFiles if (HasSeriesFolderNotChangedSinceLastScan(seriesPaths, normalizedPath, forceCheck)) { - result.Add(new ScanResult() - { - Files = ArraySegment.Empty, - Folder = folderPath, - LibraryRoot = libraryRoot, - HasChanged = false - }); + result.Add(CreateScanResult(folderPath, libraryRoot, false, ArraySegment.Empty)); + } + else + { + result.Add(CreateScanResult(folderPath, libraryRoot, true, + _directoryService.ScanFiles(folderPath, fileExtensions))); } - result.Add(new ScanResult() - { - Files = _directoryService.ScanFiles(folderPath, fileExtensions), - Folder = folderPath, - LibraryRoot = libraryRoot, - HasChanged = true - }); return result; } + private static ScanResult CreateScanResult(string folderPath, string libraryRoot, bool hasChanged, + IList files) + { + return new ScanResult() + { + Files = files, + Folder = folderPath, + LibraryRoot = libraryRoot, + HasChanged = hasChanged + }; + } + /// /// 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; - return seriesPaths.ContainsKey(normalizedFolder) && seriesPaths[normalizedFolder].All(f => f.LastScanned.Truncate(TimeSpan.TicksPerSecond) >= - _directoryService.GetLastWriteTime(normalizedFolder).Truncate(TimeSpan.TicksPerSecond)); + if (seriesPaths.TryGetValue(normalizedFolder, out var v)) + { + return HasAllSeriesFolderNotChangedSinceLastScan(v, normalizedFolder); + } + + return false; } + private bool HasAllSeriesFolderNotChangedSinceLastScan(IList 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); + } + + + /// /// 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. diff --git a/API/Services/Tasks/Scanner/Parser/BookParser.cs b/API/Services/Tasks/Scanner/Parser/BookParser.cs index 2a57f3e71..73e5513ac 100644 --- a/API/Services/Tasks/Scanner/Parser/BookParser.cs +++ b/API/Services/Tasks/Scanner/Parser/BookParser.cs @@ -12,8 +12,14 @@ public class BookParser(IDirectoryService directoryService, IBookService bookSer 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 - 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) .Equals(Parser.LooseLeafVolume); diff --git a/API/Services/Tasks/Scanner/Parser/Parser.cs b/API/Services/Tasks/Scanner/Parser/Parser.cs index f64e34595..c86d99548 100644 --- a/API/Services/Tasks/Scanner/Parser/Parser.cs +++ b/API/Services/Tasks/Scanner/Parser/Parser.cs @@ -103,7 +103,7 @@ public static class Parser private static readonly Regex CoverImageRegex = new Regex(@"(? diff --git a/API/Services/Tasks/Scanner/ProcessSeries.cs b/API/Services/Tasks/Scanner/ProcessSeries.cs index 1b9d0bab6..b1fa5867b 100644 --- a/API/Services/Tasks/Scanner/ProcessSeries.cs +++ b/API/Services/Tasks/Scanner/ProcessSeries.cs @@ -710,6 +710,12 @@ public class ProcessSeries : IProcessSeries chapter.SortOrder = info.IssueOrder; } 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(); + } + } diff --git a/API/Startup.cs b/API/Startup.cs index a7eb490de..186a8802f 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -358,7 +358,14 @@ public class Startup 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, OnPrepareResponse = ctx => { diff --git a/UI/Web/src/app/_services/search.service.ts b/UI/Web/src/app/_services/search.service.ts index fa989fa35..4a95fff99 100644 --- a/UI/Web/src/app/_services/search.service.ts +++ b/UI/Web/src/app/_services/search.service.ts @@ -14,11 +14,11 @@ export class SearchService { constructor(private httpClient: HttpClient) { } - search(term: string) { + search(term: string, includeChapterAndFiles: boolean = false) { if (term === '') { return of(new SearchResultGroup()); } - return this.httpClient.get(this.baseUrl + 'search/search?queryString=' + encodeURIComponent(term)); + return this.httpClient.get(this.baseUrl + `search/search?includeChapterAndFiles=${includeChapterAndFiles}&queryString=${encodeURIComponent(term)}`); } getSeriesForMangaFile(mangaFileId: number) { diff --git a/UI/Web/src/app/collections/_components/import-mal-collection-modal/import-mal-collection-modal.component.ts b/UI/Web/src/app/collections/_components/import-mal-collection-modal/import-mal-collection-modal.component.ts index 89f683b0a..1e1054e31 100644 --- a/UI/Web/src/app/collections/_components/import-mal-collection-modal/import-mal-collection-modal.component.ts +++ b/UI/Web/src/app/collections/_components/import-mal-collection-modal/import-mal-collection-modal.component.ts @@ -11,7 +11,6 @@ import {forkJoin} from "rxjs"; import {ToastrService} from "ngx-toastr"; import {DecimalPipe} from "@angular/common"; import {LoadingComponent} from "../../../shared/loading/loading.component"; -import {AccountService} from "../../../_services/account.service"; import {ConfirmService} from "../../../shared/confirm.service"; @Component({ diff --git a/UI/Web/src/app/nav/_components/grouped-typeahead/grouped-typeahead.component.html b/UI/Web/src/app/nav/_components/grouped-typeahead/grouped-typeahead.component.html index f55ce8f77..e6be284c8 100644 --- a/UI/Web/src/app/nav/_components/grouped-typeahead/grouped-typeahead.component.html +++ b/UI/Web/src/app/nav/_components/grouped-typeahead/grouped-typeahead.component.html @@ -5,126 +5,166 @@ -
- {{t('loading')}} -
- + @if (searchTerm.length > 0) { + @if (isLoading) { +
+ {{t('loading')}} +
+ } @else { + + } + } - + } diff --git a/UI/Web/src/app/nav/_components/grouped-typeahead/grouped-typeahead.component.ts b/UI/Web/src/app/nav/_components/grouped-typeahead/grouped-typeahead.component.ts index 4fef13241..727accefc 100644 --- a/UI/Web/src/app/nav/_components/grouped-typeahead/grouped-typeahead.component.ts +++ b/UI/Web/src/app/nav/_components/grouped-typeahead/grouped-typeahead.component.ts @@ -18,8 +18,14 @@ import { debounceTime } from 'rxjs/operators'; import { KEY_CODES } from 'src/app/shared/_services/utility.service'; import { SearchResultGroup } from 'src/app/_models/search/search-result-group'; 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 {LoadingComponent} from "../../../shared/loading/loading.component"; + +export interface SearchEvent { + value: string; + includeFiles: boolean; +} @Component({ selector: 'app-grouped-typeahead', @@ -27,9 +33,12 @@ import {TranslocoDirective} from "@ngneat/transloco"; styleUrls: ['./grouped-typeahead.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [ReactiveFormsModule, NgClass, NgIf, NgFor, NgTemplateOutlet, TranslocoDirective] + imports: [ReactiveFormsModule, NgClass, NgTemplateOutlet, TranslocoDirective, LoadingComponent] }) export class GroupedTypeaheadComponent implements OnInit { + private readonly destroyRef = inject(DestroyRef); + private readonly cdRef = inject(ChangeDetectorRef); + /** * Unique id to tie with a label element */ @@ -47,6 +56,10 @@ export class GroupedTypeaheadComponent implements OnInit { * Placeholder for the input */ @Input() placeholder: string = ''; + /** + * When the search is active + */ + @Input() isLoading: boolean = false; /** * 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 */ - @Output() inputChanged: EventEmitter = new EventEmitter(); + @Output() inputChanged: EventEmitter = new EventEmitter(); /** * Emits when something is clicked/selected */ @@ -76,17 +89,18 @@ export class GroupedTypeaheadComponent implements OnInit { @ContentChild('personTemplate') personTemplate: TemplateRef | undefined; @ContentChild('genreTemplate') genreTemplate!: TemplateRef; @ContentChild('noResultsTemplate') noResultsTemplate!: TemplateRef; + @ContentChild('extraTemplate') extraTemplate!: TemplateRef; @ContentChild('libraryTemplate') libraryTemplate!: TemplateRef; @ContentChild('readingListTemplate') readingListTemplate!: TemplateRef; @ContentChild('fileTemplate') fileTemplate!: TemplateRef; @ContentChild('chapterTemplate') chapterTemplate!: TemplateRef; @ContentChild('bookmarkTemplate') bookmarkTemplate!: TemplateRef; - private readonly destroyRef = inject(DestroyRef); + hasFocus: boolean = false; - isLoading: boolean = false; typeaheadForm: FormGroup = new FormGroup({}); + includeChapterAndFiles: boolean = false; prevSearchTerm: string = ''; @@ -101,8 +115,6 @@ export class GroupedTypeaheadComponent implements OnInit { } - constructor(private readonly cdRef: ChangeDetectorRef) { } - @HostListener('window:click', ['$event']) handleDocumentClick(event: any) { this.close(); @@ -127,7 +139,10 @@ export class GroupedTypeaheadComponent implements OnInit { this.typeaheadForm.addControl('typeahead', new FormControl(this.initialValue, [])); 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; if (value != undefined && value != '' && !this.hasFocus) { @@ -138,7 +153,7 @@ export class GroupedTypeaheadComponent implements OnInit { if (value != undefined && value.length >= this.minQueryLength) { if (this.prevSearchTerm === value) return; - this.inputChanged.emit(value); + this.inputChanged.emit({value, includeFiles: this.includeChapterAndFiles}); this.prevSearchTerm = value; this.cdRef.markForCheck(); } @@ -164,10 +179,20 @@ export class GroupedTypeaheadComponent implements OnInit { }); } - handleResultlick(item: any) { + handleResultClick(item: any) { 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() { this.prevSearchTerm = ''; this.typeaheadForm.get('typeahead')?.setValue(this.initialValue); diff --git a/UI/Web/src/app/nav/_components/nav-header/nav-header.component.html b/UI/Web/src/app/nav/_components/nav-header/nav-header.component.html index 0adfd4bd5..6958717b3 100644 --- a/UI/Web/src/app/nav/_components/nav-header/nav-header.component.html +++ b/UI/Web/src/app/nav/_components/nav-header/nav-header.component.html @@ -16,6 +16,7 @@ #search id="nav-search" [minQueryLength]="2" + [isLoading]="isLoading" initialValue="" [placeholder]="t('search-alt')" [groupedData]="searchResults" @@ -147,7 +148,6 @@ {{t('no-data')}} - diff --git a/UI/Web/src/app/nav/_components/nav-header/nav-header.component.ts b/UI/Web/src/app/nav/_components/nav-header/nav-header.component.ts index 473da8c57..8b0d8bf4e 100644 --- a/UI/Web/src/app/nav/_components/nav-header/nav-header.component.ts +++ b/UI/Web/src/app/nav/_components/nav-header/nav-header.component.ts @@ -33,7 +33,7 @@ import {NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle} from ' import {EventsWidgetComponent} from '../events-widget/events-widget.component'; import {SeriesFormatComponent} from '../../../shared/series-format/series-format.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 {FilterUtilitiesService} from "../../../shared/_services/filter-utilities.service"; import {FilterStatement} from "../../../_models/metadata/v2/filter-statement"; @@ -66,7 +66,6 @@ export class NavHeaderComponent implements OnInit { searchResults: SearchResultGroup = new SearchResultGroup(); searchTerm = ''; - backToTopNeeded = false; searchFocused: boolean = false; scrollElem: HTMLElement; @@ -121,12 +120,14 @@ export class NavHeaderComponent implements OnInit { - onChangeSearch(val: string) { + + + onChangeSearch(evt: SearchEvent) { this.isLoading = true; - this.searchTerm = val.trim(); + this.searchTerm = evt.value.trim(); 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.isLoading = false; this.cdRef.markForCheck(); diff --git a/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.ts b/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.ts index 7948316e7..4d817ee1e 100644 --- a/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.ts +++ b/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.ts @@ -34,7 +34,6 @@ import {SpreadType} from "ngx-extended-pdf-viewer/lib/options/spread-type"; import {PdfLayoutModePipe} from "../../_pipe/pdf-layout-mode.pipe"; import {PdfScrollModePipe} from "../../_pipe/pdf-scroll-mode.pipe"; import {PdfSpreadModePipe} from "../../_pipe/pdf-spread-mode.pipe"; -import {HandtoolChanged} from "ngx-extended-pdf-viewer/lib/events/handtool-changed"; @Component({ selector: 'app-pdf-reader', diff --git a/UI/Web/src/app/series-detail/_components/series-metadata-detail/series-metadata-detail.component.ts b/UI/Web/src/app/series-detail/_components/series-metadata-detail/series-metadata-detail.component.ts index 62ffb2e87..ee7a1de2d 100644 --- a/UI/Web/src/app/series-detail/_components/series-metadata-detail/series-metadata-detail.component.ts +++ b/UI/Web/src/app/series-detail/_components/series-metadata-detail/series-metadata-detail.component.ts @@ -94,7 +94,7 @@ export class SeriesMetadataDetailComponent implements OnChanges, OnInit { ngOnInit() { // 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 const sum = (this.seriesMetadata.colorists.length + this.seriesMetadata.editors.length + this.seriesMetadata.coverArtists.length + this.seriesMetadata.inkers.length diff --git a/UI/Web/src/app/shared/_services/utility.service.ts b/UI/Web/src/app/shared/_services/utility.service.ts index 496d58aad..04a23a330 100644 --- a/UI/Web/src/app/shared/_services/utility.service.ts +++ b/UI/Web/src/app/shared/_services/utility.service.ts @@ -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 ((object2 === null || object2 === undefined) && (object1 !== null || object1 !== undefined)) return false; if (object1 === null && object2 === null) return true; diff --git a/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts b/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts index 4efab4e07..d93c48584 100644 --- a/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts +++ b/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts @@ -65,7 +65,6 @@ export class SideNavComponent implements OnInit { homeActions = [ {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-mal-stack', children: [], requiresAdmin: true, callback: this.importMalCollection.bind(this)}, // This requires the Collection Rework (https://github.com/Kareadita/Kavita/issues/2810) ]; filterQuery: string = ''; @@ -144,6 +143,13 @@ export class SideNavComponent implements OnInit { this.navService.toggleSideNav(); 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 { diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index 38e7dcaab..14fb87937 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -1537,8 +1537,8 @@ "reading-lists": "Reading Lists", "collections": "Collections", "close": "{{common.close}}", - "loading": "{{common.loading}}" - + "loading": "{{common.loading}}", + "include-extras": "Include Chapters & Files" }, "nav-header": { @@ -1605,7 +1605,7 @@ "description": "Import your MAL Interest Stacks and create Collections within Kavita", "series-count": "{{common.series-count}}", "restack-count": "{{num}} Restacks", - "nothing-found": "" + "nothing-found": "Nothing found" }, "edit-chapter-progress": {