diff --git a/API.Tests/API.Tests.csproj b/API.Tests/API.Tests.csproj index c00a93d46..1651d35d0 100644 --- a/API.Tests/API.Tests.csproj +++ b/API.Tests/API.Tests.csproj @@ -6,13 +6,13 @@ - - + + - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/API.Tests/Parsing/MangaParsingTests.cs b/API.Tests/Parsing/MangaParsingTests.cs index 3b01ea00d..3b41b429a 100644 --- a/API.Tests/Parsing/MangaParsingTests.cs +++ b/API.Tests/Parsing/MangaParsingTests.cs @@ -210,6 +210,7 @@ public class MangaParsingTests [InlineData("เด็กคนนี้ขอลาออกจากการเป็นเจ้าของปราสาท เล่ม 1", "เด็กคนนี้ขอลาออกจากการเป็นเจ้าของปราสาท")] [InlineData("Max Level Returner เล่มที่ 5", "Max Level Returner")] [InlineData("หนึ่งความคิด นิจนิรันดร์ เล่ม 2", "หนึ่งความคิด นิจนิรันดร์")] + [InlineData("不安の種\uff0b - 01", "不安の種\uff0b")] public void ParseSeriesTest(string filename, string expected) { Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseSeries(filename, LibraryType.Manga)); diff --git a/API.Tests/Parsing/ParsingTests.cs b/API.Tests/Parsing/ParsingTests.cs index c6f256de5..6a184e3f9 100644 --- a/API.Tests/Parsing/ParsingTests.cs +++ b/API.Tests/Parsing/ParsingTests.cs @@ -201,7 +201,6 @@ public class ParsingTests [InlineData("06", "06")] [InlineData("", "")] [InlineData("不安の種+", "不安の種+")] - [InlineData("不安の種*", "不安の種*")] public void NormalizeTest(string input, string expected) { Assert.Equal(expected, Normalize(input)); diff --git a/API.Tests/Services/ParseScannedFilesTests.cs b/API.Tests/Services/ParseScannedFilesTests.cs index 04dc20522..1bf2257ce 100644 --- a/API.Tests/Services/ParseScannedFilesTests.cs +++ b/API.Tests/Services/ParseScannedFilesTests.cs @@ -244,7 +244,7 @@ public class ParseScannedFilesTests : AbstractDbTest var directoriesSeen = new HashSet(); var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1, LibraryIncludes.Folders | LibraryIncludes.FileTypes); - var scanResults = psf.ProcessFiles("C:/Data/", true, await _unitOfWork.SeriesRepository.GetFolderPathMap(1), library); + var scanResults = await psf.ProcessFiles("C:/Data/", true, await _unitOfWork.SeriesRepository.GetFolderPathMap(1), library); foreach (var scanResult in scanResults) { directoriesSeen.Add(scanResult.Folder); @@ -266,7 +266,7 @@ public class ParseScannedFilesTests : AbstractDbTest Assert.NotNull(library); var directoriesSeen = new HashSet(); - var scanResults = psf.ProcessFiles("C:/Data/", false, + var scanResults = await psf.ProcessFiles("C:/Data/", false, await _unitOfWork.SeriesRepository.GetFolderPathMap(1), library); foreach (var scanResult in scanResults) @@ -299,7 +299,7 @@ public class ParseScannedFilesTests : AbstractDbTest var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1, LibraryIncludes.Folders | LibraryIncludes.FileTypes); Assert.NotNull(library); - var scanResults = psf.ProcessFiles("C:/Data", true, await _unitOfWork.SeriesRepository.GetFolderPathMap(1), library); + var scanResults = await psf.ProcessFiles("C:/Data", true, await _unitOfWork.SeriesRepository.GetFolderPathMap(1), library); Assert.Equal(2, scanResults.Count); } @@ -328,7 +328,7 @@ public class ParseScannedFilesTests : AbstractDbTest var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1, LibraryIncludes.Folders | LibraryIncludes.FileTypes); Assert.NotNull(library); - var scanResults = psf.ProcessFiles("C:/Data", false, + var scanResults = await psf.ProcessFiles("C:/Data", false, await _unitOfWork.SeriesRepository.GetFolderPathMap(1), library); Assert.Single(scanResults); diff --git a/API/API.csproj b/API/API.csproj index cc3c3aa38..2f97240df 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -53,9 +53,9 @@ - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -66,17 +66,17 @@ - + - - - - + + + + @@ -84,7 +84,7 @@ - + @@ -95,16 +95,16 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - + - - + + diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index b72ce77b9..c2b211353 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -853,27 +853,35 @@ 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, ChapterIncludes.Files); + var chaptersForVolume = await _unitOfWork.ChapterRepository.GetChaptersAsync(volume.Id, ChapterIncludes.Files | ChapterIncludes.People); - foreach (var chapter in chapters) + foreach (var chapter in chaptersForVolume) { 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, chapterDto, apiKey, prefix, baseUrl)); + feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volume.Id, chapterId, _mapper.Map(mangaFile), series, + chapterDto, apiKey, prefix, baseUrl)); } } } - foreach (var storylineChapter in seriesDetail.StorylineChapters.Where(c => !c.IsSpecial)) + var chapters = seriesDetail.StorylineChapters; + if (!seriesDetail.StorylineChapters.Any() && seriesDetail.Chapters.Any()) { - var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(storylineChapter.Id); - var chapterDto = _mapper.Map(storylineChapter); + chapters = seriesDetail.Chapters; + } + + foreach (var chapter in chapters.Where(c => !c.IsSpecial)) + { + var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapter.Id); + var chapterDto = _mapper.Map(chapter); foreach (var mangaFile in files) { - feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, storylineChapter.VolumeId, storylineChapter.Id, mangaFile, series, chapterDto, apiKey, prefix, baseUrl)); + feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, chapter.VolumeId, chapter.Id, _mapper.Map(mangaFile), series, + chapterDto, apiKey, prefix, baseUrl)); } } @@ -883,7 +891,8 @@ public class OpdsController : BaseApiController var chapterDto = _mapper.Map(special); foreach (var mangaFile in files) { - feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, special.VolumeId, special.Id, mangaFile, series, chapterDto, apiKey, prefix, baseUrl)); + feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, special.VolumeId, special.Id, _mapper.Map(mangaFile), series, + chapterDto, apiKey, prefix, baseUrl)); } } @@ -909,9 +918,9 @@ public class OpdsController : BaseApiController SetFeedId(feed, $"series-{series.Id}-volume-{volume.Id}-{_seriesService.FormatChapterName(userId, libraryType)}s"); foreach (var chapter in chapters) { - var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapter.Id); - var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapter.Id); - foreach (var mangaFile in files) + //var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapter.Id); + var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapter.Id, ChapterIncludes.Files | ChapterIncludes.People); + foreach (var mangaFile in chapterDto.Files) { feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volumeId, chapter.Id, mangaFile, series, chapterDto!, apiKey, prefix, baseUrl)); } @@ -928,17 +937,20 @@ public class OpdsController : BaseApiController if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); var (baseUrl, prefix) = await GetPrefix(); + var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId); - var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId); + var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId, ChapterIncludes.Files | ChapterIncludes.People); + if (chapter == null) return BadRequest(await _localizationService.Translate(userId, "chapter-doesnt-exist")); + var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId); - var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId); var feed = CreateFeed(series.Name + " - Volume " + volume!.Name + $" - {_seriesService.FormatChapterName(userId, libraryType)}s", $"{prefix}{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}", apiKey, prefix); SetFeedId(feed, $"series-{series.Id}-volume-{volumeId}-{_seriesService.FormatChapterName(userId, libraryType)}-{chapterId}-files"); - foreach (var mangaFile in files) + + foreach (var mangaFile in chapter.Files) { feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volumeId, chapterId, mangaFile, series, chapter, apiKey, prefix, baseUrl)); } @@ -1028,22 +1040,30 @@ public class OpdsController : BaseApiController Summary = $"Format: {seriesDto.Format}" + (string.IsNullOrWhiteSpace(metadata.Summary) ? string.Empty : $" Summary: {metadata.Summary}"), - Authors = metadata.Writers.Select(p => new FeedAuthor() - { - Name = p.Name, - Uri = "http://opds-spec.org/author/" + p.Id - }).ToList(), + Authors = metadata.Writers.Select(CreateAuthor).ToList(), Categories = metadata.Genres.Select(g => new FeedCategory() { Label = g.Title, Term = string.Empty }).ToList(), - Links = new List() - { - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/series/{seriesDto.Id}"), - CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/series-cover?seriesId={seriesDto.Id}&apiKey={apiKey}"), - CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/series-cover?seriesId={seriesDto.Id}&apiKey={apiKey}") - } + Links = + [ + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, + $"{prefix}{apiKey}/series/{seriesDto.Id}"), + CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, + $"{baseUrl}api/image/series-cover?seriesId={seriesDto.Id}&apiKey={apiKey}"), + CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, + $"{baseUrl}api/image/series-cover?seriesId={seriesDto.Id}&apiKey={apiKey}") + ] + }; + } + + private static FeedAuthor CreateAuthor(PersonDto person) + { + return new FeedAuthor() + { + Name = person.Name, + Uri = "http://opds-spec.org/author/" + person.Id }; } @@ -1070,6 +1090,7 @@ public class OpdsController : BaseApiController Id = chapterId.ToString(), Title = title, Summary = summary ?? string.Empty, + Links = new List() { CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, @@ -1082,7 +1103,7 @@ public class OpdsController : BaseApiController }; } - private async Task CreateChapterWithFile(int userId, int seriesId, int volumeId, int chapterId, MangaFile mangaFile, SeriesDto series, ChapterDto chapter, string apiKey, string prefix, string baseUrl) + private async Task CreateChapterWithFile(int userId, int seriesId, int volumeId, int chapterId, MangaFileDto mangaFile, SeriesDto series, ChapterDto chapter, string apiKey, string prefix, string baseUrl) { var fileSize = mangaFile.Bytes > 0 ? DirectoryService.GetHumanReadableBytes(mangaFile.Bytes) : @@ -1143,7 +1164,8 @@ public class OpdsController : BaseApiController { Text = fileType, Type = "text" - } + }, + Authors = chapter.Writers.Select(CreateAuthor).ToList() }; var canPageStream = mangaFile.Extension != ".epub"; @@ -1241,7 +1263,7 @@ public class OpdsController : BaseApiController throw new KavitaException(await _localizationService.Get("en", "user-doesnt-exist")); } - private async Task CreatePageStreamLink(int libraryId, int seriesId, int volumeId, int chapterId, MangaFile mangaFile, string apiKey, string prefix) + private async Task CreatePageStreamLink(int libraryId, int seriesId, int volumeId, int chapterId, MangaFileDto mangaFile, string apiKey, string prefix) { var userId = await GetUser(apiKey); var progress = await _unitOfWork.AppUserProgressRepository.GetUserProgressDtoAsync(chapterId, userId); diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index 6e870c6ea..54a1adfb3 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -263,9 +263,9 @@ public class ReaderController : BaseApiController info.Title += " - " + info.ChapterTitle; } - if (info.IsSpecial && dto.VolumeNumber.Equals(Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)) + if (info.IsSpecial) { - info.Subtitle = info.FileName; + info.Subtitle = Path.GetFileNameWithoutExtension(info.FileName); } else if (!info.IsSpecial && info.VolumeNumber.Equals(Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)) { info.Subtitle = ReaderService.FormatChapterName(info.LibraryType, true, true) + info.ChapterNumber; diff --git a/API/DTOs/MangaFileDto.cs b/API/DTOs/MangaFileDto.cs index 9be3c117f..3f0983067 100644 --- a/API/DTOs/MangaFileDto.cs +++ b/API/DTOs/MangaFileDto.cs @@ -6,10 +6,23 @@ namespace API.DTOs; public class MangaFileDto { public int Id { get; init; } + /// + /// Absolute path to the archive file (normalized) + /// public string FilePath { get; init; } = default!; + /// + /// Number of pages for the given file + /// public int Pages { get; init; } + /// + /// How many bytes make up this file + /// public long Bytes { get; init; } public MangaFormat Format { get; init; } public DateTime Created { get; init; } + /// + /// File extension + /// + public string? Extension { get; set; } } diff --git a/API/DTOs/Settings/ServerSettingDTO.cs b/API/DTOs/Settings/ServerSettingDTO.cs index fd1cb3b65..45abcc528 100644 --- a/API/DTOs/Settings/ServerSettingDTO.cs +++ b/API/DTOs/Settings/ServerSettingDTO.cs @@ -1,4 +1,5 @@ -using API.Entities.Enums; +using System; +using API.Entities.Enums; using API.Services; namespace API.DTOs.Settings; @@ -88,6 +89,14 @@ public class ServerSettingDto /// SMTP Configuration /// public SmtpConfigDto SmtpConfig { get; set; } + /// + /// The Date Kavita was first installed + /// + public DateTime? FirstInstallDate { get; set; } + /// + /// The Version of Kavita on the first run + /// + public string? FirstInstallVersion { get; set; } /// /// Are at least some basics filled in diff --git a/API/DTOs/Stats/ServerInfoSlimDto.cs b/API/DTOs/Stats/ServerInfoSlimDto.cs index e8db6a2b0..ef44bb408 100644 --- a/API/DTOs/Stats/ServerInfoSlimDto.cs +++ b/API/DTOs/Stats/ServerInfoSlimDto.cs @@ -1,4 +1,6 @@ -namespace API.DTOs.Stats; +using System; + +namespace API.DTOs.Stats; /// /// This is just for the Server tab on UI @@ -17,5 +19,13 @@ public class ServerInfoSlimDto /// Version of Kavita /// public required string KavitaVersion { get; set; } + /// + /// The Date Kavita was first installed + /// + public DateTime? FirstInstallDate { get; set; } + /// + /// The Version of Kavita on the first run + /// + public string? FirstInstallVersion { get; set; } } diff --git a/API/Data/ManualMigrations/MigrateInitialInstallData.cs b/API/Data/ManualMigrations/MigrateInitialInstallData.cs new file mode 100644 index 000000000..f572034d1 --- /dev/null +++ b/API/Data/ManualMigrations/MigrateInitialInstallData.cs @@ -0,0 +1,53 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using API.Entities; +using API.Entities.Enums; +using API.Services; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +/// +/// v0.8.2 I started collecting information on when the user first installed Kavita as a nice to have info for the user +/// +public static class MigrateInitialInstallData +{ + public static async Task Migrate(DataContext dataContext, ILogger logger, IDirectoryService directoryService) + { + if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateInitialInstallData")) + { + return; + } + + logger.LogCritical( + "Running MigrateInitialInstallData migration - Please be patient, this may take some time. This is not an error"); + + var settings = await dataContext.ServerSetting.ToListAsync(); + + // Get the Install Date as Date the DB was written + var dbFile = Path.Join(directoryService.ConfigDirectory, "kavita.db"); + if (!string.IsNullOrEmpty(dbFile) && directoryService.FileSystem.File.Exists(dbFile)) + { + var fi = directoryService.FileSystem.FileInfo.New(dbFile); + var setting = settings.First(s => s.Key == ServerSettingKey.FirstInstallDate); + setting.Value = fi.CreationTimeUtc.ToString(); + await dataContext.SaveChangesAsync(); + } + + + dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory() + { + Name = "MigrateInitialInstallData", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + await dataContext.SaveChangesAsync(); + + logger.LogCritical( + "Running MigrateInitialInstallData migration - Completed. This is not an error"); + } +} diff --git a/API/Data/Repositories/ChapterRepository.cs b/API/Data/Repositories/ChapterRepository.cs index 15ef74b2e..059eeb2e9 100644 --- a/API/Data/Repositories/ChapterRepository.cs +++ b/API/Data/Repositories/ChapterRepository.cs @@ -22,6 +22,7 @@ public enum ChapterIncludes None = 1, Volumes = 2, Files = 4, + People = 8 } public interface IChapterRepository diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index f0b84f5f8..2ca93d1f0 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; @@ -137,6 +138,7 @@ public interface ISeriesRepository Task> GetWantToReadForUserAsync(int userId); Task IsSeriesInWantToRead(int userId, int seriesId); Task GetSeriesByFolderPath(string folder, SeriesIncludes includes = SeriesIncludes.None); + Task GetSeriesThatContainsLowestFolderPath(string folder, SeriesIncludes includes = SeriesIncludes.None); Task> GetAllSeriesByNameAsync(IList normalizedNames, int userId, SeriesIncludes includes = SeriesIncludes.None); Task GetFullSeriesByAnyName(string seriesName, string localizedName, int libraryId, MangaFormat format, bool withFullIncludes = true); @@ -1589,14 +1591,29 @@ public class SeriesRepository : ISeriesRepository /// /// Return a Series by Folder path. Null if not found. /// - /// This will be normalized in the query + /// This will be normalized in the query and checked against FolderPath and LowestFolderPath /// Additional relationships to include with the base query /// public async Task GetSeriesByFolderPath(string folder, SeriesIncludes includes = SeriesIncludes.None) { var normalized = Services.Tasks.Scanner.Parser.Parser.NormalizePath(folder); + if (string.IsNullOrEmpty(normalized)) return null; + return await _context.Series - .Where(s => s.FolderPath != null && s.FolderPath.Equals(normalized)) + .Where(s => (!string.IsNullOrEmpty(s.FolderPath) && s.FolderPath.Equals(normalized) || (!string.IsNullOrEmpty(s.LowestFolderPath) && s.LowestFolderPath.Equals(normalized)))) + .Includes(includes) + .SingleOrDefaultAsync(); + } + + public async Task GetSeriesThatContainsLowestFolderPath(string folder, SeriesIncludes includes = SeriesIncludes.None) + { + var normalized = Services.Tasks.Scanner.Parser.Parser.NormalizePath(folder); + if (string.IsNullOrEmpty(normalized)) return null; + + normalized = normalized.TrimEnd('/'); + + return await _context.Series + .Where(s => !string.IsNullOrEmpty(s.LowestFolderPath) && EF.Functions.Like(normalized, s.LowestFolderPath + "%")) .Includes(includes) .SingleOrDefaultAsync(); } diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index 75715645c..ddc682c32 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Collections.Immutable; using System.IO; using System.Linq; @@ -251,6 +252,9 @@ public static class Seed new() {Key = ServerSettingKey.EmailEnableSsl, Value = "true"}, new() {Key = ServerSettingKey.EmailSizeLimit, Value = 26_214_400 + string.Empty}, new() {Key = ServerSettingKey.EmailCustomizedTemplates, Value = "false"}, + new() {Key = ServerSettingKey.FirstInstallVersion, Value = BuildInfo.Version.ToString()}, + new() {Key = ServerSettingKey.FirstInstallDate, Value = DateTime.UtcNow.ToString()}, + }.ToArray()); foreach (var defaultSetting in DefaultSettings) diff --git a/API/Entities/Enums/ServerSettingKey.cs b/API/Entities/Enums/ServerSettingKey.cs index be53a105d..b1050d553 100644 --- a/API/Entities/Enums/ServerSettingKey.cs +++ b/API/Entities/Enums/ServerSettingKey.cs @@ -186,5 +186,15 @@ public enum ServerSettingKey /// When the cleanup task should run - Critical to keeping Kavita working /// [Description("TaskCleanup")] - TaskCleanup = 37 + TaskCleanup = 37, + /// + /// The Date Kavita was first installed + /// + [Description("FirstInstallDate")] + FirstInstallDate = 38, + /// + /// The Version of Kavita on the first run + /// + [Description("FirstInstallVersion")] + FirstInstallVersion = 39, } diff --git a/API/Extensions/QueryExtensions/IncludesExtensions.cs b/API/Extensions/QueryExtensions/IncludesExtensions.cs index 8cb0aed01..4890a8b90 100644 --- a/API/Extensions/QueryExtensions/IncludesExtensions.cs +++ b/API/Extensions/QueryExtensions/IncludesExtensions.cs @@ -53,6 +53,12 @@ public static class IncludesExtensions .Include(c => c.Files); } + if (includes.HasFlag(ChapterIncludes.People)) + { + queryable = queryable + .Include(c => c.People); + } + return queryable.AsSplitQuery(); } diff --git a/API/Helpers/Converters/ServerSettingConverter.cs b/API/Helpers/Converters/ServerSettingConverter.cs index c356bb907..1180e4087 100644 --- a/API/Helpers/Converters/ServerSettingConverter.cs +++ b/API/Helpers/Converters/ServerSettingConverter.cs @@ -122,6 +122,12 @@ public class ServerSettingConverter : ITypeConverter, destination.SmtpConfig ??= new SmtpConfigDto(); destination.SmtpConfig.CustomizedTemplates = bool.Parse(row.Value); break; + case ServerSettingKey.FirstInstallDate: + destination.FirstInstallDate = DateTime.Parse(row.Value); + break; + case ServerSettingKey.FirstInstallVersion: + destination.FirstInstallVersion = row.Value; + break; } } diff --git a/API/Services/DownloadService.cs b/API/Services/DownloadService.cs index a8dfd5d50..8a8cff1da 100644 --- a/API/Services/DownloadService.cs +++ b/API/Services/DownloadService.cs @@ -35,6 +35,12 @@ public class DownloadService : IDownloadService // Figures out what the content type should be based on the file name. if (!_fileTypeProvider.TryGetContentType(filepath, out var contentType)) { + if (contentType == null) + { + // Get extension + contentType = Path.GetExtension(filepath); + } + contentType = Path.GetExtension(filepath).ToLowerInvariant() switch { ".cbz" => "application/x-cbz", diff --git a/API/Services/Plus/SmartCollectionSyncService.cs b/API/Services/Plus/SmartCollectionSyncService.cs index 79a9c7a23..d5bbf2cce 100644 --- a/API/Services/Plus/SmartCollectionSyncService.cs +++ b/API/Services/Plus/SmartCollectionSyncService.cs @@ -169,6 +169,9 @@ public class SmartCollectionSyncService : ISmartCollectionSyncService s.NormalizedLocalizedName == normalizedSeriesName) && formats.Contains(s.Format)); + _logger.LogDebug("Trying to find {SeriesName} with formats ({Formats}) within Kavita for linking. Found: {ExistingSeriesName} ({ExistingSeriesId})", + seriesInfo.SeriesName, formats, existingSeries?.Name, existingSeries?.Id); + if (existingSeries != null) { await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, diff --git a/API/Services/StatisticService.cs b/API/Services/StatisticService.cs index ea49f353d..cf73e0211 100644 --- a/API/Services/StatisticService.cs +++ b/API/Services/StatisticService.cs @@ -57,7 +57,10 @@ public class StatisticService : IStatisticService public async Task GetUserReadStatistics(int userId, IList libraryIds) { if (libraryIds.Count == 0) + { libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync(); + } + // Total Pages Read var totalPagesRead = await _context.AppUserProgresses diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index c25f97e9f..9704259c4 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -20,7 +20,7 @@ public interface ITaskScheduler Task ScheduleStatsTasks(); void ScheduleUpdaterTasks(); Task ScheduleKavitaPlusTasks(); - void ScanFolder(string folderPath, TimeSpan delay); + void ScanFolder(string folderPath, string originalPath, TimeSpan delay); void ScanFolder(string folderPath); void ScanLibrary(int libraryId, bool force = false); void ScanLibraries(bool force = false); @@ -267,24 +267,38 @@ public class TaskScheduler : ITaskScheduler BackgroundJob.Enqueue(() => CheckForUpdate()); } - public void ScanFolder(string folderPath, TimeSpan delay) + /// + /// Queue up a Scan folder for a folder from Library Watcher. + /// + /// + /// + /// + public void ScanFolder(string folderPath, string originalPath, TimeSpan delay) { var normalizedFolder = Tasks.Scanner.Parser.Parser.NormalizePath(folderPath); - if (HasAlreadyEnqueuedTask(ScannerService.Name, "ScanFolder", [normalizedFolder])) + var normalizedOriginal = Tasks.Scanner.Parser.Parser.NormalizePath(originalPath); + if (HasAlreadyEnqueuedTask(ScannerService.Name, "ScanFolder", [normalizedFolder, normalizedOriginal]) || + HasAlreadyEnqueuedTask(ScannerService.Name, "ScanFolder", [normalizedFolder, string.Empty])) { _logger.LogInformation("Skipped scheduling ScanFolder for {Folder} as a job already queued", normalizedFolder); return; } + // Not sure where we should put this code, but we can get a bunch of ScanFolders when original has slight variations, like + // create a folder, add a new file, etc. All of these can be merged into just 1 request. + + + + _logger.LogInformation("Scheduling ScanFolder for {Folder}", normalizedFolder); - BackgroundJob.Schedule(() => _scannerService.ScanFolder(normalizedFolder), delay); + BackgroundJob.Schedule(() => _scannerService.ScanFolder(normalizedFolder, normalizedOriginal), delay); } public void ScanFolder(string folderPath) { var normalizedFolder = Tasks.Scanner.Parser.Parser.NormalizePath(folderPath); - if (HasAlreadyEnqueuedTask(ScannerService.Name, "ScanFolder", new object[] {normalizedFolder})) + if (HasAlreadyEnqueuedTask(ScannerService.Name, "ScanFolder", [normalizedFolder, string.Empty])) { _logger.LogInformation("Skipped scheduling ScanFolder for {Folder} as a job already queued", normalizedFolder); @@ -292,7 +306,7 @@ public class TaskScheduler : ITaskScheduler } _logger.LogInformation("Scheduling ScanFolder for {Folder}", normalizedFolder); - _scannerService.ScanFolder(normalizedFolder); + _scannerService.ScanFolder(normalizedFolder, string.Empty); } #endregion @@ -350,9 +364,9 @@ public class TaskScheduler : ITaskScheduler public void RefreshMetadata(int libraryId, bool forceUpdate = true) { var alreadyEnqueued = HasAlreadyEnqueuedTask(MetadataService.Name, "GenerateCoversForLibrary", - new object[] {libraryId, true}) || + [libraryId, true]) || HasAlreadyEnqueuedTask("MetadataService", "GenerateCoversForLibrary", - new object[] {libraryId, false}); + [libraryId, false]); if (alreadyEnqueued) { _logger.LogInformation("A duplicate request to refresh metadata for library occured. Skipping"); @@ -365,7 +379,7 @@ public class TaskScheduler : ITaskScheduler public void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = false) { - if (HasAlreadyEnqueuedTask(MetadataService.Name,"GenerateCoversForSeries", new object[] {libraryId, seriesId, forceUpdate})) + if (HasAlreadyEnqueuedTask(MetadataService.Name,"GenerateCoversForSeries", [libraryId, seriesId, forceUpdate])) { _logger.LogInformation("A duplicate request to refresh metadata for library occured. Skipping"); return; @@ -377,7 +391,7 @@ public class TaskScheduler : ITaskScheduler public void ScanSeries(int libraryId, int seriesId, bool forceUpdate = false) { - if (HasAlreadyEnqueuedTask(ScannerService.Name, "ScanSeries", new object[] {seriesId, forceUpdate}, ScanQueue)) + if (HasAlreadyEnqueuedTask(ScannerService.Name, "ScanSeries", [seriesId, forceUpdate], ScanQueue)) { _logger.LogInformation("A duplicate request to scan series occured. Skipping"); return; @@ -396,7 +410,7 @@ public class TaskScheduler : ITaskScheduler public void AnalyzeFilesForSeries(int libraryId, int seriesId, bool forceUpdate = false) { - if (HasAlreadyEnqueuedTask("WordCountAnalyzerService", "ScanSeries", new object[] {libraryId, seriesId, forceUpdate})) + if (HasAlreadyEnqueuedTask("WordCountAnalyzerService", "ScanSeries", [libraryId, seriesId, forceUpdate])) { _logger.LogInformation("A duplicate request to scan series occured. Skipping"); return; @@ -426,13 +440,13 @@ public class TaskScheduler : ITaskScheduler public static bool HasScanTaskRunningForLibrary(int libraryId, bool checkRunningJobs = true) { return - HasAlreadyEnqueuedTask(ScannerService.Name, "ScanLibrary", new object[] {libraryId, true, true}, ScanQueue, + HasAlreadyEnqueuedTask(ScannerService.Name, "ScanLibrary", [libraryId, true, true], ScanQueue, checkRunningJobs) || - HasAlreadyEnqueuedTask(ScannerService.Name, "ScanLibrary", new object[] {libraryId, false, true}, ScanQueue, + HasAlreadyEnqueuedTask(ScannerService.Name, "ScanLibrary", [libraryId, false, true], ScanQueue, checkRunningJobs) || - HasAlreadyEnqueuedTask(ScannerService.Name, "ScanLibrary", new object[] {libraryId, true, false}, ScanQueue, + HasAlreadyEnqueuedTask(ScannerService.Name, "ScanLibrary", [libraryId, true, false], ScanQueue, checkRunningJobs) || - HasAlreadyEnqueuedTask(ScannerService.Name, "ScanLibrary", new object[] {libraryId, false, false}, ScanQueue, + HasAlreadyEnqueuedTask(ScannerService.Name, "ScanLibrary", [libraryId, false, false], ScanQueue, checkRunningJobs); } @@ -445,8 +459,8 @@ public class TaskScheduler : ITaskScheduler public static bool HasScanTaskRunningForSeries(int seriesId, bool checkRunningJobs = true) { return - HasAlreadyEnqueuedTask(ScannerService.Name, "ScanSeries", new object[] {seriesId, true}, ScanQueue, checkRunningJobs) || - HasAlreadyEnqueuedTask(ScannerService.Name, "ScanSeries", new object[] {seriesId, false}, ScanQueue, checkRunningJobs); + HasAlreadyEnqueuedTask(ScannerService.Name, "ScanSeries", [seriesId, true], ScanQueue, checkRunningJobs) || + HasAlreadyEnqueuedTask(ScannerService.Name, "ScanSeries", [seriesId, false], ScanQueue, checkRunningJobs); } /// @@ -488,6 +502,7 @@ public class TaskScheduler : ITaskScheduler return false; } + /// /// Checks against any jobs that are running or about to run /// diff --git a/API/Services/Tasks/Scanner/LibraryWatcher.cs b/API/Services/Tasks/Scanner/LibraryWatcher.cs index 4d5f17cb9..60262136a 100644 --- a/API/Services/Tasks/Scanner/LibraryWatcher.cs +++ b/API/Services/Tasks/Scanner/LibraryWatcher.cs @@ -56,9 +56,9 @@ public class LibraryWatcher : ILibraryWatcher /// /// Counts within a time frame how many times the buffer became full. Is used to reschedule LibraryWatcher to start monitoring much later rather than instantly /// - private int _bufferFullCounter; - private int _restartCounter; - private DateTime _lastErrorTime = DateTime.MinValue; + private static int _bufferFullCounter; + private static int _restartCounter; + private static DateTime _lastErrorTime = DateTime.MinValue; /// /// Used to lock buffer Full Counter /// @@ -262,17 +262,19 @@ public class LibraryWatcher : ILibraryWatcher return; } - _taskScheduler.ScanFolder(fullPath, _queueWaitTime); + _taskScheduler.ScanFolder(fullPath, filePath, _queueWaitTime); } catch (Exception ex) { _logger.LogError(ex, "[LibraryWatcher] An error occured when processing a watch event"); } - _logger.LogDebug("[LibraryWatcher] ProcessChange completed in {ElapsedMilliseconds}ms", sw.ElapsedMilliseconds); + _logger.LogTrace("[LibraryWatcher] ProcessChange completed in {ElapsedMilliseconds}ms", sw.ElapsedMilliseconds); } private string GetFolder(string filePath, IEnumerable libraryFolders) { + // TODO: I can optimize this to avoid a library scan and instead do a Series Scan by finding the series that has a lowestFolderPath higher or equal to the filePath + var parentDirectory = _directoryService.GetParentDirectoryName(filePath); _logger.LogTrace("[LibraryWatcher] Parent Directory: {ParentDirectory}", parentDirectory); if (string.IsNullOrEmpty(parentDirectory)) return string.Empty; diff --git a/API/Services/Tasks/Scanner/ParseScannedFiles.cs b/API/Services/Tasks/Scanner/ParseScannedFiles.cs index 395a2a781..f9b018508 100644 --- a/API/Services/Tasks/Scanner/ParseScannedFiles.cs +++ b/API/Services/Tasks/Scanner/ParseScannedFiles.cs @@ -114,7 +114,6 @@ public class ParseScannedFiles _eventHub = eventHub; } - /// /// This will Scan all files in a folder path. For each folder within the folderPath, FolderAction will be invoked for all files contained /// @@ -122,48 +121,53 @@ public class ParseScannedFiles /// A dictionary mapping a normalized path to a list of to help scanner skip I/O /// A library folder or series folder /// If we should bypass any folder last write time checks on the scan and force I/O - public IList ProcessFiles(string folderPath, bool scanDirectoryByDirectory, + public async Task> ProcessFiles(string folderPath, bool scanDirectoryByDirectory, IDictionary> seriesPaths, Library library, bool forceCheck = false) { - string normalizedPath; - var result = new List(); var fileExtensions = string.Join("|", library.LibraryFileTypes.Select(l => l.FileTypeGroup.GetRegex())); + var matcher = BuildMatcher(library); + + var result = new List(); + if (scanDirectoryByDirectory) { - // This is used in library scan, so we should check first for a ignore file and use that here as well - var matcher = new GlobMatcher(); - foreach (var pattern in library.LibraryExcludePatterns.Where(p => !string.IsNullOrEmpty(p.Pattern))) - { - matcher.AddExclude(pattern.Pattern); - } - - var directories = _directoryService.GetDirectories(folderPath, matcher).ToList(); + var directories = _directoryService.GetDirectories(folderPath, matcher).Select(Parser.Parser.NormalizePath); foreach (var directory in directories) { - // Since this is a loop, we need a list return - normalizedPath = Parser.Parser.NormalizePath(directory); - if (HasSeriesFolderNotChangedSinceLastScan(seriesPaths, normalizedPath, forceCheck)) + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.FileScanProgressEvent(directory, library.Name, ProgressEventType.Updated)); + + if (HasSeriesFolderNotChangedSinceLastScan(seriesPaths, directory, forceCheck)) { - result.Add(new ScanResult() + if (result.Exists(r => r.Folder == directory)) { - Files = ArraySegment.Empty, - Folder = directory, - LibraryRoot = folderPath, - HasChanged = false - }); + continue; + } + result.Add(CreateScanResult(directory, folderPath, false, ArraySegment.Empty)); } - else if (seriesPaths.TryGetValue(normalizedPath, out var series) && series.All(s => !string.IsNullOrEmpty(s.LowestFolderPath))) + else if (seriesPaths.TryGetValue(directory, 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 + _logger.LogDebug("[ProcessFiles] {Directory} is dirty and has multiple series folders, checking if we can avoid a full scan", directory); foreach (var seriesModified in series) { - if (HasSeriesFolderNotChangedSinceLastScan(seriesModified, seriesModified.LowestFolderPath!)) + var hasFolderChangedSinceLastScan = library.LastScanned.Truncate(TimeSpan.TicksPerSecond) < + _directoryService + .GetLastWriteTime(seriesModified.LowestFolderPath!) + .Truncate(TimeSpan.TicksPerSecond); + + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.FileScanProgressEvent(seriesModified.LowestFolderPath!, library.Name, ProgressEventType.Updated)); + + if (!hasFolderChangedSinceLastScan) { - result.Add(CreateScanResult(directory, folderPath, false, ArraySegment.Empty)); + _logger.LogDebug("[ProcessFiles] {Directory} subfolder {Folder} did not change since last scan, adding entry to skip", directory, seriesModified.LowestFolderPath); + result.Add(CreateScanResult(seriesModified.LowestFolderPath!, folderPath, false, ArraySegment.Empty)); } else { + _logger.LogDebug("[ProcessFiles] {Directory} subfolder {Folder} changed, adding folders", directory, seriesModified.LowestFolderPath); result.Add(CreateScanResult(directory, folderPath, true, _directoryService.ScanFiles(seriesModified.LowestFolderPath!, fileExtensions, matcher))); } @@ -173,19 +177,22 @@ public class ParseScannedFiles { // 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(CreateScanResult(directory, folderPath, true, - _directoryService.ScanFiles(directory, fileExtensions))); + _directoryService.ScanFiles(directory, fileExtensions, matcher))); } } return result; } - normalizedPath = Parser.Parser.NormalizePath(folderPath); + var normalizedPath = Parser.Parser.NormalizePath(folderPath); var libraryRoot = library.Folders.FirstOrDefault(f => - Parser.Parser.NormalizePath(folderPath).Contains(Parser.Parser.NormalizePath(f.Path)))?.Path ?? + normalizedPath.Contains(Parser.Parser.NormalizePath(f.Path)))?.Path ?? folderPath; + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.FileScanProgressEvent(normalizedPath, library.Name, ProgressEventType.Updated)); + if (HasSeriesFolderNotChangedSinceLastScan(seriesPaths, normalizedPath, forceCheck)) { result.Add(CreateScanResult(folderPath, libraryRoot, false, ArraySegment.Empty)); @@ -193,13 +200,24 @@ public class ParseScannedFiles else { result.Add(CreateScanResult(folderPath, libraryRoot, true, - _directoryService.ScanFiles(folderPath, fileExtensions))); + _directoryService.ScanFiles(folderPath, fileExtensions, matcher))); } return result; } + private static GlobMatcher BuildMatcher(Library library) + { + var matcher = new GlobMatcher(); + foreach (var pattern in library.LibraryExcludePatterns.Where(p => !string.IsNullOrEmpty(p.Pattern))) + { + matcher.AddExclude(pattern.Pattern); + } + + return matcher; + } + private static ScanResult CreateScanResult(string folderPath, string libraryRoot, bool hasChanged, IList files) { @@ -243,7 +261,7 @@ public class ParseScannedFiles NormalizedName = normalizedSeries }; - scannedSeries.AddOrUpdate(existingKey, new List() {info}, (_, oldValue) => + scannedSeries.AddOrUpdate(existingKey, [info], (_, oldValue) => { oldValue ??= new List(); if (!oldValue.Contains(info)) @@ -338,7 +356,7 @@ public class ParseScannedFiles { try { - var scanResults = ProcessFiles(folderPath, isLibraryScan, seriesPaths, library, forceCheck); + var scanResults = await ProcessFiles(folderPath, isLibraryScan, seriesPaths, library, forceCheck); foreach (var scanResult in scanResults) { @@ -414,15 +432,19 @@ public class ParseScannedFiles /// private async Task ProcessScanResult(ScanResult result, IDictionary> seriesPaths, Library library) { + // TODO: This should return the result as we are modifying it as a side effect + // If the folder hasn't changed, generate fake ParserInfos for the Series that were in that folder. + var normalizedFolder = Parser.Parser.NormalizePath(result.Folder); if (!result.HasChanged) { - var normalizedFolder = Parser.Parser.NormalizePath(result.Folder); - result.ParserInfos = seriesPaths[normalizedFolder].Select(fp => new ParserInfo() - { - Series = fp.SeriesName, - Format = fp.Format, - }).ToList(); + result.ParserInfos = seriesPaths[normalizedFolder] + .Select(fp => new ParserInfo() + { + Series = fp.SeriesName, + Format = fp.Format, + }) + .ToList(); _logger.LogDebug("[ScannerService] Skipped File Scan for {Folder} as it hasn't changed since last scan", normalizedFolder); await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, @@ -431,25 +453,24 @@ public class ParseScannedFiles } var files = result.Files; - var folder = result.Folder; - var libraryRoot = result.LibraryRoot; // When processing files for a folder and we do enter, we need to parse the information and combine parser infos // NOTE: We might want to move the merge step later in the process, like return and combine. - _logger.LogDebug("[ScannerService] Found {Count} files for {Folder}", files.Count, folder); - await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, - MessageFactory.FileScanProgressEvent($"{files.Count} files in {folder}", library.Name, ProgressEventType.Updated)); if (files.Count == 0) { - _logger.LogInformation("[ScannerService] {Folder} is empty, no longer in this location, or has no file types that match Library File Types", folder); + _logger.LogInformation("[ScannerService] {Folder} is empty, no longer in this location, or has no file types that match Library File Types", normalizedFolder); result.ParserInfos = ArraySegment.Empty; return; } + _logger.LogDebug("[ScannerService] Found {Count} files for {Folder}", files.Count, normalizedFolder); + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.FileScanProgressEvent($"{files.Count} files in {normalizedFolder}", library.Name, ProgressEventType.Updated)); + // Multiple Series can exist within a folder. We should instead put these infos on the result and perform merging above IList infos = files - .Select(file => _readingItemService.ParseFile(file, folder, libraryRoot, library.Type)) + .Select(file => _readingItemService.ParseFile(file, normalizedFolder, result.LibraryRoot, library.Type)) .Where(info => info != null) .ToList()!; diff --git a/API/Services/Tasks/Scanner/Parser/BasicParser.cs b/API/Services/Tasks/Scanner/Parser/BasicParser.cs index 12a66b98e..98264faf8 100644 --- a/API/Services/Tasks/Scanner/Parser/BasicParser.cs +++ b/API/Services/Tasks/Scanner/Parser/BasicParser.cs @@ -26,7 +26,7 @@ public class BasicParser(IDirectoryService directoryService, IDefaultParser imag { Filename = Path.GetFileName(filePath), Format = Parser.ParseFormat(filePath), - Title = Parser.RemoveExtensionIfSupported(fileName), + Title = Parser.RemoveExtensionIfSupported(fileName)!, FullFilePath = Parser.NormalizePath(filePath), Series = string.Empty, ComicInfo = comicInfo @@ -76,6 +76,9 @@ public class BasicParser(IDirectoryService directoryService, IDefaultParser imag ret.Chapters = Parser.DefaultChapter; ret.Volumes = Parser.SpecialVolume; + // NOTE: This uses rootPath. LibraryRoot works better for manga, but it's not always that way. + // It might be worth writing some logic if the file is a special, to take the folder above the Specials/ + // if present ParseFromFallbackFolders(filePath, rootPath, type, ref ret); } diff --git a/API/Services/Tasks/Scanner/Parser/DefaultParser.cs b/API/Services/Tasks/Scanner/Parser/DefaultParser.cs index 6b3717828..f59a3b66f 100644 --- a/API/Services/Tasks/Scanner/Parser/DefaultParser.cs +++ b/API/Services/Tasks/Scanner/Parser/DefaultParser.cs @@ -101,7 +101,7 @@ public abstract class DefaultParser(IDirectoryService directoryService) : IDefau } } - protected void UpdateFromComicInfo(ParserInfo info) + protected static void UpdateFromComicInfo(ParserInfo info) { if (info.ComicInfo == null) return; @@ -109,6 +109,10 @@ public abstract class DefaultParser(IDirectoryService directoryService) : IDefau { info.Volumes = info.ComicInfo.Volume; } + if (!string.IsNullOrEmpty(info.ComicInfo.Number)) + { + info.Chapters = info.ComicInfo.Number; + } if (!string.IsNullOrEmpty(info.ComicInfo.Series)) { info.Series = info.ComicInfo.Series.Trim(); @@ -125,16 +129,6 @@ public abstract class DefaultParser(IDirectoryService directoryService) : IDefau info.Volumes = Parser.SpecialVolume; } - if (!string.IsNullOrEmpty(info.ComicInfo.Number)) - { - info.Chapters = info.ComicInfo.Number; - if (info.IsSpecial && Parser.DefaultChapter != info.Chapters) - { - info.IsSpecial = false; - info.Volumes = Parser.SpecialVolume; - } - } - // Patch is SeriesSort from ComicInfo if (!string.IsNullOrEmpty(info.ComicInfo.TitleSort)) { diff --git a/API/Services/Tasks/Scanner/Parser/Parser.cs b/API/Services/Tasks/Scanner/Parser/Parser.cs index c86d99548..24bb3ef7a 100644 --- a/API/Services/Tasks/Scanner/Parser/Parser.cs +++ b/API/Services/Tasks/Scanner/Parser/Parser.cs @@ -103,7 +103,11 @@ public static class Parser private static readonly Regex CoverImageRegex = new Regex(@"(? + /// Normalize everything within Kavita. Some characters don't fall under Unicode, like full-width characters and need to be + /// added on a case-by-case basis. + /// + private static readonly Regex NormalizeRegex = new Regex(@"[^\p{L}0-9\+!*!+]", MatchOptions, RegexTimeout); /// diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index 6ec65793e..ab8340be0 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -45,7 +45,7 @@ public interface IScannerService [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)] Task ScanSeries(int seriesId, bool bypassFolderOptimizationChecks = true); - Task ScanFolder(string folder); + Task ScanFolder(string folder, string originalPath); Task AnalyzeFiles(); } @@ -135,30 +135,35 @@ public class ScannerService : IScannerService /// Given a generic folder path, will invoke a Series scan or Library scan. /// /// This will Schedule the job to run 1 minute in the future to allow for any close-by duplicate requests to be dropped - /// - public async Task ScanFolder(string folder) + /// Normalized folder + /// If invoked from LibraryWatcher, this maybe a nested folder and can allow for optimization + public async Task ScanFolder(string folder, string originalPath) { Series? series = null; try { - series = await _unitOfWork.SeriesRepository.GetSeriesByFolderPath(folder, SeriesIncludes.Library); + series = await _unitOfWork.SeriesRepository.GetSeriesThatContainsLowestFolderPath(originalPath, + SeriesIncludes.Library) ?? + await _unitOfWork.SeriesRepository.GetSeriesByFolderPath(originalPath, SeriesIncludes.Library) ?? + await _unitOfWork.SeriesRepository.GetSeriesByFolderPath(folder, SeriesIncludes.Library); } catch (InvalidOperationException ex) { if (ex.Message.Equals("Sequence contains more than one element.")) { - _logger.LogCritical(ex, "[ScannerService] Multiple series map to this folder. Library scan will be used for ScanFolder"); + _logger.LogCritical(ex, "[ScannerService] Multiple series map to this folder or folder is at library root. Library scan will be used for ScanFolder"); } } // TODO: Figure out why we have the library type restriction here - if (series != null && (series.Library.Type != LibraryType.Book || series.Library.Type != LibraryType.LightNovel)) + if (series != null && series.Library.Type is not (LibraryType.Book or LibraryType.LightNovel)) { if (TaskScheduler.HasScanTaskRunningForSeries(series.Id)) { _logger.LogInformation("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this series. Dropping request", folder); return; } + _logger.LogInformation("[ScannerService] Scan folder invoked for {Folder}, Series matched to folder and ScanSeries enqueued for 1 minute", folder); BackgroundJob.Schedule(() => ScanSeries(series.Id, true), TimeSpan.FromMinutes(1)); return; } diff --git a/API/Services/Tasks/StatsService.cs b/API/Services/Tasks/StatsService.cs index 48602a12e..9cc93fefd 100644 --- a/API/Services/Tasks/StatsService.cs +++ b/API/Services/Tasks/StatsService.cs @@ -181,7 +181,9 @@ public class StatsService : IStatsService { InstallId = serverSettings.InstallId, KavitaVersion = serverSettings.InstallVersion, - IsDocker = OsInfo.IsDocker + IsDocker = OsInfo.IsDocker, + FirstInstallDate = serverSettings.FirstInstallDate, + FirstInstallVersion = serverSettings.FirstInstallVersion }; } diff --git a/API/Startup.cs b/API/Startup.cs index 186a8802f..c6b1e852d 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -268,6 +268,7 @@ public class Startup // v0.8.2 await ManualMigrateThemeDescription.Migrate(dataContext, logger); + await MigrateInitialInstallData.Migrate(dataContext, logger, directoryService); // Update the version in the DB after all migrations are run var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion); diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index 3f45da67b..1e46a9f17 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -14,10 +14,10 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + \ No newline at end of file diff --git a/UI/Web/src/app/_services/action-factory.service.ts b/UI/Web/src/app/_services/action-factory.service.ts index d103cf133..ecd2ee604 100644 --- a/UI/Web/src/app/_services/action-factory.service.ts +++ b/UI/Web/src/app/_services/action-factory.service.ts @@ -10,6 +10,7 @@ import { Volume } from '../_models/volume'; import { AccountService } from './account.service'; import { DeviceService } from './device.service'; import {SideNavStream} from "../_models/sidenav/sidenav-stream"; +import {SmartFilter} from "../_models/metadata/v2/smart-filter"; export enum Action { Submenu = -1, @@ -150,6 +151,7 @@ export class ActionFactoryService { bookmarkActions: Array> = []; sideNavStreamActions: Array> = []; + smartFilterActions: Array> = []; isAdmin = false; @@ -178,6 +180,10 @@ export class ActionFactoryService { return this.applyCallbackToList(this.sideNavStreamActions, callback); } + getSmartFilterActions(callback: ActionCallback) { + return this.applyCallbackToList(this.smartFilterActions, callback); + } + getVolumeActions(callback: ActionCallback) { return this.applyCallbackToList(this.volumeActions, callback); } @@ -620,6 +626,16 @@ export class ActionFactoryService { children: [], }, ]; + + this.smartFilterActions = [ + { + action: Action.Delete, + title: 'delete', + callback: this.dummyCallback, + requiresAdmin: false, + children: [], + }, + ]; } private applyCallback(action: ActionItem, callback: (action: ActionItem, data: any) => void) { diff --git a/UI/Web/src/app/_services/action.service.ts b/UI/Web/src/app/_services/action.service.ts index 5a14c54ac..22b8d862d 100644 --- a/UI/Web/src/app/_services/action.service.ts +++ b/UI/Web/src/app/_services/action.service.ts @@ -1,7 +1,7 @@ import { Injectable, OnDestroy } from '@angular/core'; import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; import { ToastrService } from 'ngx-toastr'; -import { Subject } from 'rxjs'; +import {Subject, tap} from 'rxjs'; import { take } from 'rxjs/operators'; import { BulkAddToCollectionComponent } from '../cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component'; import { AddToListModalComponent, ADD_FLOW } from '../reading-list/_modals/add-to-list-modal/add-to-list-modal.component'; @@ -19,9 +19,11 @@ import { LibraryService } from './library.service'; import { MemberService } from './member.service'; import { ReaderService } from './reader.service'; import { SeriesService } from './series.service'; -import {translate, TranslocoService} from "@ngneat/transloco"; +import {translate} from "@ngneat/transloco"; import {UserCollection} from "../_models/collection-tag"; import {CollectionTagService} from "./collection-tag.service"; +import {SmartFilter} from "../_models/metadata/v2/smart-filter"; +import {FilterService} from "./filter.service"; export type LibraryActionCallback = (library: Partial) => void; export type SeriesActionCallback = (series: Series) => void; @@ -46,7 +48,7 @@ export class ActionService implements OnDestroy { constructor(private libraryService: LibraryService, private seriesService: SeriesService, private readerService: ReaderService, private toastr: ToastrService, private modalService: NgbModal, private confirmService: ConfirmService, private memberService: MemberService, private deviceService: DeviceService, - private readonly collectionTagService: CollectionTagService) { } + private readonly collectionTagService: CollectionTagService, private filterService: FilterService) { } ngOnDestroy() { this.onDestroy.next(); @@ -655,4 +657,21 @@ export class ActionService implements OnDestroy { }); } + async deleteFilter(filterId: number, callback?: BooleanActionCallback) { + if (!await this.confirmService.confirm(translate('toasts.confirm-delete-smart-filter'))) { + if (callback) { + callback(false); + } + return; + } + + this.filterService.deleteFilter(filterId).subscribe(_ => { + this.toastr.success(translate('toasts.smart-filter-deleted')); + + if (callback) { + callback(true); + } + }); + } + } diff --git a/UI/Web/src/app/admin/_models/server-info.ts b/UI/Web/src/app/admin/_models/server-info.ts index 751c87534..c67bab4e9 100644 --- a/UI/Web/src/app/admin/_models/server-info.ts +++ b/UI/Web/src/app/admin/_models/server-info.ts @@ -1,5 +1,7 @@ export interface ServerInfoSlim { - kavitaVersion: string; - installId: string; - isDocker: boolean; + kavitaVersion: string; + installId: string; + isDocker: boolean; + firstInstallVersion?: string; + firstInstallDate?: string; } diff --git a/UI/Web/src/app/admin/manage-system/manage-system.component.html b/UI/Web/src/app/admin/manage-system/manage-system.component.html index 34583edb7..aabe99ff9 100644 --- a/UI/Web/src/app/admin/manage-system/manage-system.component.html +++ b/UI/Web/src/app/admin/manage-system/manage-system.component.html @@ -9,6 +9,12 @@
{{t('installId-title')}}
{{serverInfo.installId}}
+ +
{{t('first-install-version-title')}}
+
{{serverInfo.firstInstallVersion | defaultValue}}
+ +
{{t('first-install-date-title')}}
+
{{serverInfo.firstInstallDate | date:'shortDate'}}
diff --git a/UI/Web/src/app/admin/manage-system/manage-system.component.ts b/UI/Web/src/app/admin/manage-system/manage-system.component.ts index 5d8976e3b..27f129c8b 100644 --- a/UI/Web/src/app/admin/manage-system/manage-system.component.ts +++ b/UI/Web/src/app/admin/manage-system/manage-system.component.ts @@ -1,9 +1,11 @@ import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core'; import {ServerService} from 'src/app/_services/server.service'; import {ServerInfoSlim} from '../_models/server-info'; -import {NgIf} from '@angular/common'; +import {DatePipe, NgIf} from '@angular/common'; import {TranslocoDirective} from "@ngneat/transloco"; import {ChangelogComponent} from "../../announcements/_components/changelog/changelog.component"; +import {DefaultDatePipe} from "../../_pipes/default-date.pipe"; +import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; @Component({ selector: 'app-manage-system', @@ -11,18 +13,16 @@ import {ChangelogComponent} from "../../announcements/_components/changelog/chan styleUrls: ['./manage-system.component.scss'], standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, - imports: [NgIf, TranslocoDirective, ChangelogComponent] + imports: [NgIf, TranslocoDirective, ChangelogComponent, DefaultDatePipe, DefaultValuePipe, DatePipe] }) export class ManageSystemComponent implements OnInit { - serverInfo!: ServerInfoSlim; private readonly cdRef = inject(ChangeDetectorRef); + private readonly serverService = inject(ServerService); - - constructor(public serverService: ServerService) { } + serverInfo!: ServerInfoSlim; ngOnInit(): void { - this.serverService.getServerInfo().subscribe(info => { this.serverInfo = info; this.cdRef.markForCheck(); diff --git a/UI/Web/src/app/all-filters/all-filters.component.html b/UI/Web/src/app/all-filters/all-filters.component.html index ba5a7fca5..bde282b6d 100644 --- a/UI/Web/src/app/all-filters/all-filters.component.html +++ b/UI/Web/src/app/all-filters/all-filters.component.html @@ -3,32 +3,18 @@

