diff --git a/API.Tests/Services/ScannerServiceTests.cs b/API.Tests/Services/ScannerServiceTests.cs index fcbfe82602..2a70ea79eb 100644 --- a/API.Tests/Services/ScannerServiceTests.cs +++ b/API.Tests/Services/ScannerServiceTests.cs @@ -7,7 +7,10 @@ using System.Text; using System.Text.Json; using System.Threading.Tasks; +using System.Xml; +using System.Xml.Serialization; using API.Data; +using API.Data.Metadata; using API.Data.Repositories; using API.Entities; using API.Entities.Enums; @@ -35,6 +38,7 @@ public class ScannerServiceTests : AbstractDbTest private readonly string _testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/ScanTests"); private readonly string _testcasesDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/TestCases"); private readonly string _imagePath = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/1x1.png"); + private static readonly string[] ComicInfoExtensions = new[] { ".cbz", ".cbr", ".zip", ".rar" }; public ScannerServiceTests(ITestOutputHelper testOutputHelper) { @@ -125,9 +129,61 @@ public async Task ScanLibrary_FlatSeriesWithSpecial() Assert.NotNull(postLib.Series.First().Volumes.FirstOrDefault(v => v.Chapters.FirstOrDefault(c => c.IsSpecial) != null)); } - private async Task GenerateScannerData(string testcase) + /// + /// This is testing that if the first file is named A and has a localized name of B if all other files are named B, it should still group and name the series A + /// + [Fact] + public async Task ScanLibrary_LocalizedSeries() + { + const string testcase = "Series with Localized - Manga.json"; + + // Get the first file and generate a ComicInfo + var infos = new Dictionary(); + infos.Add("My Dress-Up Darling v01.cbz", new ComicInfo() + { + Series = "My Dress-Up Darling", + LocalizedSeries = "Sono Bisque Doll wa Koi wo Suru" + }); + + 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); + Assert.Equal(3, postLib.Series.First().Volumes.Count); + } + + + /// + /// Files under a folder with a SP marker should group into one issue + /// + /// https://github.com/Kareadita/Kavita/issues/3299 + [Fact] + public async Task ScanLibrary_ImageSeries_SpecialGrouping() + { + const string testcase = "Image Series with SP Folder - Manga.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); + Assert.Equal(3, postLib.Series.First().Volumes.Count); + } + + + #region Setup + private async Task GenerateScannerData(string testcase, Dictionary comicInfos = null) { - var testDirectoryPath = await GenerateTestDirectory(Path.Join(_testcasesDirectory, testcase)); + var testDirectoryPath = await GenerateTestDirectory(Path.Join(_testcasesDirectory, testcase), comicInfos); var (publisher, type) = SplitPublisherAndLibraryType(Path.GetFileNameWithoutExtension(testcase)); @@ -148,11 +204,17 @@ private async Task GenerateScannerData(string testcase) private ScannerService CreateServices() { - var ds = new DirectoryService(Substitute.For>(), new FileSystem()); - var mockReadingService = new MockReadingItemService(ds, Substitute.For()); + var fs = new FileSystem(); + var ds = new DirectoryService(Substitute.For>(), fs); + var archiveService = new ArchiveService(Substitute.For>(), ds, + Substitute.For(), Substitute.For()); + var readingItemService = new ReadingItemService(archiveService, Substitute.For(), + Substitute.For(), ds, Substitute.For>()); + + var processSeries = new ProcessSeries(_unitOfWork, Substitute.For>(), Substitute.For(), - ds, Substitute.For(), mockReadingService, Substitute.For(), + ds, Substitute.For(), readingItemService, new FileService(fs), Substitute.For(), Substitute.For(), Substitute.For(), @@ -161,7 +223,7 @@ private ScannerService CreateServices() var scanner = new ScannerService(_unitOfWork, Substitute.For>(), Substitute.For(), Substitute.For(), Substitute.For(), ds, - mockReadingService, processSeries, Substitute.For()); + readingItemService, processSeries, Substitute.For()); return scanner; } @@ -189,7 +251,7 @@ private static (string Publisher, LibraryType Type) SplitPublisherAndLibraryType - private async Task GenerateTestDirectory(string mapPath) + private async Task GenerateTestDirectory(string mapPath, Dictionary comicInfos = null) { // Read the map file var mapContent = await File.ReadAllTextAsync(mapPath); @@ -206,7 +268,7 @@ private async Task GenerateTestDirectory(string mapPath) Directory.CreateDirectory(testDirectory); // Generate the files and folders - await Scaffold(testDirectory, filePaths); + await Scaffold(testDirectory, filePaths, comicInfos); _testOutputHelper.WriteLine($"Test Directory Path: {testDirectory}"); @@ -214,7 +276,7 @@ private async Task GenerateTestDirectory(string mapPath) } - private async Task Scaffold(string testDirectory, List filePaths) + private async Task Scaffold(string testDirectory, List filePaths, Dictionary comicInfos = null) { foreach (var relativePath in filePaths) { @@ -229,9 +291,9 @@ private async Task Scaffold(string testDirectory, List filePaths) } var ext = Path.GetExtension(fullPath).ToLower(); - if (new[] { ".cbz", ".cbr", ".zip", ".rar" }.Contains(ext)) + if (ComicInfoExtensions.Contains(ext) && comicInfos != null && comicInfos.TryGetValue(Path.GetFileName(relativePath), out var info)) { - CreateMinimalCbz(fullPath, includeMetadata: true); + CreateMinimalCbz(fullPath, info); } else { @@ -242,54 +304,44 @@ private async Task Scaffold(string testDirectory, List filePaths) } } - private void CreateMinimalCbz(string filePath, bool includeMetadata) + private void CreateMinimalCbz(string filePath, ComicInfo? comicInfo = null) { - var tempImagePath = _imagePath; // Assuming _imagePath is a valid path to the 1x1 image - using (var archive = ZipFile.Open(filePath, ZipArchiveMode.Create)) { // Add the 1x1 image to the archive - archive.CreateEntryFromFile(tempImagePath, "1x1.png"); + archive.CreateEntryFromFile(_imagePath, "1x1.png"); - if (includeMetadata) + if (comicInfo != null) { - var comicInfo = GenerateComicInfo(); + // Serialize ComicInfo object to XML + var comicInfoXml = SerializeComicInfoToXml(comicInfo); + + // Create an entry for ComicInfo.xml in the archive var entry = archive.CreateEntry("ComicInfo.xml"); using var entryStream = entry.Open(); using var writer = new StreamWriter(entryStream, Encoding.UTF8); - writer.Write(comicInfo); + + // Write the XML to the archive + writer.Write(comicInfoXml); } + } - Console.WriteLine($"Created minimal CBZ archive: {filePath} with{(includeMetadata ? "" : "out")} metadata."); + Console.WriteLine($"Created minimal CBZ archive: {filePath} with{(comicInfo != null ? "" : "out")} metadata."); } - private string GenerateComicInfo() - { - var comicInfo = new StringBuilder(); - comicInfo.AppendLine(""); - comicInfo.AppendLine(""); - - // People Tags - string[] people = { "Joe Shmo", "Tommy Two Hands"}; - string[] genres = { /* Your list of genres here */ }; - - void AddRandomTag(string tagName, string[] choices) - { - if (new Random().Next(0, 2) == 1) // 50% chance to include the tag - { - var selected = choices.OrderBy(x => Guid.NewGuid()).Take(new Random().Next(1, 5)).ToArray(); - comicInfo.AppendLine($" <{tagName}>{string.Join(", ", selected)}"); - } - } - foreach (var tag in new[] { "Writer", "Penciller", "Inker", "CoverArtist", "Publisher", "Character", "Imprint", "Colorist", "Letterer", "Editor", "Translator", "Team", "Location" }) + private static string SerializeComicInfoToXml(ComicInfo comicInfo) + { + var xmlSerializer = new XmlSerializer(typeof(ComicInfo)); + using var stringWriter = new StringWriter(); + using (var xmlWriter = XmlWriter.Create(stringWriter, new XmlWriterSettings { Indent = true, Encoding = new UTF8Encoding(false), OmitXmlDeclaration = false})) { - AddRandomTag(tag, people); + xmlSerializer.Serialize(xmlWriter, comicInfo); } - AddRandomTag("Genre", genres); - comicInfo.AppendLine(""); - - return comicInfo.ToString(); + // For the love of god, I spent 2 hours trying to get utf-8 with no BOM + return stringWriter.ToString().Replace("""""", + @""); } + #endregion } diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Image Series with SP Folder - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Image Series with SP Folder - Manga.json new file mode 100644 index 0000000000..62106703c7 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Image Series with SP Folder - Manga.json @@ -0,0 +1,6 @@ +[ + "My Dress-Up Darling/My Dress-Up Darling vol 1/0001.png", + "My Dress-Up Darling/My Dress-Up Darling vol 1/0002.png", + "My Dress-Up Darling/My Dress-Up Darling vol 2/0001.png", + "My Dress-Up Darling/Specials/My Dress-Up Darling SP01/0001.png" +] \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Localized - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Localized - Manga.json new file mode 100644 index 0000000000..6495c294f0 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Localized - Manga.json @@ -0,0 +1,5 @@ +[ + "My Dress-Up Darling/My Dress-Up Darling v01.cbz", + "My Dress-Up Darling/Sono Bisque Doll wa Koi wo Suru v02.cbz", + "My Dress-Up Darling/Sono Bisque Doll wa Koi wo Suru ch 10.cbz" +] \ No newline at end of file diff --git a/API/Controllers/PersonController.cs b/API/Controllers/PersonController.cs index fb18156bab..e5b9a99cce 100644 --- a/API/Controllers/PersonController.cs +++ b/API/Controllers/PersonController.cs @@ -11,6 +11,7 @@ using Nager.ArticleNumber; namespace API.Controllers; +#nullable enable public class PersonController : BaseApiController { @@ -39,11 +40,11 @@ public async Task>> GetRolesForPersonByName } /// - /// Returns a list of authors for browsing + /// Returns a list of authors & artists for browsing /// /// /// - [HttpPost("authors")] + [HttpPost("all")] public async Task>> GetAuthorsForBrowse([FromQuery] UserParams? userParams) { userParams ??= UserParams.Default; diff --git a/API/Data/Repositories/PersonRepository.cs b/API/Data/Repositories/PersonRepository.cs index a6bb7b2717..c6c4371033 100644 --- a/API/Data/Repositories/PersonRepository.cs +++ b/API/Data/Repositories/PersonRepository.cs @@ -171,6 +171,7 @@ public async Task> GetAllWritersAndSeriesCount(int us Id = p.Id, Name = p.Name, Description = p.Description, + CoverImage = p.CoverImage, SeriesCount = p.SeriesMetadataPeople .Where(smp => roles.Contains(smp.Role)) .Select(smp => smp.SeriesMetadata.SeriesId) diff --git a/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs b/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs index b6f1082af8..a0f88c582b 100644 --- a/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs +++ b/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs @@ -424,7 +424,7 @@ public static IQueryable HasReadingDate(this IQueryable queryabl public static IQueryable HasTags(this IQueryable queryable, bool condition, FilterComparison comparison, IList tags) { - if (!condition || tags.Count == 0) return queryable; + if (!condition || (comparison != FilterComparison.IsEmpty && tags.Count == 0)) return queryable; switch (comparison) { @@ -547,7 +547,7 @@ public static IQueryable HasPeopleLegacy(this IQueryable queryab public static IQueryable HasGenre(this IQueryable queryable, bool condition, FilterComparison comparison, IList genres) { - if (!condition || genres.Count == 0) return queryable; + if (!condition || (comparison != FilterComparison.IsEmpty && genres.Count == 0)) return queryable; switch (comparison) { @@ -620,7 +620,7 @@ public static IQueryable HasFormat(this IQueryable queryable, bo public static IQueryable HasCollectionTags(this IQueryable queryable, bool condition, FilterComparison comparison, IList collectionTags, IList collectionSeries) { - if (!condition || collectionTags.Count == 0) return queryable; + if (!condition || (comparison != FilterComparison.IsEmpty && collectionTags.Count == 0)) return queryable; switch (comparison) diff --git a/API/Services/ReadingItemService.cs b/API/Services/ReadingItemService.cs index 34360efa54..3898bd2388 100644 --- a/API/Services/ReadingItemService.cs +++ b/API/Services/ReadingItemService.cs @@ -9,7 +9,6 @@ namespace API.Services; public interface IReadingItemService { - ComicInfo? GetComicInfo(string filePath); int GetNumberOfPages(string filePath, MangaFormat format); string GetCoverImage(string filePath, string fileName, MangaFormat format, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default); void Extract(string fileFilePath, string targetDirectory, MangaFormat format, int imageCount = 1); @@ -51,7 +50,7 @@ public ReadingItemService(IArchiveService archiveService, IBookService bookServi /// /// Fully qualified path of file /// - public ComicInfo? GetComicInfo(string filePath) + private ComicInfo? GetComicInfo(string filePath) { if (Parser.IsEpub(filePath)) { diff --git a/API/Services/Tasks/Scanner/ProcessSeries.cs b/API/Services/Tasks/Scanner/ProcessSeries.cs index b5b0ffbcfa..dfc8dcda74 100644 --- a/API/Services/Tasks/Scanner/ProcessSeries.cs +++ b/API/Services/Tasks/Scanner/ProcessSeries.cs @@ -110,7 +110,7 @@ await _unitOfWork.SeriesRepository.GetFullSeriesByAnyName(firstInfo.Series, firs try { - _logger.LogInformation("[ScannerService] Processing series {SeriesName}", series.OriginalName); + _logger.LogInformation("[ScannerService] Processing series {SeriesName} with {Count} files", series.OriginalName, parsedInfos.Count); // parsedInfos[0] is not the first volume or chapter. We need to find it using a ComicInfo check (as it uses firstParsedInfo for series sort) var firstParsedInfo = parsedInfos.FirstOrDefault(p => p.ComicInfo != null, firstInfo); @@ -423,7 +423,7 @@ private async Task UpdateCollectionTags(Series series, Chapter firstChapter) var defaultAdmin = await _unitOfWork.UserRepository.GetDefaultAdminUser(AppUserIncludes.Collections); if (defaultAdmin == null) return; - _logger.LogDebug("Collection tag(s) found for {SeriesName}, updating collections", series.Name); + _logger.LogInformation("Collection tag(s) found for {SeriesName}, updating collections", series.Name); var sw = Stopwatch.StartNew(); foreach (var collection in firstChapter.SeriesGroup.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) @@ -593,7 +593,6 @@ private async Task UpdateVolumes(Series series, IList parsedInfos, b { // Add new volumes and update chapters per volume var distinctVolumes = parsedInfos.DistinctVolumes(); - _logger.LogTrace("[ScannerService] Updating {DistinctVolumes} volumes on {SeriesName}", distinctVolumes.Count, series.Name); foreach (var volumeNumber in distinctVolumes) { Volume? volume; @@ -621,7 +620,6 @@ private async Task UpdateVolumes(Series series, IList parsedInfos, b volume.LookupName = volumeNumber; volume.Name = volume.GetNumberTitle(); - _logger.LogTrace("[ScannerService] Parsing {SeriesName} - Volume {VolumeNumber}", series.Name, volume.Name); var infos = parsedInfos.Where(p => p.Volumes == volumeNumber).ToArray(); await UpdateChapters(series, volume, infos, forceUpdate); @@ -641,7 +639,7 @@ private void RemoveVolumes(Series series, IList parsedInfos) if (series.Volumes.Count == nonDeletedVolumes.Count) return; - _logger.LogTrace("[ScannerService] Removed {Count} volumes from {SeriesName} where parsed infos were not mapping with volume name", + _logger.LogDebug("[ScannerService] Removed {Count} volumes from {SeriesName} where parsed infos were not mapping with volume name", (series.Volumes.Count - nonDeletedVolumes.Count), series.Name); var deletedVolumes = series.Volumes.Except(nonDeletedVolumes); foreach (var volume in deletedVolumes) @@ -655,7 +653,7 @@ private void RemoveVolumes(Series series, IList parsedInfos) file); } - _logger.LogTrace("[ScannerService] Removed {SeriesName} - Volume {Volume}: {File}", series.Name, volume.Name, file); + _logger.LogDebug("[ScannerService] Removed {SeriesName} - Volume {Volume}: {File}", series.Name, volume.Name, file); } series.Volumes = nonDeletedVolumes; @@ -681,7 +679,7 @@ private async Task UpdateChapters(Series series, Volume volume, IList parsedInfos) // If no files remain after filtering, remove the chapter if (existingChapter.Files.Count != 0) continue; - _logger.LogTrace("[ScannerService] Removed chapter {Chapter} for Volume {VolumeNumber} on {SeriesName}", + _logger.LogDebug("[ScannerService] Removed chapter {Chapter} for Volume {VolumeNumber} on {SeriesName}", existingChapter.Range, volume.Name, parsedInfos[0].Series); volume.Chapters.Remove(existingChapter); } @@ -789,7 +787,7 @@ private void RemoveChapters(Volume volume, IList parsedInfos) // If no files exist, remove the chapter if (filesExist) continue; - _logger.LogTrace("[ScannerService] Removed chapter {Chapter} for Volume {VolumeNumber} on {SeriesName} as no files exist", + _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); } diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index 5c4a5c95be..09af58f198 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -504,6 +504,7 @@ "version": "17.3.4", "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-17.3.4.tgz", "integrity": "sha512-TVWjpZSI/GIXTYsmVgEKYjBckcW8Aj62DcxLNehRFR+c7UB95OY3ZFjU8U4jL0XvWPgTkkVWQVq+P6N4KCBsyw==", + "dev": true, "dependencies": { "@babel/core": "7.23.9", "@jridgewell/sourcemap-codec": "^1.4.14", @@ -531,6 +532,7 @@ "version": "7.23.9", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.9.tgz", "integrity": "sha512-5q0175NOjddqpvvzU+kDiSOAk4PfdO6FvwCWoQ6RO7rTzEe8vlo+4HVfcnAREhD4npMs0e9uZypjTwzZPCf/cw==", + "dev": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.23.5", @@ -559,12 +561,14 @@ "node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true }, "node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "bin": { "semver": "bin/semver.js" } @@ -745,6 +749,7 @@ "version": "7.24.0", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.0.tgz", "integrity": "sha512-fQfkg0Gjkza3nf0c7/w6Xf34BW4YvzNfACRLmmb7XRLa6XHdR+K9AlJlxneFfWYf6uhOzuzZVTjF/8KfndZANw==", + "dev": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.23.5", @@ -773,12 +778,14 @@ "node_modules/@babel/core/node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true }, "node_modules/@babel/core/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "bin": { "semver": "bin/semver.js" } @@ -5622,6 +5629,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -5634,6 +5642,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, "engines": { "node": ">=8.6" }, @@ -5905,6 +5914,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, "engines": { "node": ">=8" }, @@ -6216,6 +6226,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -6507,7 +6518,8 @@ "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true }, "node_modules/cookie": { "version": "0.6.0", @@ -7409,6 +7421,7 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, "optional": true, "dependencies": { "iconv-lite": "^0.6.2" @@ -7418,6 +7431,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, "optional": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -8526,6 +8540,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -9207,6 +9222,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, "dependencies": { "binary-extensions": "^2.0.0" }, @@ -11047,6 +11063,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -12436,6 +12453,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, "dependencies": { "picomatch": "^2.2.1" }, @@ -12447,6 +12465,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, "engines": { "node": ">=8.6" }, @@ -12457,7 +12476,8 @@ "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", - "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "dev": true }, "node_modules/regenerate": { "version": "1.4.2", @@ -12925,7 +12945,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "devOptional": true + "dev": true }, "node_modules/sass": { "version": "1.71.1", @@ -13044,6 +13064,7 @@ "version": "7.6.0", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "dev": true, "dependencies": { "lru-cache": "^6.0.0" }, @@ -13058,6 +13079,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, "dependencies": { "yallist": "^4.0.0" }, @@ -13068,7 +13090,8 @@ "node_modules/semver/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true }, "node_modules/send": { "version": "0.18.0", @@ -14199,6 +14222,7 @@ "version": "5.4.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/UI/Web/src/app/_pipes/read-time-left.pipe.ts b/UI/Web/src/app/_pipes/read-time-left.pipe.ts index 7ac093dd7c..43ac41c868 100644 --- a/UI/Web/src/app/_pipes/read-time-left.pipe.ts +++ b/UI/Web/src/app/_pipes/read-time-left.pipe.ts @@ -1,6 +1,7 @@ import { Pipe, PipeTransform } from '@angular/core'; import {TranslocoService} from "@jsverse/transloco"; import {HourEstimateRange} from "../_models/series-detail/hour-estimate-range"; +import {DecimalPipe} from "@angular/common"; @Pipe({ name: 'readTimeLeft', @@ -8,9 +9,31 @@ import {HourEstimateRange} from "../_models/series-detail/hour-estimate-range"; }) export class ReadTimeLeftPipe implements PipeTransform { - constructor(private translocoService: TranslocoService) {} + constructor(private readonly translocoService: TranslocoService) {} transform(readingTimeLeft: HourEstimateRange): string { - return `~${readingTimeLeft.avgHours} ${readingTimeLeft.avgHours > 1 ? this.translocoService.translate('read-time-pipe.hours') : this.translocoService.translate('read-time-pipe.hour')}`; + const hoursLabel = readingTimeLeft.avgHours > 1 + ? this.translocoService.translate('read-time-pipe.hours') + : this.translocoService.translate('read-time-pipe.hour'); + + const formattedHours = this.customRound(readingTimeLeft.avgHours); + + return `~${formattedHours} ${hoursLabel}`; + } + + private customRound(value: number): string { + const integerPart = Math.floor(value); + const decimalPart = value - integerPart; + + if (decimalPart < 0.5) { + // Round down to the nearest whole number + return integerPart.toString(); + } else if (decimalPart >= 0.5 && decimalPart < 0.9) { + // Return with 1 decimal place + return value.toFixed(1); + } else { + // Round up to the nearest whole number + return Math.ceil(value).toString(); + } } } diff --git a/UI/Web/src/app/_services/action.service.ts b/UI/Web/src/app/_services/action.service.ts index 5ccdf9b227..4933ca0921 100644 --- a/UI/Web/src/app/_services/action.service.ts +++ b/UI/Web/src/app/_services/action.service.ts @@ -490,7 +490,7 @@ export class ActionService { this.readingListModalRef.componentInstance.seriesId = seriesId; this.readingListModalRef.componentInstance.volumeIds = volumes.map(v => v.id); this.readingListModalRef.componentInstance.chapterIds = chapters?.map(c => c.id); - this.readingListModalRef.componentInstance.title = translate('action.multiple-selections'); + this.readingListModalRef.componentInstance.title = translate('actionable.multiple-selections'); this.readingListModalRef.componentInstance.type = ADD_FLOW.Multiple; @@ -530,7 +530,7 @@ export class ActionService { if (this.readingListModalRef != null) { return; } this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md', fullscreen: 'md' }); this.readingListModalRef.componentInstance.seriesIds = series.map(v => v.id); - this.readingListModalRef.componentInstance.title = translate('action.multiple-selections'); + this.readingListModalRef.componentInstance.title = translate('actionable.multiple-selections'); this.readingListModalRef.componentInstance.type = ADD_FLOW.Multiple_Series; diff --git a/UI/Web/src/app/_services/person.service.ts b/UI/Web/src/app/_services/person.service.ts index cc83e2a50e..006909d3eb 100644 --- a/UI/Web/src/app/_services/person.service.ts +++ b/UI/Web/src/app/_services/person.service.ts @@ -44,7 +44,7 @@ export class PersonService { let params = new HttpParams(); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); - return this.httpClient.post>(this.baseUrl + 'person/authors', {}, {observe: 'response', params}).pipe( + return this.httpClient.post>(this.baseUrl + 'person/all', {}, {observe: 'response', params}).pipe( map((response: any) => { return this.utilityService.createPaginatedResult(response) as PaginatedResult; }) diff --git a/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.html b/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.html index 600e46637f..067dc5fb2b 100644 --- a/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.html +++ b/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.html @@ -2,21 +2,23 @@