diff --git a/API.Tests/Services/ScannerServiceTests.cs b/API.Tests/Services/ScannerServiceTests.cs index 2a70ea79e..7aea0519e 100644 --- a/API.Tests/Services/ScannerServiceTests.cs +++ b/API.Tests/Services/ScannerServiceTests.cs @@ -14,6 +14,7 @@ using API.Data.Metadata; using API.Data.Repositories; using API.Entities; using API.Entities.Enums; +using API.Extensions; using API.Helpers; using API.Helpers.Builders; using API.Services; @@ -157,6 +158,34 @@ public class ScannerServiceTests : AbstractDbTest Assert.Equal(3, postLib.Series.First().Volumes.Count); } + [Fact] + public async Task ScanLibrary_LocalizedSeries2() + { + const string testcase = "Series with Localized 2 - Manga.json"; + + // Get the first file and generate a ComicInfo + var infos = new Dictionary(); + infos.Add("Immoral Guild v01.cbz", new ComicInfo() + { + Series = "Immoral Guild", + LocalizedSeries = "Futoku no Guild" // Filename has a capital N and localizedSeries has lowercase + }); + + var library = await GenerateScannerData(testcase, infos); + + + var scanner = CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + var s = postLib.Series.First(); + Assert.Equal("Immoral Guild", s.Name); + Assert.Equal("Futoku no Guild", s.LocalizedName); + Assert.Equal(3, s.Volumes.Count); + } + /// /// Files under a folder with a SP marker should group into one issue @@ -179,6 +208,109 @@ public class ScannerServiceTests : AbstractDbTest Assert.Equal(3, postLib.Series.First().Volumes.Count); } + /// + /// This test is currently disabled because the Image parser is unable to support multiple files mapping into one single Special. + /// https://github.com/Kareadita/Kavita/issues/3299 + /// + public async Task ScanLibrary_ImageSeries_SpecialGrouping_NonEnglish() + { + const string testcase = "Image Series with SP Folder (Non English) - Image.json"; + + var library = await GenerateScannerData(testcase); + + + var scanner = CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + var series = postLib.Series.First(); + Assert.Equal(3, series.Volumes.Count); + var specialVolume = series.Volumes.FirstOrDefault(v => v.Name == Parser.SpecialVolume); + Assert.NotNull(specialVolume); + Assert.Single(specialVolume.Chapters); + Assert.True(specialVolume.Chapters.First().IsSpecial); + //Assert.Equal("葬送のフリーレン 公式ファンブック SP01", specialVolume.Chapters.First().Title); + } + + + [Fact] + public async Task ScanLibrary_PublishersInheritFromChapters() + { + const string testcase = "Flat Special - Manga.json"; + + var infos = new Dictionary(); + infos.Add("Uzaki-chan Wants to Hang Out! v01 (2019) (Digital) (danke-Empire).cbz", new ComicInfo() + { + Publisher = "Correct Publisher" + }); + infos.Add("Uzaki-chan Wants to Hang Out! - 2022 New Years Special SP01.cbz", new ComicInfo() + { + Publisher = "Special Publisher" + }); + infos.Add("Uzaki-chan Wants to Hang Out! - Ch. 103 - Kouhai and Control.cbz", new ComicInfo() + { + Publisher = "Chapter Publisher" + }); + + var library = await GenerateScannerData(testcase, infos); + + + var scanner = CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + var publishers = postLib.Series.First().Metadata.People + .Where(p => p.Role == PersonRole.Publisher); + Assert.Equal(3, publishers.Count()); + } + + + /// + /// Tests that pdf parser handles the loose chapters correctly + /// https://github.com/Kareadita/Kavita/issues/3148 + /// + [Fact] + public async Task ScanLibrary_LooseChapters_Pdf() + { + const string testcase = "PDF Comic Chapters - Comic.json"; + + var library = await GenerateScannerData(testcase); + + + var scanner = CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + var series = postLib.Series.First(); + Assert.Single(series.Volumes); + Assert.Equal(4, series.Volumes.First().Chapters.Count); + } + + [Fact] + public async Task ScanLibrary_LooseChapters_Pdf_LN() + { + const string testcase = "PDF Comic Chapters - LightNovel.json"; + + var library = await GenerateScannerData(testcase); + + + var scanner = CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + var series = postLib.Series.First(); + Assert.Single(series.Volumes); + Assert.Equal(4, series.Volumes.First().Chapters.Count); + } + #region Setup private async Task GenerateScannerData(string testcase, Dictionary comicInfos = null) diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Image Series with SP Folder (Non English) - Image.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Image Series with SP Folder (Non English) - Image.json new file mode 100644 index 000000000..d283a3460 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Image Series with SP Folder (Non English) - Image.json @@ -0,0 +1,5 @@ +[ + "葬送のフィリーレン/葬送のフィリーレン vol 1/0001.png", + "葬送のフィリーレン/葬送のフィリーレン vol 2/0002.png", + "葬送のフィリーレン/Specials/葬送のフリーレン 公式ファンブック SP01/0001.png" +] \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/PDF Comic Chapters - Comic.json b/API.Tests/Services/Test Data/ScannerService/TestCases/PDF Comic Chapters - Comic.json new file mode 100644 index 000000000..f5097b369 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/PDF Comic Chapters - Comic.json @@ -0,0 +1,6 @@ +[ + "Dreadwolf/Dreadwolf Chapter 1-12.pdf", + "Dreadwolf/Dreadwolf Chapter 13-24.pdf", + "Dreadwolf/Dreadwolf Chapter 25.pdf", + "Dreadwolf/Dreadwolf Chapter 26.pdf" +] \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/PDF Comic Chapters - LightNovel.json b/API.Tests/Services/Test Data/ScannerService/TestCases/PDF Comic Chapters - LightNovel.json new file mode 100644 index 000000000..f5097b369 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/PDF Comic Chapters - LightNovel.json @@ -0,0 +1,6 @@ +[ + "Dreadwolf/Dreadwolf Chapter 1-12.pdf", + "Dreadwolf/Dreadwolf Chapter 13-24.pdf", + "Dreadwolf/Dreadwolf Chapter 25.pdf", + "Dreadwolf/Dreadwolf Chapter 26.pdf" +] \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Localized 2 - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Localized 2 - Manga.json new file mode 100644 index 000000000..26619df88 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Localized 2 - Manga.json @@ -0,0 +1,5 @@ +[ + "Immoral Guild/Immoral Guild v01.cbz", + "Immoral Guild/Immoral Guild v02.cbz", + "Immoral Guild/Futoku No Guild - Vol. 12 Ch. 67 - Take Responsibility.cbz" +] \ No newline at end of file diff --git a/API/DTOs/Collection/AppUserCollectionDto.cs b/API/DTOs/Collection/AppUserCollectionDto.cs index 4c7ba0f44..cde0c1c14 100644 --- a/API/DTOs/Collection/AppUserCollectionDto.cs +++ b/API/DTOs/Collection/AppUserCollectionDto.cs @@ -23,11 +23,15 @@ public class AppUserCollectionDto : IHasCoverImage public string SecondaryColor { get; set; } = string.Empty; public bool CoverImageLocked { get; set; } + /// + /// Number of Series in the Collection + /// + public int ItemCount { get; set; } + /// /// Owner of the Collection /// public string? Owner { get; set; } - /// /// Last time Kavita Synced the Collection with an upstream source (for non Kavita sourced collections) /// diff --git a/API/DTOs/Filtering/v2/FilterField.cs b/API/DTOs/Filtering/v2/FilterField.cs index 2159e4e11..5323f2b48 100644 --- a/API/DTOs/Filtering/v2/FilterField.cs +++ b/API/DTOs/Filtering/v2/FilterField.cs @@ -51,6 +51,10 @@ public enum FilterField AverageRating = 28, Imprint = 29, Team = 30, - Location = 31 + Location = 31, + /// + /// Last time User Read + /// + ReadLast = 32, } diff --git a/API/DTOs/ReadingLists/ReadingListDto.cs b/API/DTOs/ReadingLists/ReadingListDto.cs index 14609b4ae..139039bf5 100644 --- a/API/DTOs/ReadingLists/ReadingListDto.cs +++ b/API/DTOs/ReadingLists/ReadingListDto.cs @@ -22,6 +22,11 @@ public class ReadingListDto : IHasCoverImage public string PrimaryColor { get; set; } = string.Empty; public string SecondaryColor { get; set; } = string.Empty; + /// + /// Number of Items in the Reading List + /// + public int ItemCount { get; set; } + /// /// Minimum Year the Reading List starts /// diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index 741968d3a..835490e0a 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -1253,6 +1253,7 @@ public class SeriesRepository : ISeriesRepository FilterField.ReleaseYear => query.HasReleaseYear(true, statement.Comparison, (int) value), FilterField.ReadTime => query.HasAverageReadTime(true, statement.Comparison, (int) value), FilterField.ReadingDate => query.HasReadingDate(true, statement.Comparison, (DateTime) value, userId), + FilterField.ReadLast => query.HasReadLast(true, statement.Comparison, (int) value, userId), FilterField.AverageRating => query.HasAverageRating(true, statement.Comparison, (float) value), _ => throw new ArgumentOutOfRangeException() }; diff --git a/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs b/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs index a0f88c582..115f84297 100644 --- a/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs +++ b/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs @@ -257,9 +257,9 @@ public static class SeriesFilter .Select(s => new { Series = s, - Percentage = ((float) s.Progress + Percentage = s.Progress .Where(p => p != null && p.AppUserId == userId) - .Sum(p => p != null ? (p.PagesRead * 1.0f / s.Pages) : 0) * 100) + .Sum(p => p != null ? (p.PagesRead * 1.0f / s.Pages) : 0) * 100 }) .AsSplitQuery() .AsEnumerable(); @@ -361,6 +361,72 @@ public static class SeriesFilter return queryable.Where(s => ids.Contains(s.Id)); } + /// + /// HasReadingDate but used to filter where last reading point was TODAY() - timeDeltaDays. This allows the user + /// to build smart filters "Haven't read in a month" + /// + public static IQueryable HasReadLast(this IQueryable queryable, bool condition, + FilterComparison comparison, int timeDeltaDays, int userId) + { + if (!condition || timeDeltaDays == 0) return queryable; + + var subQuery = queryable + .Include(s => s.Progress) + .Where(s => s.Progress != null) + .Select(s => new + { + Series = s, + MaxDate = s.Progress.Where(p => p != null && p.AppUserId == userId) + .Select(p => (DateTime?) p.LastModified) + .DefaultIfEmpty() + .Max() + }) + .Where(s => s.MaxDate != null) + .AsSplitQuery() + .AsEnumerable(); + + var date = DateTime.Now.AddDays(-timeDeltaDays); + + switch (comparison) + { + case FilterComparison.Equal: + subQuery = subQuery.Where(s => s.MaxDate != null && s.MaxDate.Equals(date)); + break; + case FilterComparison.IsAfter: + case FilterComparison.GreaterThan: + subQuery = subQuery.Where(s => s.MaxDate != null && s.MaxDate > date); + break; + case FilterComparison.GreaterThanEqual: + subQuery = subQuery.Where(s => s.MaxDate != null && s.MaxDate >= date); + break; + case FilterComparison.IsBefore: + case FilterComparison.LessThan: + subQuery = subQuery.Where(s => s.MaxDate != null && s.MaxDate < date); + break; + case FilterComparison.LessThanEqual: + subQuery = subQuery.Where(s => s.MaxDate != null && s.MaxDate <= date); + break; + case FilterComparison.NotEqual: + subQuery = subQuery.Where(s => s.MaxDate != null && !s.MaxDate.Equals(date)); + break; + case FilterComparison.Matches: + case FilterComparison.Contains: + case FilterComparison.NotContains: + case FilterComparison.BeginsWith: + case FilterComparison.EndsWith: + case FilterComparison.IsInLast: + case FilterComparison.IsNotInLast: + case FilterComparison.MustContains: + case FilterComparison.IsEmpty: + throw new KavitaException($"{comparison} not applicable for Series.ReadProgress"); + default: + throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); + } + + var ids = subQuery.Select(s => s.Series.Id).ToList(); + return queryable.Where(s => ids.Contains(s.Id)); + } + public static IQueryable HasReadingDate(this IQueryable queryable, bool condition, FilterComparison comparison, DateTime? date, int userId) { diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index 89cd93d12..6c8ad418f 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -59,7 +59,8 @@ public class AutoMapperProfiles : Profile CreateMap(); CreateMap(); CreateMap() - .ForMember(dest => dest.Owner, opt => opt.MapFrom(src => src.AppUser.UserName)); + .ForMember(dest => dest.Owner, opt => opt.MapFrom(src => src.AppUser.UserName)) + .ForMember(dest => dest.ItemCount, opt => opt.MapFrom(src => src.Items.Count)); CreateMap(); CreateMap(); CreateMap(); @@ -266,7 +267,8 @@ public class AutoMapperProfiles : Profile CreateMap(); - CreateMap(); + CreateMap() + .ForMember(dest => dest.ItemCount, opt => opt.MapFrom(src => src.Items.Count)); CreateMap(); CreateMap(); CreateMap(); diff --git a/API/Helpers/Converters/FilterFieldValueConverter.cs b/API/Helpers/Converters/FilterFieldValueConverter.cs index 46012f256..b0fb8fd0f 100644 --- a/API/Helpers/Converters/FilterFieldValueConverter.cs +++ b/API/Helpers/Converters/FilterFieldValueConverter.cs @@ -101,6 +101,7 @@ public static class FilterFieldValueConverter FilterField.WantToRead => bool.Parse(value), FilterField.ReadProgress => string.IsNullOrEmpty(value) ? 0f : value.AsFloat(), FilterField.ReadingDate => DateTime.Parse(value), + FilterField.ReadLast => int.Parse(value), FilterField.Formats => value.Split(',') .Select(x => (MangaFormat) Enum.Parse(typeof(MangaFormat), x)) .ToList(), diff --git a/API/Services/Tasks/Scanner/ParseScannedFiles.cs b/API/Services/Tasks/Scanner/ParseScannedFiles.cs index dccbd8e90..9102eb6d5 100644 --- a/API/Services/Tasks/Scanner/ParseScannedFiles.cs +++ b/API/Services/Tasks/Scanner/ParseScannedFiles.cs @@ -613,7 +613,7 @@ public class ParseScannedFiles } // Remove or clear any scan results that now have no ParserInfos after merging - return scanResults.Where(sr => sr.ParserInfos.Any()).ToList(); + return scanResults.Where(sr => sr.ParserInfos.Count > 0).ToList(); } private static List GetRelevantInfos(List allInfos) @@ -665,10 +665,11 @@ public class ParseScannedFiles } } - private void RemapSeries(IList scanResults, List allInfos, string localizedSeries, string nonLocalizedSeries) + private static void RemapSeries(IList scanResults, List allInfos, string localizedSeries, string nonLocalizedSeries) { // Find all infos that need to be remapped from the localized series to the non-localized series - var seriesToBeRemapped = allInfos.Where(i => i.Series.Equals(localizedSeries)).ToList(); + var normalizedLocalizedSeries = localizedSeries.ToNormalized(); + var seriesToBeRemapped = allInfos.Where(i => i.Series.ToNormalized().Equals(normalizedLocalizedSeries)).ToList(); foreach (var infoNeedingMapping in seriesToBeRemapped) { diff --git a/API/Services/Tasks/Scanner/Parser/ImageParser.cs b/API/Services/Tasks/Scanner/Parser/ImageParser.cs index 4834a6ed5..e61b79aea 100644 --- a/API/Services/Tasks/Scanner/Parser/ImageParser.cs +++ b/API/Services/Tasks/Scanner/Parser/ImageParser.cs @@ -9,7 +9,7 @@ public class ImageParser(IDirectoryService directoryService) : DefaultParser(dir { public override ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo? comicInfo = null) { - if (type != LibraryType.Image || !Parser.IsImage(filePath)) return null; + if (!IsApplicable(filePath, type)) return null; var directoryName = directoryService.FileSystem.DirectoryInfo.New(rootPath).Name; var fileName = directoryService.FileSystem.Path.GetFileNameWithoutExtension(filePath); @@ -29,7 +29,7 @@ public class ImageParser(IDirectoryService directoryService) : DefaultParser(dir if (IsEmptyOrDefault(ret.Volumes, ret.Chapters)) { ret.IsSpecial = true; - ret.Volumes = $"{Parser.SpecialVolumeNumber}"; + ret.Volumes = Parser.SpecialVolume; } // Override the series name, as fallback folders needs it to try and parse folder name @@ -38,6 +38,7 @@ public class ImageParser(IDirectoryService directoryService) : DefaultParser(dir ret.Series = Parser.CleanTitle(directoryName, replaceSpecials: false); } + return string.IsNullOrEmpty(ret.Series) ? null : ret; } diff --git a/API/Services/Tasks/Scanner/ProcessSeries.cs b/API/Services/Tasks/Scanner/ProcessSeries.cs index dfc8dcda7..43d252c3b 100644 --- a/API/Services/Tasks/Scanner/ProcessSeries.cs +++ b/API/Services/Tasks/Scanner/ProcessSeries.cs @@ -722,78 +722,64 @@ public class ProcessSeries : IProcessSeries } RemoveChapters(volume, parsedInfos); - - // // Update all the metadata on the Chapters - // foreach (var chapter in volume.Chapters) - // { - // var firstFile = chapter.Files.MinBy(x => x.Chapter); - // if (firstFile == null || _cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, forceUpdate, firstFile)) continue; - // try - // { - // var firstChapterInfo = infos.SingleOrDefault(i => i.FullFilePath.Equals(firstFile.FilePath)); - // await UpdateChapterFromComicInfo(chapter, firstChapterInfo?.ComicInfo, forceUpdate); - // } - // catch (Exception ex) - // { - // _logger.LogError(ex, "There was some issue when updating chapter's metadata"); - // } - // } } private void RemoveChapters(Volume volume, IList parsedInfos) { - // Remove chapters that aren't in parsedInfos or have no files linked + // Chapters to remove after enumeration + var chaptersToRemove = new List(); + var existingChapters = volume.Chapters; // Extract the directories (without filenames) from parserInfos var parsedDirectories = parsedInfos - .Select(p => Path.GetDirectoryName(p.FullFilePath)) // Get directory path + .Select(p => Path.GetDirectoryName(p.FullFilePath)) .Distinct() .ToList(); foreach (var existingChapter in existingChapters) { - // Get the directories for the files in the current chapter var chapterFileDirectories = existingChapter.Files - .Select(f => Path.GetDirectoryName(f.FilePath)) // Get directory path minus the filename + .Select(f => Path.GetDirectoryName(f.FilePath)) .Distinct() .ToList(); - // Check if any of the chapter's file directories match the parsedDirectories var hasMatchingDirectory = chapterFileDirectories.Exists(dir => parsedDirectories.Contains(dir)); if (hasMatchingDirectory) { - // Ensure we remove any files that no longer exist AND order the remaining files existingChapter.Files = existingChapter.Files .Where(f => parsedInfos.Any(p => Parser.Parser.NormalizePath(p.FullFilePath) == Parser.Parser.NormalizePath(f.FilePath))) .OrderByNatural(f => f.FilePath) .ToList(); - // Update the chapter's page count after filtering the files existingChapter.Pages = existingChapter.Files.Sum(f => f.Pages); - // If no files remain after filtering, remove the chapter if (existingChapter.Files.Count != 0) continue; _logger.LogDebug("[ScannerService] Removed chapter {Chapter} for Volume {VolumeNumber} on {SeriesName}", existingChapter.Range, volume.Name, parsedInfos[0].Series); - volume.Chapters.Remove(existingChapter); + chaptersToRemove.Add(existingChapter); // Mark chapter for removal } else { - // If there are no matching directories in the current scan, check if the files still exist on disk var filesExist = existingChapter.Files.Any(f => File.Exists(f.FilePath)); - - // If no files exist, remove the chapter if (filesExist) continue; + _logger.LogDebug("[ScannerService] Removed chapter {Chapter} for Volume {VolumeNumber} on {SeriesName} as no files exist", existingChapter.Range, volume.Name, parsedInfos[0].Series); - volume.Chapters.Remove(existingChapter); + chaptersToRemove.Add(existingChapter); // Mark chapter for removal } } + + // Remove chapters after the loop to avoid modifying the collection during enumeration + foreach (var chapter in chaptersToRemove) + { + volume.Chapters.Remove(chapter); + } } + private void AddOrUpdateFileForChapter(Chapter chapter, ParserInfo info, bool forceUpdate = false) { chapter.Files ??= new List(); diff --git a/UI/Web/src/app/_models/collection-tag.ts b/UI/Web/src/app/_models/collection-tag.ts index fb83c6aab..e9f072f51 100644 --- a/UI/Web/src/app/_models/collection-tag.ts +++ b/UI/Web/src/app/_models/collection-tag.ts @@ -18,4 +18,5 @@ export interface UserCollection { totalSourceCount: number; missingSeriesFromSource: string | null; ageRating: AgeRating; + itemCount: number; } diff --git a/UI/Web/src/app/_models/metadata/v2/filter-field.ts b/UI/Web/src/app/_models/metadata/v2/filter-field.ts index 2adebdf6d..08005d5c8 100644 --- a/UI/Web/src/app/_models/metadata/v2/filter-field.ts +++ b/UI/Web/src/app/_models/metadata/v2/filter-field.ts @@ -34,7 +34,8 @@ export enum FilterField AverageRating = 28, Imprint = 29, Team = 30, - Location = 31 + Location = 31, + ReadLast = 32 } diff --git a/UI/Web/src/app/_models/reading-list.ts b/UI/Web/src/app/_models/reading-list.ts index 123fb3b85..d5b115ad0 100644 --- a/UI/Web/src/app/_models/reading-list.ts +++ b/UI/Web/src/app/_models/reading-list.ts @@ -3,39 +3,40 @@ import { MangaFormat } from "./manga-format"; import {IHasCover} from "./common/i-has-cover"; export interface ReadingListItem { - pagesRead: number; - pagesTotal: number; - seriesName: string; - seriesFormat: MangaFormat; - seriesId: number; - chapterId: number; - order: number; - chapterNumber: string; - volumeNumber: string; - libraryId: number; - id: number; - releaseDate: string; - title: string; - libraryType: LibraryType; - libraryName: string; - summary?: string; + pagesRead: number; + pagesTotal: number; + seriesName: string; + seriesFormat: MangaFormat; + seriesId: number; + chapterId: number; + order: number; + chapterNumber: string; + volumeNumber: string; + libraryId: number; + id: number; + releaseDate: string; + title: string; + libraryType: LibraryType; + libraryName: string; + summary?: string; } export interface ReadingList extends IHasCover { - id: number; - title: string; - summary: string; - promoted: boolean; - coverImageLocked: boolean; - items: Array; - /** - * If this is empty or null, the cover image isn't set. Do not use this externally. - */ - coverImage?: string; - primaryColor: string; - secondaryColor: string; - startingYear: number; - startingMonth: number; - endingYear: number; - endingMonth: number; + id: number; + title: string; + summary: string; + promoted: boolean; + coverImageLocked: boolean; + items: Array; + /** + * If this is empty or null, the cover image isn't set. Do not use this externally. + */ + coverImage?: string; + primaryColor: string; + secondaryColor: string; + startingYear: number; + startingMonth: number; + endingYear: number; + endingMonth: number; + itemCount: number; } diff --git a/UI/Web/src/app/_pipes/filter-field.pipe.ts b/UI/Web/src/app/_pipes/filter-field.pipe.ts index acd0993f7..056d99f53 100644 --- a/UI/Web/src/app/_pipes/filter-field.pipe.ts +++ b/UI/Web/src/app/_pipes/filter-field.pipe.ts @@ -72,6 +72,8 @@ export class FilterFieldPipe implements PipeTransform { return translate('filter-field-pipe.want-to-read'); case FilterField.ReadingDate: return translate('filter-field-pipe.read-date'); + case FilterField.ReadLast: + return translate('filter-field-pipe.read-last'); case FilterField.AverageRating: return translate('filter-field-pipe.average-rating'); default: diff --git a/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.html b/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.html index 9ae52d1aa..9e1b96ac9 100644 --- a/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.html +++ b/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.html @@ -18,7 +18,7 @@ - @for(action of list; track action.id) { + @for(action of list; track action.title) { @if (action.children === undefined || action?.children?.length === 0 || action.dynamicList !== undefined) { @if (action.dynamicList !== undefined && (action.dynamicList | async | dynamicList); as dList) { diff --git a/UI/Web/src/app/_single-modules/publisher-flipper/publisher-flipper.component.html b/UI/Web/src/app/_single-modules/publisher-flipper/publisher-flipper.component.html new file mode 100644 index 000000000..a1907f85a --- /dev/null +++ b/UI/Web/src/app/_single-modules/publisher-flipper/publisher-flipper.component.html @@ -0,0 +1,39 @@ +@if (publishers.length > 0) { +
+
+
+
+ +
+ {{currentPublisher!.name}} +
+
+
+
+
+ +
+ {{nextPublisher!.name}} +
+
+
+
+
+} + diff --git a/UI/Web/src/app/_single-modules/publisher-flipper/publisher-flipper.component.scss b/UI/Web/src/app/_single-modules/publisher-flipper/publisher-flipper.component.scss new file mode 100644 index 000000000..45c8ce539 --- /dev/null +++ b/UI/Web/src/app/_single-modules/publisher-flipper/publisher-flipper.component.scss @@ -0,0 +1,45 @@ +//.publisher-flipper-container { +// perspective: 1000px; +//} +// +//.publisher-img-container { +// transform-style: preserve-3d; +// transition: transform 0.5s; +//} + + +// jumpbar example + +.publisher-wrapper { + perspective: 1000px; + height: 32px; +} + +.publisher-flipper { + position: relative; + width: 100%; + height: 100%; + text-align: left; + transition: transform 0.6s; + transform-style: preserve-3d; +} + +.publisher-flipper.is-flipped { + transform: rotateX(180deg); +} + +.publisher-side { + position: absolute; + width: 100%; + height: 100%; + backface-visibility: hidden; + transform-style: preserve-3d; +} + +.publisher-front { + z-index: 2; +} + +.publisher-back { + transform: rotateX(180deg); +} diff --git a/UI/Web/src/app/_single-modules/publisher-flipper/publisher-flipper.component.ts b/UI/Web/src/app/_single-modules/publisher-flipper/publisher-flipper.component.ts new file mode 100644 index 000000000..2cbdaccd7 --- /dev/null +++ b/UI/Web/src/app/_single-modules/publisher-flipper/publisher-flipper.component.ts @@ -0,0 +1,81 @@ +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnDestroy, OnInit} from '@angular/core'; +import {ImageComponent} from "../../shared/image/image.component"; +import {FilterField} from "../../_models/metadata/v2/filter-field"; +import {Person} from "../../_models/metadata/person"; +import {ImageService} from "../../_services/image.service"; +import {FilterComparison} from "../../_models/metadata/v2/filter-comparison"; +import {FilterUtilitiesService} from "../../shared/_services/filter-utilities.service"; +import {Router} from "@angular/router"; + +const ANIMATION_TIME = 3000; + +@Component({ + selector: 'app-publisher-flipper', + standalone: true, + imports: [ + ImageComponent + ], + templateUrl: './publisher-flipper.component.html', + styleUrl: './publisher-flipper.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class PublisherFlipperComponent implements OnInit, OnDestroy { + + protected readonly imageService = inject(ImageService); + private readonly filterUtilityService = inject(FilterUtilitiesService); + private readonly cdRef = inject(ChangeDetectorRef); + private readonly router = inject(Router); + + @Input() publishers: Array = []; + + + currentPublisher: Person | undefined = undefined; + nextPublisher: Person | undefined = undefined; + + currentIndex = 0; + isFlipped = false; + private intervalId: any; + + + ngOnInit() { + if (this.publishers.length > 0) { + this.currentPublisher = this.publishers[0]; + this.nextPublisher = this.publishers[1] || this.publishers[0]; + if (this.publishers.length > 1) { + this.startFlipping(); + } + } + } + + ngOnDestroy() { + if (this.intervalId) { + clearInterval(this.intervalId); + } + } + + private startFlipping() { + this.intervalId = setInterval(() => { + // First flip + this.isFlipped = true; + this.cdRef.markForCheck(); + + // Update content after flip animation completes + setTimeout(() => { + // Update indices and content + this.currentIndex = (this.currentIndex + 1) % this.publishers.length; + this.currentPublisher = this.publishers[this.currentIndex]; + this.nextPublisher = this.publishers[(this.currentIndex + 1) % this.publishers.length]; + + // Reset flip + this.isFlipped = false; + + this.cdRef.markForCheck(); + }, ANIMATION_TIME); // Full transition time to ensure flip completes + }, ANIMATION_TIME); + } + + openPublisher(filter: string | number) { + // TODO: once we build out publisher person-detail page, we can redirect there + this.filterUtilityService.applyFilter(['all-series'], FilterField.Publisher, FilterComparison.Equal, `${filter}`).subscribe(); + } +} diff --git a/UI/Web/src/app/_single-modules/related-tab/related-tab.component.html b/UI/Web/src/app/_single-modules/related-tab/related-tab.component.html index 201f9e247..17d40e515 100644 --- a/UI/Web/src/app/_single-modules/related-tab/related-tab.component.html +++ b/UI/Web/src/app/_single-modules/related-tab/related-tab.component.html @@ -12,6 +12,7 @@ @@ -23,6 +24,7 @@ diff --git a/UI/Web/src/app/admin/manage-email-settings/manage-email-settings.component.html b/UI/Web/src/app/admin/manage-email-settings/manage-email-settings.component.html index bef6f63c2..099059b3c 100644 --- a/UI/Web/src/app/admin/manage-email-settings/manage-email-settings.component.html +++ b/UI/Web/src/app/admin/manage-email-settings/manage-email-settings.component.html @@ -6,21 +6,21 @@
@if (settingsForm.get('hostName'); as formControl) { - + {{formControl.value | defaultValue}}
+ [class.is-invalid]="formControl.invalid && !formControl.untouched">
- @if(settingsForm.dirty || settingsForm.touched) { -
- @if (formControl.errors?.pattern) { + @if (formControl.errors; as errors) { +
+ @if (errors.pattern) {
{{t('host-name-validation')}}
}
diff --git a/UI/Web/src/app/admin/manage-library/manage-library.component.ts b/UI/Web/src/app/admin/manage-library/manage-library.component.ts index 4c72d5c6d..eac07fe3d 100644 --- a/UI/Web/src/app/admin/manage-library/manage-library.component.ts +++ b/UI/Web/src/app/admin/manage-library/manage-library.component.ts @@ -198,10 +198,6 @@ export class ManageLibraryComponent implements OnInit { async applyBulkAction() { - if (!this.bulkMode) { - this.resetBulkMode(); - } - // Get Selected libraries let selected = this.selections.selected(); @@ -218,32 +214,54 @@ export class ManageLibraryComponent implements OnInit { switch(this.bulkAction) { case (Action.Scan): await this.confirmService.alert(translate('toasts.bulk-scan')); - this.libraryService.scanMultipleLibraries(selected.map(l => l.id)).subscribe(); + this.bulkMode = true; + this.cdRef.markForCheck(); + this.libraryService.scanMultipleLibraries(selected.map(l => l.id)).subscribe(_ => this.resetBulkMode()); break; case Action.RefreshMetadata: if (!await this.confirmService.confirm(translate('toasts.bulk-covers'))) return; - this.libraryService.refreshMetadataMultipleLibraries(selected.map(l => l.id), true, false).subscribe(() => this.getLibraries()); + this.bulkMode = true; + this.cdRef.markForCheck(); + this.libraryService.refreshMetadataMultipleLibraries(selected.map(l => l.id), true, false).subscribe(() => { + this.getLibraries(); + this.resetBulkMode(); + }); break case Action.AnalyzeFiles: - this.libraryService.analyzeFilesMultipleLibraries(selected.map(l => l.id)).subscribe(() => this.getLibraries()); + this.bulkMode = true; + this.cdRef.markForCheck(); + this.libraryService.analyzeFilesMultipleLibraries(selected.map(l => l.id)).subscribe(() => { + this.getLibraries(); + this.resetBulkMode(); + }); break; case Action.GenerateColorScape: - this.libraryService.refreshMetadataMultipleLibraries(selected.map(l => l.id), false, true).subscribe(() => this.getLibraries()); + this.bulkMode = true; + this.cdRef.markForCheck(); + this.libraryService.refreshMetadataMultipleLibraries(selected.map(l => l.id), false, true).subscribe(() => { + this.getLibraries(); + this.resetBulkMode(); + }); break; case Action.CopySettings: // Remove the source library from the list if (selected.length === 1 && selected[0].id === this.sourceCopyToLibrary!.id) { return; } + + this.bulkMode = true; + this.cdRef.markForCheck(); + const includeType = this.bulkForm.get('includeType')!.value + '' == 'true'; - this.libraryService.copySettingsFromLibrary(this.sourceCopyToLibrary!.id, selected.map(l => l.id), includeType).subscribe(() => this.getLibraries()); + this.libraryService.copySettingsFromLibrary(this.sourceCopyToLibrary!.id, selected.map(l => l.id), includeType).subscribe(() => { + this.getLibraries(); + this.resetBulkMode(); + }); break; } } async handleBulkAction(action: ActionItem, library : Library | null) { - - this.bulkMode = true; this.bulkAction = action.action; this.cdRef.markForCheck(); @@ -252,9 +270,11 @@ export class ManageLibraryComponent implements OnInit { case(Action.RefreshMetadata): case(Action.GenerateColorScape): case (Action.Delete): + case (Action.AnalyzeFiles): await this.applyBulkAction(); break; case (Action.CopySettings): + // Prompt the user for the library then wait for them to manually trigger applyBulkAction const ref = this.modalService.open(CopySettingsFromLibraryModalComponent, {size: 'lg', fullscreen: 'md'}); ref.componentInstance.libraries = this.libraries; 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 6c23a0b27..0e47ea16b 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 @@ -39,7 +39,7 @@ @if (count > 1) {
- {{count}} + {{count | compactNumber}}
} @@ -76,7 +76,7 @@ @if (showFormat) { - } + } @@ -84,16 +84,15 @@ } @if (linkUrl) { - {{title}} + {{title}} } @else { {{title}} } + @if (actions && actions.length > 0) { - - }
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 187377ce8..8e8085f98 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 @@ -47,6 +47,7 @@ import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe"; import {PromotedIconComponent} from "../../shared/_components/promoted-icon/promoted-icon.component"; import {SeriesFormatComponent} from "../../shared/series-format/series-format.component"; import {BrowsePerson} from "../../_models/person/browse-person"; +import {CompactNumberPipe} from "../../_pipes/compact-number.pipe"; export type CardEntity = Series | Volume | Chapter | UserCollection | PageBookmark | RecentlyAddedItem | NextExpectedChapter | BrowsePerson; @@ -70,7 +71,8 @@ export type CardEntity = Series | Volume | Chapter | UserCollection | PageBookma PromotedIconComponent, SeriesFormatComponent, DecimalPipe, - NgTemplateOutlet + NgTemplateOutlet, + CompactNumberPipe ], templateUrl: './card-item.component.html', styleUrls: ['./card-item.component.scss'], @@ -257,6 +259,8 @@ export class CardItemComponent implements OnInit { } this.cdRef.markForCheck(); + } else { + this.tooltipTitle = this.title; } diff --git a/UI/Web/src/app/cards/next-expected-card/next-expected-card.component.html b/UI/Web/src/app/cards/next-expected-card/next-expected-card.component.html index eb99e4424..acd2aef88 100644 --- a/UI/Web/src/app/cards/next-expected-card/next-expected-card.component.html +++ b/UI/Web/src/app/cards/next-expected-card/next-expected-card.component.html @@ -1,27 +1,28 @@ -
-
- + +
+
+ -
-
+
+
- @if (entity.title | safeHtml; as info) { - @if (info !== '') { -
-
- -
Upcoming
- + @if (entity.title | safeHtml; as info) { + @if (info !== '') { +
+
+
{{t('upcoming-title')}}
+ +
-
+ } } - } -
- - {{title}} - +
+ + {{title}} + +
+
- -
+
diff --git a/UI/Web/src/app/cards/next-expected-card/next-expected-card.component.scss b/UI/Web/src/app/cards/next-expected-card/next-expected-card.component.scss index 150c2d1a8..2745d7977 100644 --- a/UI/Web/src/app/cards/next-expected-card/next-expected-card.component.scss +++ b/UI/Web/src/app/cards/next-expected-card/next-expected-card.component.scss @@ -1,22 +1,10 @@ @use '../../../card-item-common'; -::ng-deep .extreme-blur { - filter: brightness(50%) blur(4px) +:host ::ng-deep .extreme-blur { + filter: brightness(50%) blur(4px); } -.overlay-information { - background-color: transparent; +.card-title-container { + justify-content: center; } -.upcoming-header { - font-size: 0.8rem; - font-weight: bold; -} - -.card-title { - width: 146px; -} - -.card-content { - font-size: 0.8rem; -} diff --git a/UI/Web/src/app/cards/next-expected-card/next-expected-card.component.ts b/UI/Web/src/app/cards/next-expected-card/next-expected-card.component.ts index ea2e8c729..36edb8042 100644 --- a/UI/Web/src/app/cards/next-expected-card/next-expected-card.component.ts +++ b/UI/Web/src/app/cards/next-expected-card/next-expected-card.component.ts @@ -3,12 +3,12 @@ import {ImageComponent} from "../../shared/image/image.component"; import {NextExpectedChapter} from "../../_models/series-detail/next-expected-chapter"; import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe"; -import {translate} from "@jsverse/transloco"; +import {translate, TranslocoDirective} from "@jsverse/transloco"; @Component({ selector: 'app-next-expected-card', standalone: true, - imports: [ImageComponent, SafeHtmlPipe], + imports: [ImageComponent, SafeHtmlPipe, TranslocoDirective], templateUrl: './next-expected-card.component.html', styleUrl: './next-expected-card.component.scss', changeDetection: ChangeDetectionStrategy.OnPush diff --git a/UI/Web/src/app/collections/_components/all-collections/all-collections.component.html b/UI/Web/src/app/collections/_components/all-collections/all-collections.component.html index b57c51eb0..09923b239 100644 --- a/UI/Web/src/app/collections/_components/all-collections/all-collections.component.html +++ b/UI/Web/src/app/collections/_components/all-collections/all-collections.component.html @@ -17,6 +17,7 @@ diff --git a/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.html b/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.html index d88283ef2..97a8f6c5a 100644 --- a/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.html +++ b/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.html @@ -3,7 +3,9 @@
@@ -16,19 +18,19 @@
- @if (formGroup.get('comparison')?.value != FilterComparison.IsEmpty) { - - - + @if (formGroup.get('comparison')?.value !== FilterComparison.IsEmpty) { + @if (predicateType$ | async; as predicateType) { + @switch (predicateType) { + @case (PredicateType.Text) { - - + } + @case (PredicateType.Number) { - - + } + @case (PredicateType.Boolean) { - - + } + @case (PredicateType.Date) {
- - -
- - - - - - - - - -
-
+ } + @case (PredicateType.Dropdown) { + @if (dropdownOptions$ | async; as opts) { + + + } + } + } + } } -
diff --git a/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.ts b/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.ts index 5a38810a1..47dae4c44 100644 --- a/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.ts +++ b/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.ts @@ -19,7 +19,7 @@ import {LibraryService} from 'src/app/_services/library.service'; import {CollectionTagService} from 'src/app/_services/collection-tag.service'; import {FilterComparison} from 'src/app/_models/metadata/v2/filter-comparison'; import {allFields, FilterField} from 'src/app/_models/metadata/v2/filter-field'; -import {AsyncPipe, NgForOf, NgIf, NgSwitch, NgSwitchCase, NgTemplateOutlet} from "@angular/common"; +import {AsyncPipe, NgTemplateOutlet} from "@angular/common"; import {FilterFieldPipe} from "../../../_pipes/filter-field.pipe"; import {FilterComparisonPipe} from "../../../_pipes/filter-comparison.pipe"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; @@ -56,17 +56,22 @@ const unitLabels: Map = new Map([ [FilterField.AverageRating, new FilterRowUi('unit-average-rating')], [FilterField.ReadProgress, new FilterRowUi('unit-reading-progress')], [FilterField.UserRating, new FilterRowUi('unit-user-rating')], + [FilterField.ReadLast, new FilterRowUi('unit-read-last')], ]); const StringFields = [FilterField.SeriesName, FilterField.Summary, FilterField.Path, FilterField.FilePath]; -const NumberFields = [FilterField.ReadTime, FilterField.ReleaseYear, FilterField.ReadProgress, FilterField.UserRating, FilterField.AverageRating]; -const DropdownFields = [FilterField.PublicationStatus, FilterField.Languages, FilterField.AgeRating, - FilterField.Translators, FilterField.Characters, FilterField.Publisher, - FilterField.Editor, FilterField.CoverArtist, FilterField.Letterer, - FilterField.Colorist, FilterField.Inker, FilterField.Penciller, - FilterField.Writers, FilterField.Genres, FilterField.Libraries, - FilterField.Formats, FilterField.CollectionTags, FilterField.Tags, - FilterField.Imprint, FilterField.Team, FilterField.Location +const NumberFields = [ + FilterField.ReadTime, FilterField.ReleaseYear, FilterField.ReadProgress, + FilterField.UserRating, FilterField.AverageRating, FilterField.ReadLast +]; +const DropdownFields = [ + FilterField.PublicationStatus, FilterField.Languages, FilterField.AgeRating, + FilterField.Translators, FilterField.Characters, FilterField.Publisher, + FilterField.Editor, FilterField.CoverArtist, FilterField.Letterer, + FilterField.Colorist, FilterField.Inker, FilterField.Penciller, + FilterField.Writers, FilterField.Genres, FilterField.Libraries, + FilterField.Formats, FilterField.CollectionTags, FilterField.Tags, + FilterField.Imprint, FilterField.Team, FilterField.Location ]; const BooleanFields = [FilterField.WantToRead]; const DateFields = [FilterField.ReadingDate]; @@ -89,7 +94,6 @@ const FieldsThatShouldIncludeIsEmpty = [ FilterField.Colorist, FilterField.Inker, FilterField.Penciller, FilterField.Writers, FilterField.Imprint, FilterField.Team, FilterField.Location, - ]; const StringComparisons = [ @@ -130,10 +134,6 @@ const BooleanComparisons = [ AsyncPipe, FilterFieldPipe, FilterComparisonPipe, - NgSwitch, - NgSwitchCase, - NgForOf, - NgIf, Select2Module, NgTemplateOutlet, TagBadgeComponent, @@ -159,6 +159,8 @@ export class MetadataFilterRowComponent implements OnInit { private readonly destroyRef = inject(DestroyRef); private readonly dateParser = inject(NgbDateParserFormatter); + protected readonly FilterComparison = FilterComparison; + formGroup: FormGroup = new FormGroup({ 'comparison': new FormControl(FilterComparison.Equal, []), 'filterValue': new FormControl('', []), @@ -425,12 +427,10 @@ export class MetadataFilterRowComponent implements OnInit { - onDateSelect(event: NgbDate) { + onDateSelect(_: NgbDate) { this.propagateFilterUpdate(); } updateIfDateFilled() { this.propagateFilterUpdate(); } - - protected readonly FilterComparison = FilterComparison; } diff --git a/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.html b/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.html index 68b7c602f..3d1192600 100644 --- a/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.html +++ b/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.html @@ -22,11 +22,14 @@ > + [selected]="bulkSelectionService.isCardSelected('readingList', position)" + [allowSelection]="true" [showFormat]="false" + (selection)="bulkSelectionService.handleCardSelection('readingList', position, lists.length, $event)"> + diff --git a/UI/Web/src/app/series-detail/_components/metadata-detail-row/metadata-detail-row.component.html b/UI/Web/src/app/series-detail/_components/metadata-detail-row/metadata-detail-row.component.html index 2d862abc3..f82e8e4b0 100644 --- a/UI/Web/src/app/series-detail/_components/metadata-detail-row/metadata-detail-row.component.html +++ b/UI/Web/src/app/series-detail/_components/metadata-detail-row/metadata-detail-row.component.html @@ -7,6 +7,8 @@
{{entity.publishers[0].name}}
} + + diff --git a/UI/Web/src/app/series-detail/_components/metadata-detail-row/metadata-detail-row.component.scss b/UI/Web/src/app/series-detail/_components/metadata-detail-row/metadata-detail-row.component.scss index c3e64f0d7..42eb06de9 100644 --- a/UI/Web/src/app/series-detail/_components/metadata-detail-row/metadata-detail-row.component.scss +++ b/UI/Web/src/app/series-detail/_components/metadata-detail-row/metadata-detail-row.component.scss @@ -17,4 +17,4 @@ .word-count { font-size: 0.8rem; -} \ No newline at end of file +} diff --git a/UI/Web/src/app/series-detail/_components/metadata-detail-row/metadata-detail-row.component.ts b/UI/Web/src/app/series-detail/_components/metadata-detail-row/metadata-detail-row.component.ts index 56c3bface..3855656f6 100644 --- a/UI/Web/src/app/series-detail/_components/metadata-detail-row/metadata-detail-row.component.ts +++ b/UI/Web/src/app/series-detail/_components/metadata-detail-row/metadata-detail-row.component.ts @@ -19,6 +19,7 @@ import {MangaFormatPipe} from "../../../_pipes/manga-format.pipe"; import {MangaFormat} from "../../../_models/manga-format"; import {MangaFormatIconPipe} from "../../../_pipes/manga-format-icon.pipe"; import {SeriesFormatComponent} from "../../../shared/series-format/series-format.component"; +import {PublisherFlipperComponent} from "../../../_single-modules/publisher-flipper/publisher-flipper.component"; @Component({ selector: 'app-metadata-detail-row', @@ -33,7 +34,8 @@ import {SeriesFormatComponent} from "../../../shared/series-format/series-format ImageComponent, MangaFormatPipe, MangaFormatIconPipe, - SeriesFormatComponent + SeriesFormatComponent, + PublisherFlipperComponent ], templateUrl: './metadata-detail-row.component.html', styleUrl: './metadata-detail-row.component.scss', diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts index d73bff343..d8ea1b8f6 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts @@ -1,7 +1,9 @@ import { AsyncPipe, DecimalPipe, - DOCUMENT, JsonPipe, Location, + DOCUMENT, + JsonPipe, + Location, NgClass, NgOptimizedImage, NgStyle, @@ -35,19 +37,17 @@ import { NgbNavItem, NgbNavLink, NgbNavOutlet, - NgbOffcanvas, NgbProgressbar, NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; import {ToastrService} from 'ngx-toastr'; -import {catchError, forkJoin, Observable, of, shareReplay, tap} from 'rxjs'; +import {catchError, forkJoin, Observable, of, tap} from 'rxjs'; import {map} from 'rxjs/operators'; import {BulkSelectionService} from 'src/app/cards/bulk-selection.service'; import { EditSeriesModalCloseResult, EditSeriesModalComponent } from 'src/app/cards/_modals/edit-series-modal/edit-series-modal.component'; -import {TagBadgeCursor} from 'src/app/shared/tag-badge/tag-badge.component'; import {DownloadEvent, DownloadService} from 'src/app/shared/_services/download.service'; import {Breakpoint, KEY_CODES, UtilityService} from 'src/app/shared/_services/utility.service'; import {Chapter, LooseLeafOrDefaultNumber, SpecialVolumeNumber} from 'src/app/_models/chapter'; @@ -65,7 +65,6 @@ import {Volume} from 'src/app/_models/volume'; import {AccountService} from 'src/app/_services/account.service'; import {Action, ActionFactoryService, ActionItem} from 'src/app/_services/action-factory.service'; import {ActionService} from 'src/app/_services/action.service'; -import {DeviceService} from 'src/app/_services/device.service'; import {ImageService} from 'src/app/_services/image.service'; import {LibraryService} from 'src/app/_services/library.service'; import {EVENTS, MessageHubService} from 'src/app/_services/message-hub.service'; @@ -704,6 +703,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { this.cdRef.markForCheck(); if (![PublicationStatus.Ended, PublicationStatus.OnGoing].includes(this.seriesMetadata.publicationStatus)) return; + this.seriesService.getNextExpectedChapterDate(seriesId).subscribe(date => { if (date == null || date.expectedDate === null) { if (this.nextExpectedChapter !== undefined) { @@ -716,7 +716,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { this.nextExpectedChapter = date; this.cdRef.markForCheck(); - }) + }); }); this.seriesService.isWantToRead(seriesId).subscribe(isWantToRead => { @@ -850,7 +850,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { shouldShowStorylineTab() { if (this.libraryType === LibraryType.ComicVine) return false; // Edge case for bad pdf parse - if (this.libraryType === LibraryType.Book && (this.volumes.length === 0 && this.chapters.length === 0 && this.storyChapters.length > 0)) return true; + if ((this.libraryType === LibraryType.Book || this.libraryType === LibraryType.LightNovel) && (this.volumes.length === 0 && this.chapters.length === 0 && this.storyChapters.length > 0)) return true; return (this.libraryType !== LibraryType.Book && this.libraryType !== LibraryType.LightNovel && this.libraryType !== LibraryType.Comic) && (this.volumes.length > 0 || this.chapters.length > 0); diff --git a/UI/Web/src/app/settings/_components/setting-item/setting-item.component.ts b/UI/Web/src/app/settings/_components/setting-item/setting-item.component.ts index fb428bca2..36563341d 100644 --- a/UI/Web/src/app/settings/_components/setting-item/setting-item.component.ts +++ b/UI/Web/src/app/settings/_components/setting-item/setting-item.component.ts @@ -11,6 +11,7 @@ import {TranslocoDirective} from "@jsverse/transloco"; import {NgTemplateOutlet} from "@angular/common"; import {SafeHtmlPipe} from "../../../_pipes/safe-html.pipe"; import {filter, fromEvent, tap} from "rxjs"; +import {AbstractControl, FormControl} from "@angular/forms"; @Component({ selector: 'app-setting-item', @@ -36,6 +37,7 @@ export class SettingItemComponent { @Input() subtitle: string | undefined = undefined; @Input() labelId: string | undefined = undefined; @Input() toggleOnViewClick: boolean = true; + @Input() control: AbstractControl | null = null; @Output() editMode = new EventEmitter(); /** @@ -67,6 +69,7 @@ export class SettingItemComponent { .pipe( filter((event: Event) => { if (!this.toggleOnViewClick) return false; + if (this.control != null && this.control.invalid) return false; const mouseEvent = event as MouseEvent; const selection = window.getSelection(); @@ -86,6 +89,7 @@ export class SettingItemComponent { if (!this.toggleOnViewClick) return; if (!this.canEdit) return; + if (this.control != null && this.control.invalid) return; this.isEditMode = !this.isEditMode; this.editMode.emit(this.isEditMode); diff --git a/UI/Web/src/app/typeahead/_components/typeahead.component.ts b/UI/Web/src/app/typeahead/_components/typeahead.component.ts index 0ebffe01e..a4f7ad4d0 100644 --- a/UI/Web/src/app/typeahead/_components/typeahead.component.ts +++ b/UI/Web/src/app/typeahead/_components/typeahead.component.ts @@ -252,6 +252,7 @@ export class TypeaheadComponent implements OnInit { case KEY_CODES.ESC_KEY: this.hasFocus = false; event.stopPropagation(); + event.preventDefault(); break; default: break; diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index 15e51f831..c3f49f3e2 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -1156,7 +1156,7 @@ "reset": "{{common.reset}}", "test": "Test", "host-name-label": "Host Name", - "host-name-tooltip": "Domain Name (of Reverse Proxy). If set, email generation will always use this.", + "host-name-tooltip": "Domain Name (of Reverse Proxy). Required for email functionality. If no reverse proxy, use any url.", "host-name-validation": "Host name must start with http(s) and not end in /", "sender-address-label": "Sender Address", @@ -1207,6 +1207,7 @@ "bulk-action-label": "Bulk Action" }, + "copy-settings-from-library-modal": { "close": "{{common.close}}", "select": "Select", @@ -1831,7 +1832,8 @@ "unit-reading-date": "Date", "unit-average-rating": "Kavita+ external rating, percent", "unit-reading-progress": "Percent", - "unit-user-rating": "{{metadata-filter-row.unit-reading-progress}}" + "unit-user-rating": "{{metadata-filter-row.unit-reading-progress}}", + "unit-read-last": "Days from TODAY" }, "sort-field-pipe": { @@ -2097,7 +2099,8 @@ }, "next-expected-card": { - "title": "~{{date}}" + "title": "~{{date}}", + "upcoming-title": "Upcoming" }, "server-stats": { @@ -2270,6 +2273,7 @@ "file-path": "File Path", "want-to-read": "Want to Read", "read-date": "Reading Date", + "read-last": "Read Last", "average-rating": "Average Rating" },