{{t('title')}}

-
{{t('count', {count: filters.length | number})}}
+
+
+ {{t('count', {count: filters.length | number})}} + {{t('create')}} +
+ +
+ - - - -
-
-
-
-
- - - -
-
-
-
- {{item.name}} -
-
-
-
+ + + + diff --git a/UI/Web/src/app/all-filters/all-filters.component.scss b/UI/Web/src/app/all-filters/all-filters.component.scss index 9d517144c..139597f9c 100644 --- a/UI/Web/src/app/all-filters/all-filters.component.scss +++ b/UI/Web/src/app/all-filters/all-filters.component.scss @@ -1,16 +1,2 @@ -.card-title { - width: 146px; -} -.error { - color: var(--error-color); -} -.card, .overlay { - height: 160px; -} - -.card-item-container .overlay-information.overlay-information--centered { - top: 54px; - left: 51px; -} diff --git a/UI/Web/src/app/all-filters/all-filters.component.ts b/UI/Web/src/app/all-filters/all-filters.component.ts index 0174e871d..bb9ec0df8 100644 --- a/UI/Web/src/app/all-filters/all-filters.component.ts +++ b/UI/Web/src/app/all-filters/all-filters.component.ts @@ -2,7 +2,7 @@ import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, injec import {CommonModule} from '@angular/common'; import {JumpKey} from "../_models/jumpbar/jump-key"; import {EVENTS, Message, MessageHubService} from "../_services/message-hub.service"; -import {TranslocoDirective} from "@ngneat/transloco"; +import {translate, TranslocoDirective} from "@ngneat/transloco"; import {CardItemComponent} from "../cards/card-item/card-item.component"; import { SideNavCompanionBarComponent @@ -11,14 +11,20 @@ import {SmartFilter} from "../_models/metadata/v2/smart-filter"; import {FilterService} from "../_services/filter.service"; import {CardDetailLayoutComponent} from "../cards/card-detail-layout/card-detail-layout.component"; import {SafeHtmlPipe} from "../_pipes/safe-html.pipe"; -import {Router} from "@angular/router"; +import {Router, RouterLink} from "@angular/router"; import {Series} from "../_models/series"; import {JumpbarService} from "../_services/jumpbar.service"; +import {Action, ActionFactoryService, ActionItem} from "../_services/action-factory.service"; +import {CardActionablesComponent} from "../_single-module/card-actionables/card-actionables.component"; +import {ActionService} from "../_services/action.service"; +import {FilterPipe} from "../_pipes/filter.pipe"; +import {filter} from "rxjs"; +import {ManageSmartFiltersComponent} from "../sidenav/_components/manage-smart-filters/manage-smart-filters.component"; @Component({ selector: 'app-all-filters', standalone: true, - imports: [CommonModule, TranslocoDirective, CardItemComponent, SideNavCompanionBarComponent, CardDetailLayoutComponent, SafeHtmlPipe], + imports: [CommonModule, TranslocoDirective, CardItemComponent, SideNavCompanionBarComponent, CardDetailLayoutComponent, SafeHtmlPipe, CardActionablesComponent, RouterLink, FilterPipe, ManageSmartFiltersComponent], templateUrl: './all-filters.component.html', styleUrl: './all-filters.component.scss', changeDetection: ChangeDetectionStrategy.OnPush @@ -28,13 +34,20 @@ export class AllFiltersComponent implements OnInit { private readonly jumpbarService = inject(JumpbarService); private readonly router = inject(Router); private readonly filterService = inject(FilterService); + private readonly actionFactory = inject(ActionFactoryService); + private readonly actionService = inject(ActionService); + filterActions = this.actionFactory.getSmartFilterActions(this.handleAction.bind(this)); jumpbarKeys: Array = []; filters: SmartFilter[] = []; isLoading = true; trackByIdentity = (index: number, item: SmartFilter) => item.name; ngOnInit() { + this.loadData(); + } + + loadData() { this.filterService.getAllFilters().subscribe(filters => { this.filters = filters; this.jumpbarKeys = this.jumpbarService.getJumpKeys(this.filters, (s: Series) => s.name); @@ -51,4 +64,23 @@ export class AllFiltersComponent implements OnInit { return !decodeURIComponent(filter.filter).includes('¦'); } + async deleteFilter(filter: SmartFilter) { + await this.actionService.deleteFilter(filter.id, success => { + this.filters = this.filters.filter(f => f.id != filter.id); + this.jumpbarKeys = this.jumpbarService.getJumpKeys(this.filters, (s: Series) => s.name); + this.cdRef.markForCheck(); + }); + } + + async handleAction(action: ActionItem, filter: SmartFilter) { + switch (action.action) { + case(Action.Delete): + await this.deleteFilter(filter); + break; + default: + break; + } + } + + protected readonly filter = filter; } diff --git a/UI/Web/src/app/cards/card-item/card-item.component.html b/UI/Web/src/app/cards/card-item/card-item.component.html index 6eff15d29..b3cf05b7d 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.html +++ b/UI/Web/src/app/cards/card-item/card-item.component.html @@ -57,15 +57,18 @@ @if (title.length > 0 || actions.length > 0) {
- - - - {{title}} - - - - + + + + {{title}} + + @if (actions && actions.length > 0) { + + + + }
+ @if (subtitleTemplate) {
diff --git a/UI/Web/src/app/cards/card-item/card-item.component.ts b/UI/Web/src/app/cards/card-item/card-item.component.ts index 3c14ae1f4..c2e8dd46a 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.ts +++ b/UI/Web/src/app/cards/card-item/card-item.component.ts @@ -37,7 +37,7 @@ import {FormsModule} from "@angular/forms"; import {MangaFormatPipe} from "../../_pipes/manga-format.pipe"; import {MangaFormatIconPipe} from "../../_pipes/manga-format-icon.pipe"; import {SentenceCasePipe} from "../../_pipes/sentence-case.pipe"; -import {CommonModule} from "@angular/common"; +import {DecimalPipe, NgTemplateOutlet} from "@angular/common"; import {RouterLink, RouterLinkActive} from "@angular/router"; import {TranslocoModule} from "@ngneat/transloco"; import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component"; @@ -51,7 +51,6 @@ import {SeriesFormatComponent} from "../../shared/series-format/series-format.co selector: 'app-card-item', standalone: true, imports: [ - CommonModule, ImageComponent, NgbProgressbar, DownloadIndicatorComponent, @@ -66,7 +65,9 @@ import {SeriesFormatComponent} from "../../shared/series-format/series-format.co SafeHtmlPipe, RouterLinkActive, PromotedIconComponent, - SeriesFormatComponent + SeriesFormatComponent, + DecimalPipe, + NgTemplateOutlet ], templateUrl: './card-item.component.html', styleUrls: ['./card-item.component.scss'], diff --git a/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.html b/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.html index d58bf3035..5098a07ec 100644 --- a/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.html +++ b/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.html @@ -22,7 +22,7 @@ width="16px" height="16px" [styles]="{'vertical-align': 'text-top'}" [ngbTooltip]="collectionTag.source | providerName" tabindex="0"> {{t('sync-progress', {title: series.length + ' / ' + collectionTag.totalSourceCount})}} - +
}
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 e6be284c8..07d82a7fc 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 @@ -154,12 +154,17 @@ } - @if (hasData && !includeChapterAndFiles) { -
  • + @if (hasData && (isAdmin$ | async)) { +
  • - - {{t('include-extras')}} - +
    +
    + + +
    +
    +
  • } 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 727accefc..981f7b49f 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 @@ -13,14 +13,16 @@ import { TemplateRef, ViewChild } from '@angular/core'; -import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; -import { debounceTime } from 'rxjs/operators'; +import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms'; +import {debounceTime, distinctUntilChanged} 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, NgTemplateOutlet } from '@angular/common'; +import {AsyncPipe, NgClass, NgTemplateOutlet} from '@angular/common'; import {TranslocoDirective} from "@ngneat/transloco"; import {LoadingComponent} from "../../../shared/loading/loading.component"; +import {map, startWith, tap} from "rxjs"; +import {AccountService} from "../../../_services/account.service"; export interface SearchEvent { value: string; @@ -33,11 +35,12 @@ export interface SearchEvent { styleUrls: ['./grouped-typeahead.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [ReactiveFormsModule, NgClass, NgTemplateOutlet, TranslocoDirective, LoadingComponent] + imports: [ReactiveFormsModule, NgClass, NgTemplateOutlet, TranslocoDirective, LoadingComponent, AsyncPipe] }) export class GroupedTypeaheadComponent implements OnInit { private readonly destroyRef = inject(DestroyRef); private readonly cdRef = inject(ChangeDetectorRef); + private readonly accountService = inject(AccountService); /** * Unique id to tie with a label element @@ -97,12 +100,15 @@ export class GroupedTypeaheadComponent implements OnInit { @ContentChild('bookmarkTemplate') bookmarkTemplate!: TemplateRef; - hasFocus: boolean = false; typeaheadForm: FormGroup = new FormGroup({}); includeChapterAndFiles: boolean = false; - prevSearchTerm: string = ''; + searchSettingsForm = new FormGroup(({'includeExtras': new FormControl(false)})); + isAdmin$ = this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef), map(u => { + if (!u) return false; + return this.accountService.hasAdminRole(u); + })); get searchTerm() { return this.typeaheadForm.get('typeahead')?.value || ''; @@ -139,6 +145,17 @@ export class GroupedTypeaheadComponent implements OnInit { this.typeaheadForm.addControl('typeahead', new FormControl(this.initialValue, [])); this.cdRef.markForCheck(); + this.searchSettingsForm.get('includeExtras')!.valueChanges.pipe( + startWith(false), + map(val => { + if (val === null) return false; + return val; + }), + distinctUntilChanged(), + tap((val: boolean) => this.toggleIncludeFiles(val)), + takeUntilDestroyed(this.destroyRef) + ).subscribe(); + this.typeaheadForm.valueChanges.pipe( debounceTime(this.debounceTime), takeUntilDestroyed(this.destroyRef) @@ -183,13 +200,22 @@ export class GroupedTypeaheadComponent implements OnInit { this.selected.emit(item); } - toggleIncludeFiles() { - this.includeChapterAndFiles = true; + toggleIncludeFiles(val: boolean) { + const firstRun = val === false && val === this.includeChapterAndFiles; + + this.includeChapterAndFiles = val; this.inputChanged.emit({value: this.searchTerm, includeFiles: this.includeChapterAndFiles}); - this.hasFocus = true; - this.inputElem.nativeElement.focus(); - this.openDropdown(); + if (!firstRun) { + this.hasFocus = true; + if (this.inputElem && this.inputElem.nativeElement) { + this.inputElem.nativeElement.focus(); + } + + this.openDropdown(); + } + + this.cdRef.markForCheck(); } diff --git a/UI/Web/src/app/shared/edit-list/edit-list.component.html b/UI/Web/src/app/shared/edit-list/edit-list.component.html index 8fbd7568d..461fef21e 100644 --- a/UI/Web/src/app/shared/edit-list/edit-list.component.html +++ b/UI/Web/src/app/shared/edit-list/edit-list.component.html @@ -1,6 +1,6 @@
    - @for(item of Items; let i = $index; track item) { + @for(item of Items; let i = $index; track item; let isFirst = $first) {
    @@ -13,7 +13,7 @@ {{t('common.add')}} - diff --git a/UI/Web/src/app/sidenav/_components/manage-smart-filters/manage-smart-filters.component.html b/UI/Web/src/app/sidenav/_components/manage-smart-filters/manage-smart-filters.component.html index 04194f47a..d9b170f10 100644 --- a/UI/Web/src/app/sidenav/_components/manage-smart-filters/manage-smart-filters.component.html +++ b/UI/Web/src/app/sidenav/_components/manage-smart-filters/manage-smart-filters.component.html @@ -1,25 +1,35 @@ -
    - -
    - - + @if (filters.length >= 3) { +
    + +
    + + +
    -
    + }
      -
    • - {{f.name}} - -
    • - -
    • - {{t('no-data')}} -
    • + @for(f of filters | filter: filterList; track f.name) { +
    • + + @if (isErrored(f)) { + + {{t('errored')}} + } + {{f.name}} + + +
    • + } @empty { +
    • + {{t('no-data')}} +
    • + }
    diff --git a/UI/Web/src/app/sidenav/_components/manage-smart-filters/manage-smart-filters.component.scss b/UI/Web/src/app/sidenav/_components/manage-smart-filters/manage-smart-filters.component.scss index 1366fd3bb..2624cf753 100644 --- a/UI/Web/src/app/sidenav/_components/manage-smart-filters/manage-smart-filters.component.scss +++ b/UI/Web/src/app/sidenav/_components/manage-smart-filters/manage-smart-filters.component.scss @@ -19,3 +19,7 @@ ul { } } } + +.red { + color: var(--error-color); +} diff --git a/UI/Web/src/app/sidenav/_components/manage-smart-filters/manage-smart-filters.component.ts b/UI/Web/src/app/sidenav/_components/manage-smart-filters/manage-smart-filters.component.ts index b5577d08e..4a09c1a92 100644 --- a/UI/Web/src/app/sidenav/_components/manage-smart-filters/manage-smart-filters.component.ts +++ b/UI/Web/src/app/sidenav/_components/manage-smart-filters/manage-smart-filters.component.ts @@ -1,18 +1,16 @@ import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject} from '@angular/core'; -import {CommonModule} from '@angular/common'; import {FilterService} from "../../../_services/filter.service"; import {SmartFilter} from "../../../_models/metadata/v2/smart-filter"; -import {Router} from "@angular/router"; -import {ConfirmService} from "../../../shared/confirm.service"; -import {translate, TranslocoDirective} from "@ngneat/transloco"; -import {ToastrService} from "ngx-toastr"; +import {TranslocoDirective} from "@ngneat/transloco"; import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms"; import {FilterPipe} from "../../../_pipes/filter.pipe"; +import {ActionService} from "../../../_services/action.service"; +import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; @Component({ selector: 'app-manage-smart-filters', standalone: true, - imports: [CommonModule, ReactiveFormsModule, TranslocoDirective, FilterPipe], + imports: [ReactiveFormsModule, TranslocoDirective, FilterPipe, NgbTooltip], templateUrl: './manage-smart-filters.component.html', styleUrls: ['./manage-smart-filters.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush @@ -20,10 +18,9 @@ import {FilterPipe} from "../../../_pipes/filter.pipe"; export class ManageSmartFiltersComponent { private readonly filterService = inject(FilterService); - private readonly confirmService = inject(ConfirmService); - private readonly router = inject(Router); private readonly cdRef = inject(ChangeDetectorRef); - private readonly toastr = inject(ToastrService); + private readonly actionService = inject(ActionService); + filters: Array = []; listForm: FormGroup = new FormGroup({ 'filterQuery': new FormControl('', []) @@ -33,11 +30,6 @@ export class ManageSmartFiltersComponent { const filterVal = (this.listForm.value.filterQuery || '').toLowerCase(); return listItem.name.toLowerCase().indexOf(filterVal) >= 0; } - resetFilter() { - this.listForm.get('filterQuery')?.setValue(''); - this.cdRef.markForCheck(); - } - constructor() { this.loadData(); @@ -50,15 +42,18 @@ export class ManageSmartFiltersComponent { }); } - async loadFilter(f: SmartFilter) { - await this.router.navigateByUrl('all-series?' + f.filter); + resetFilter() { + this.listForm.get('filterQuery')?.setValue(''); + this.cdRef.markForCheck(); + } + + isErrored(filter: SmartFilter) { + return !decodeURIComponent(filter.filter).includes('¦'); } async deleteFilter(f: SmartFilter) { - if (!await this.confirmService.confirm(translate('toasts.confirm-delete-smart-filter'))) return; - - this.filterService.deleteFilter(f.id).subscribe(() => { - this.toastr.success(translate('toasts.smart-filter-deleted')); + await this.actionService.deleteFilter(f.id, success => { + if (!success) return; this.resetFilter(); this.loadData(); }); 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 d93c48584..6732adede 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 @@ -147,8 +147,10 @@ export class SideNavComponent implements OnInit { 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(); + if (this.homeActions.filter(f => f.title === 'import-mal-stack').length === 0) { + this.homeActions.push({action: Action.Import, title: 'import-mal-stack', children: [], requiresAdmin: true, callback: this.importMalCollection.bind(this)}); + this.cdRef.markForCheck(); + } }) } diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index 14fb87937..ab2008249 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -573,7 +573,8 @@ "all-filters": { "title": "All Smart Filters", - "count": "{{count}} {{customize-dashboard-modal.title-smart-filters}}" + "count": "{{count}} {{customize-dashboard-modal.title-smart-filters}}", + "create": "{{common.create}}" }, "announcements": { @@ -1273,6 +1274,8 @@ "version-title": "Version", "installId-title": "Install ID", "more-info-title": "More Info", + "first-install-version-title": "First Install Version", + "first-install-date-title": "First Install Date", "home-page-title": "Home page:", "wiki-title": "Wiki:", "discord-title": "Discord:", @@ -2022,7 +2025,8 @@ "delete": "{{common.delete}}", "no-data": "No Smart Filters created", "filter": "{{common.filter}}", - "clear": "{{common.clear}}" + "clear": "{{common.clear}}", + "errored": "There is an encoding error in the filter. You need to recreate it." }, "edit-external-source-item": {