diff --git a/API.Tests/Services/CleanupServiceTests.cs b/API.Tests/Services/CleanupServiceTests.cs index 2ebee8d1d..ef80ad850 100644 --- a/API.Tests/Services/CleanupServiceTests.cs +++ b/API.Tests/Services/CleanupServiceTests.cs @@ -521,6 +521,71 @@ public class CleanupServiceTests : AbstractDbTest } #endregion + #region ConsolidateProgress + + [Fact] + public async Task ConsolidateProgress_ShouldRemoveDuplicates() + { + await ResetDb(); + + var s = new SeriesBuilder("Test ConsolidateProgress_ShouldRemoveDuplicates") + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1") + .WithPages(3) + .Build()) + .Build()) + .Build(); + + s.Library = new LibraryBuilder("Test Lib").Build(); + _context.Series.Add(s); + + var user = new AppUser() + { + UserName = "ConsolidateProgress_ShouldRemoveDuplicates", + }; + _context.AppUser.Add(user); + + await _unitOfWork.CommitAsync(); + + // Add 2 progress events + user.Progresses ??= []; + user.Progresses.Add(new AppUserProgress() + { + ChapterId = 1, + VolumeId = 1, + SeriesId = 1, + LibraryId = s.LibraryId, + PagesRead = 1, + }); + await _unitOfWork.CommitAsync(); + + // Add a duplicate with higher page number + user.Progresses.Add(new AppUserProgress() + { + ChapterId = 1, + VolumeId = 1, + SeriesId = 1, + LibraryId = s.LibraryId, + PagesRead = 3, + }); + await _unitOfWork.CommitAsync(); + + Assert.Equal(2, (await _unitOfWork.AppUserProgressRepository.GetAllProgress()).Count()); + + var cleanupService = new CleanupService(Substitute.For>(), _unitOfWork, + Substitute.For(), + new DirectoryService(Substitute.For>(), new MockFileSystem())); + + + await cleanupService.ConsolidateProgress(); + + var progress = await _unitOfWork.AppUserProgressRepository.GetAllProgress(); + + Assert.Single(progress); + Assert.True(progress.First().PagesRead == 3); + } + #endregion + #region EnsureChapterProgressIsCapped @@ -587,7 +652,7 @@ public class CleanupServiceTests : AbstractDbTest } #endregion - // #region CleanupBookmarks + #region CleanupBookmarks // // [Fact] // public async Task CleanupBookmarks_LeaveAllFiles() @@ -724,5 +789,5 @@ public class CleanupServiceTests : AbstractDbTest // Assert.Equal(1, ds.FileSystem.Directory.GetDirectories($"{BookmarkDirectory}1/1/").Length); // } // - // #endregion + #endregion } diff --git a/API.Tests/Services/ScannerServiceTests.cs b/API.Tests/Services/ScannerServiceTests.cs index 5b6feeefa..a56794992 100644 --- a/API.Tests/Services/ScannerServiceTests.cs +++ b/API.Tests/Services/ScannerServiceTests.cs @@ -562,4 +562,73 @@ public class ScannerServiceTests : AbstractDbTest s2 = postLib.Series.First(s => s.Name == "Accel"); Assert.Single(s2.Volumes); } + + //[Fact] + public async Task ScanLibrary_AlternatingRemoval_IssueReplication() + { + // https://github.com/Kareadita/Kavita/issues/3476#issuecomment-2661635558 + // TODO: Come back to this, it's complicated + const string testcase = "Alternating Removal - Manga.json"; + + // Setup: Generate test library + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + + var testDirectoryPath = Path.Combine(Directory.GetCurrentDirectory(), + "../../../Services/Test Data/ScannerService/ScanTests", + testcase.Replace(".json", string.Empty)); + + library.Folders = + [ + new FolderPath() { Path = Path.Combine(testDirectoryPath, "Root 1") }, + new FolderPath() { Path = Path.Combine(testDirectoryPath, "Root 2") } + ]; + + _unitOfWork.LibraryRepository.Update(library); + await _unitOfWork.CommitAsync(); + + var scanner = _scannerHelper.CreateServices(); + + // First Scan: Everything should be added + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Contains(postLib.Series, s => s.Name == "Accel"); + Assert.Contains(postLib.Series, s => s.Name == "Plush"); + + // Second Scan: Remove Root 2, expect Accel to be removed + library.Folders = [new FolderPath() { Path = Path.Combine(testDirectoryPath, "Root 1") }]; + _unitOfWork.LibraryRepository.Update(library); + await _unitOfWork.CommitAsync(); + + await scanner.ScanLibrary(library.Id); + postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.DoesNotContain(postLib.Series, s => s.Name == "Accel"); // Ensure Accel is gone + Assert.Contains(postLib.Series, s => s.Name == "Plush"); + + // Third Scan: Re-add Root 2, Accel should come back + library.Folders = + [ + new FolderPath() { Path = Path.Combine(testDirectoryPath, "Root 1") }, + new FolderPath() { Path = Path.Combine(testDirectoryPath, "Root 2") } + ]; + _unitOfWork.LibraryRepository.Update(library); + await _unitOfWork.CommitAsync(); + + await scanner.ScanLibrary(library.Id); + postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.Contains(postLib.Series, s => s.Name == "Accel"); // Accel should be back + Assert.Contains(postLib.Series, s => s.Name == "Plush"); + + // Fourth Scan: Run again to check stability (should not remove Accel) + await scanner.ScanLibrary(library.Id); + postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.Contains(postLib.Series, s => s.Name == "Accel"); + Assert.Contains(postLib.Series, s => s.Name == "Plush"); + } + } diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Alternating Removal - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Alternating Removal - Manga.json new file mode 100644 index 000000000..791dcdc44 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Alternating Removal - Manga.json @@ -0,0 +1,5 @@ +[ + "Root 1/Antarctic Press/Plush/Plush v01.cbz", + "Root 1/Antarctic Press/Plush/Plush v02.cbz", + "Root 2/Accel/Accel v01.cbz" +] diff --git a/API/API.csproj b/API/API.csproj index 2e1607c54..f8e1833ca 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -197,6 +197,9 @@ Always + + Always + diff --git a/API/Assets/anilist-no-image-placeholder.jpg b/API/Assets/anilist-no-image-placeholder.jpg new file mode 100644 index 000000000..54c1066b6 Binary files /dev/null and b/API/Assets/anilist-no-image-placeholder.jpg differ diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index 152e65495..4be2b8dc1 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -1370,7 +1370,9 @@ public class OpdsController : BaseApiController using var sm = new StringWriter(); _xmlSerializer.Serialize(sm, feed); - return sm.ToString().Replace("utf-16", "utf-8"); // Chunky cannot accept UTF-16 feeds + var ret = sm.ToString().Replace("utf-16", "utf-8"); // Chunky cannot accept UTF-16 feeds + + return ret; } // Recursively sanitize all string properties in the object @@ -1381,6 +1383,10 @@ public class OpdsController : BaseApiController var properties = obj.GetType().GetProperties(); foreach (var property in properties) { + // Skip properties that require an index (e.g., indexed collections) + if (property.GetIndexParameters().Length > 0) + continue; + if (property.PropertyType == typeof(string) && property.CanWrite) { var value = (string?)property.GetValue(obj); @@ -1391,7 +1397,9 @@ public class OpdsController : BaseApiController } else if (property.PropertyType.IsClass) // Handle nested objects { - SanitizeFeed(property.GetValue(obj)); + var nestedObject = property.GetValue(obj); + if (nestedObject != null) + SanitizeFeed(nestedObject); } } } diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs index 3f12a059d..ff92964ff 100644 --- a/API/Controllers/SettingsController.cs +++ b/API/Controllers/SettingsController.cs @@ -567,14 +567,15 @@ public class SettingsController : BaseApiController existingMetadataSetting.EnableStartDate = dto.EnableStartDate; existingMetadataSetting.EnableGenres = dto.EnableGenres; existingMetadataSetting.EnableTags = dto.EnableTags; - existingMetadataSetting.PersonRoles = dto.PersonRoles; existingMetadataSetting.FirstLastPeopleNaming = dto.FirstLastPeopleNaming; + existingMetadataSetting.EnableCoverImage = dto.EnableCoverImage; existingMetadataSetting.AgeRatingMappings = dto.AgeRatingMappings ?? []; existingMetadataSetting.Blacklist = dto.Blacklist.Where(s => !string.IsNullOrWhiteSpace(s)).DistinctBy(d => d.ToNormalized()).ToList() ?? []; existingMetadataSetting.Whitelist = dto.Whitelist.Where(s => !string.IsNullOrWhiteSpace(s)).DistinctBy(d => d.ToNormalized()).ToList() ?? []; existingMetadataSetting.Overrides = dto.Overrides.ToList() ?? []; + existingMetadataSetting.PersonRoles = dto.PersonRoles ?? []; // Handle Field Mappings if (dto.FieldMappings != null) diff --git a/API/Controllers/UploadController.cs b/API/Controllers/UploadController.cs index bea9771ce..4b935a1bf 100644 --- a/API/Controllers/UploadController.cs +++ b/API/Controllers/UploadController.cs @@ -110,13 +110,10 @@ public class UploadController : BaseApiController lockState = uploadFileDto.LockCover; } - if (!string.IsNullOrEmpty(filePath)) - { - series.CoverImage = filePath; - series.CoverImageLocked = lockState; - _imageService.UpdateColorScape(series); - _unitOfWork.SeriesRepository.Update(series); - } + series.CoverImage = filePath; + series.CoverImageLocked = lockState; + _imageService.UpdateColorScape(series); + _unitOfWork.SeriesRepository.Update(series); if (_unitOfWork.HasChanges()) { diff --git a/API/DTOs/Update/UpdateNotificationDto.cs b/API/DTOs/Update/UpdateNotificationDto.cs index 8fb5146d5..535e1f896 100644 --- a/API/DTOs/Update/UpdateNotificationDto.cs +++ b/API/DTOs/Update/UpdateNotificationDto.cs @@ -62,6 +62,7 @@ public class UpdateNotificationDto public IList Theme { get; set; } public IList Developer { get; set; } public IList Api { get; set; } + public IList FeatureRequests { get; set; } /// /// The part above the changelog part /// diff --git a/API/Data/Repositories/AppUserProgressRepository.cs b/API/Data/Repositories/AppUserProgressRepository.cs index 3b065f2e0..388ca5b7e 100644 --- a/API/Data/Repositories/AppUserProgressRepository.cs +++ b/API/Data/Repositories/AppUserProgressRepository.cs @@ -19,6 +19,7 @@ namespace API.Data.Repositories; public interface IAppUserProgressRepository { void Update(AppUserProgress userProgress); + void Remove(AppUserProgress userProgress); Task CleanupAbandonedChapters(); Task UserHasProgress(LibraryType libraryType, int userId); Task GetUserProgressAsync(int chapterId, int userId); @@ -57,6 +58,11 @@ public class AppUserProgressRepository : IAppUserProgressRepository _context.Entry(userProgress).State = EntityState.Modified; } + public void Remove(AppUserProgress userProgress) + { + _context.Remove(userProgress); + } + /// /// This will remove any entries that have chapterIds that no longer exists. This will execute the save as well. /// diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index 1eb613ea4..d80d479f4 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -1848,7 +1848,7 @@ public class SeriesRepository : ISeriesRepository .ToList(); // Prefer the first match or handle duplicates by choosing the last one - if (matchingSeries.Any()) + if (matchingSeries.Count != 0) { ids.Add(matchingSeries.Last().Id); } diff --git a/API/Extensions/ImageExtensions.cs b/API/Extensions/ImageExtensions.cs new file mode 100644 index 000000000..720f572a9 --- /dev/null +++ b/API/Extensions/ImageExtensions.cs @@ -0,0 +1,122 @@ +using System; +using System.IO; +using NetVips; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using Image = NetVips.Image; + +namespace API.Extensions; + +public static class ImageExtensions +{ + public static int GetResolution(this Image image) + { + return image.Width * image.Height; + } + + /// + /// Smaller is better + /// + /// + /// + /// + public static float GetMeanSquaredError(this Image img1, Image img2) + { + if (img1.Width != img2.Width || img1.Height != img2.Height) + { + img2.Mutate(x => x.Resize(img1.Width, img1.Height)); + } + + double totalDiff = 0; + for (var y = 0; y < img1.Height; y++) + { + for (var x = 0; x < img1.Width; x++) + { + var pixel1 = img1[x, y]; + var pixel2 = img2[x, y]; + + var diff = Math.Pow(pixel1.R - pixel2.R, 2) + + Math.Pow(pixel1.G - pixel2.G, 2) + + Math.Pow(pixel1.B - pixel2.B, 2); + totalDiff += diff; + } + } + + return (float)(totalDiff / (img1.Width * img1.Height)); + } + + public static float GetSimilarity(this string imagePath1, string imagePath2) + { + if (!File.Exists(imagePath1) || !File.Exists(imagePath2)) + { + throw new FileNotFoundException("One or both image files do not exist"); + } + + // Calculate similarity score + return CalculateSimilarity(imagePath1, imagePath2); + } + + /// + /// Determines which image is "better" based on similarity and resolution. + /// + /// Path to first image + /// Path to second image + /// Minimum similarity to consider images similar + /// The path of the better image + public static string GetBetterImage(this string imagePath1, string imagePath2, float similarityThreshold = 0.7f) + { + if (!File.Exists(imagePath1) || !File.Exists(imagePath2)) + { + throw new FileNotFoundException("One or both image files do not exist"); + } + + // Calculate similarity score + var similarity = CalculateSimilarity(imagePath1, imagePath2); + + using var img1 = Image.NewFromFile(imagePath1, access: Enums.Access.Sequential); + using var img2 = Image.NewFromFile(imagePath2, access: Enums.Access.Sequential); + + var resolution1 = img1.Width * img1.Height; + var resolution2 = img2.Width * img2.Height; + + // If images are similar, choose the one with higher resolution + if (similarity >= similarityThreshold) + { + return resolution1 >= resolution2 ? imagePath1 : imagePath2; + } + + // If images are not similar, allow the new image + return imagePath2; + } + + /// + /// Calculate a similarity score (0-1f) based on resolution difference and MSE. + /// + /// + /// + /// + private static float CalculateSimilarity(string imagePath1, string imagePath2) + { + if (!File.Exists(imagePath1) || !File.Exists(imagePath2)) + { + return -1; + } + + using var img1 = Image.NewFromFile(imagePath1, access: Enums.Access.Sequential); + using var img2 = Image.NewFromFile(imagePath2, access: Enums.Access.Sequential); + + var res1 = img1.Width * img1.Height; + var res2 = img2.Width * img2.Height; + var resolutionDiff = Math.Abs(res1 - res2) / (float)Math.Max(res1, res2); + + using var imgSharp1 = SixLabors.ImageSharp.Image.Load(imagePath1); + using var imgSharp2 = SixLabors.ImageSharp.Image.Load(imagePath2); + + var mse = imgSharp1.GetMeanSquaredError(imgSharp2); + var normalizedMse = 1f - Math.Min(1f, mse / 65025f); // Normalize based on max color diff + + // Final similarity score (weighted) + return Math.Max(0f, 1f - (resolutionDiff * 0.5f) - (1f - normalizedMse) * 0.5f); + } +} diff --git a/API/Helpers/PdfComicInfoExtractor.cs b/API/Helpers/PdfComicInfoExtractor.cs index f01a25604..aaa93428f 100644 --- a/API/Helpers/PdfComicInfoExtractor.cs +++ b/API/Helpers/PdfComicInfoExtractor.cs @@ -1,16 +1,11 @@ -/// Translate PDF metadata (See PdfMetadataExtractor.cs) into ComicInfo structure. - -// Contributed by https://github.com/microtherion - -// All references to the "PDF Spec" (section numbers, etc) refer to the -// PDF 1.7 Specification a.k.a. PDF32000-1:2008 -// https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf - +/** + * Contributed by https://github.com/microtherion + * + * All references to the "PDF Spec" (section numbers, etc) refer to the + * PDF 1.7 Specification a.k.a. PDF32000-1:2008 + * https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf + */ using System; -using System.Xml; -using System.Text; -using System.IO; -using System.Diagnostics; using API.Data.Metadata; using API.Entities.Enums; using API.Services; @@ -18,6 +13,7 @@ using API.Services.Tasks.Scanner.Parser; using Microsoft.Extensions.Logging; using Nager.ArticleNumber; using System.Collections.Generic; +using System.Globalization; namespace API.Helpers; #nullable enable @@ -27,6 +23,9 @@ public interface IPdfComicInfoExtractor ComicInfo? GetComicInfo(string filePath); } +/// +/// Translate PDF metadata (See PdfMetadataExtractor.cs) into ComicInfo structure. +/// public class PdfComicInfoExtractor : IPdfComicInfoExtractor { private readonly ILogger _logger; @@ -44,7 +43,7 @@ public class PdfComicInfoExtractor : IPdfComicInfoExtractor _mediaErrorService = mediaErrorService; } - private float? GetFloatFromText(string? text) + private static float? GetFloatFromText(string? text) { if (string.IsNullOrEmpty(text)) return null; @@ -78,9 +77,9 @@ public class PdfComicInfoExtractor : IPdfComicInfoExtractor return null; } - private string? MaybeGetMetadata(Dictionary metadata, string key) + private static string? MaybeGetMetadata(Dictionary metadata, string key) { - return metadata.ContainsKey(key) ? metadata[key] : null; + return metadata.TryGetValue(key, out var value) ? value : null; } private ComicInfo? GetComicInfoFromMetadata(Dictionary metadata, string filePath) @@ -100,6 +99,7 @@ public class PdfComicInfoExtractor : IPdfComicInfoExtractor info.Publisher = MaybeGetMetadata(metadata, "Publisher") ?? string.Empty; info.Writer = MaybeGetMetadata(metadata, "Author") ?? string.Empty; info.Title = MaybeGetMetadata(metadata, "Title") ?? string.Empty; + info.TitleSort = MaybeGetMetadata(metadata, "TitleSort") ?? string.Empty; info.Genre = MaybeGetMetadata(metadata, "Subject") ?? string.Empty; info.LanguageISO = BookService.ValidateLanguage(MaybeGetMetadata(metadata, "Language")); info.Isbn = MaybeGetMetadata(metadata, "ISBN") ?? string.Empty; @@ -111,10 +111,9 @@ public class PdfComicInfoExtractor : IPdfComicInfoExtractor } info.UserRating = GetFloatFromText(MaybeGetMetadata(metadata, "UserRating")) ?? 0.0f; - info.TitleSort = MaybeGetMetadata(metadata, "TitleSort") ?? string.Empty; - info.Series = MaybeGetMetadata(metadata, "Series") ?? info.TitleSort; + info.Series = MaybeGetMetadata(metadata, "Series") ?? info.Title; info.SeriesSort = info.Series; - info.Volume = (GetFloatFromText(MaybeGetMetadata(metadata, "Volume")) ?? 0.0f).ToString(); + info.Volume = MaybeGetMetadata(metadata, "Volume") ?? string.Empty; // If this is a single book and not a collection, set publication status to Completed if (string.IsNullOrEmpty(info.Volume) && Parser.ParseVolume(filePath, LibraryType.Manga).Equals(Parser.LooseLeafVolume)) @@ -122,18 +121,6 @@ public class PdfComicInfoExtractor : IPdfComicInfoExtractor info.Count = 1; } - // Removed as probably unneeded per discussion in https://github.com/Kareadita/Kavita/pull/3108#discussion_r1956747782 - // - // var hasVolumeInSeries = !Parser.ParseVolume(info.Title, LibraryType.Manga) - // .Equals(Parser.LooseLeafVolume); - - // if (string.IsNullOrEmpty(info.Volume) && hasVolumeInSeries && (!info.Series.Equals(info.Title) || string.IsNullOrEmpty(info.Series))) - // { - // // This is likely a light novel for which we can set series from parsed title - // info.Series = Parser.ParseSeries(info.Title, LibraryType.Manga); - // info.Volume = Parser.ParseVolume(info.Title, LibraryType.Manga); - // } - ComicInfo.CleanComicInfo(info); return info; @@ -156,4 +143,4 @@ public class PdfComicInfoExtractor : IPdfComicInfoExtractor return null; } -} \ No newline at end of file +} diff --git a/API/Helpers/PdfMetadataExtractor.cs b/API/Helpers/PdfMetadataExtractor.cs index 5ef20516c..44327672b 100644 --- a/API/Helpers/PdfMetadataExtractor.cs +++ b/API/Helpers/PdfMetadataExtractor.cs @@ -1,21 +1,14 @@ -/// Parse PDF file and try to extract as much metadata as possible. -/// Supports both text based XRef tables and compressed XRef streams (Deflate only). -/// Supports both UTF-16 and PDFDocEncoding for strings. -/// Lacks support for many PDF configurations that are theoretically possible, but should handle most common cases. - -// Contributed by https://github.com/microtherion - -// All references to the "PDF Spec" (section numbers, etc) refer to the -// PDF 1.7 Specification a.k.a. PDF32000-1:2008 -// https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf +/** + * Contributed by https://github.com/microtherion + * + * All references to the "PDF Spec" (section numbers, etc) refer to the + * PDF 1.7 Specification a.k.a. PDF32000-1:2008 + * https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf + */ using System; using System.Collections.Generic; -using System.Diagnostics; using System.IO.Compression; -using System.Reflection.Metadata.Ecma335; -using System.Runtime.InteropServices; -using System.Security.Principal; using System.Text; using System.Xml; using System.IO; @@ -25,6 +18,12 @@ using API.Services; namespace API.Helpers; #nullable enable +/// +/// Parse PDF file and try to extract as much metadata as possible. +/// Supports both text based XRef tables and compressed XRef streams (Deflate only). +/// Supports both UTF-16 and PDFDocEncoding for strings. +/// Lacks support for many PDF configurations that are theoretically possible, but should handle most common cases. +/// public class PdfMetadataExtractorException : Exception { public PdfMetadataExtractorException() @@ -56,19 +55,21 @@ class PdfStringBuilder // PDFDocEncoding defined in PDF Spec D.1 - private readonly char[] _pdfDocMappingLow = new char[] { - '\u02D8', '\u02C7', '\u02C6', '\u02D9', '\u02DD', '\u02DB', '\u02DA', '\u02DC', - }; + private readonly char[] _pdfDocMappingLow = + [ + '\u02D8', '\u02C7', '\u02C6', '\u02D9', '\u02DD', '\u02DB', '\u02DA', '\u02DC' + ]; - private readonly char[] _pdfDocMappingHigh = new char[] { + private readonly char[] _pdfDocMappingHigh = + [ '\u2022', '\u2020', '\u2021', '\u2026', '\u2014', '\u2013', '\u0192', '\u2044', '\u2039', '\u203A', '\u2212', '\u2030', '\u201E', '\u201C', '\u201D', '\u2018', '\u2019', '\u201A', '\u2122', '\uFB01', '\uFB02', '\u0141', '\u0152', '\u0160', '\u0178', '\u017D', '\u0131', '\u0142', '\u0153', '\u0161', '\u017E', ' ', - '\u20AC', - }; + '\u20AC' + ]; - public void AppendPdfDocByte(byte b) + private void AppendPdfDocByte(byte b) { if (b >= 0x18 && b < 0x20) { @@ -148,8 +149,13 @@ class PdfStringBuilder } } -class PdfLexer(Stream stream) +internal class PdfLexer(Stream stream) { + private const int BufferSize = 1024; + private readonly byte[] _buffer = new byte[BufferSize]; + private int _pos = 0; + private int _valid = 0; + public enum TokenType { None, @@ -171,16 +177,10 @@ class PdfLexer(Stream stream) Newline, } - public struct Token + public struct Token(TokenType type, object value) { - public TokenType type; - public object value; - - public Token(TokenType type, object value) - { - this.type = type; - this.value = value; - } + public TokenType Type = type; + public object Value = value; } public Token NextToken(bool reportNewlines = false) @@ -273,7 +273,7 @@ class PdfLexer(Stream stream) { while (true) { - byte b = NextByte(); + var b = NextByte(); switch ((char)b) { case ' ': @@ -303,7 +303,7 @@ class PdfLexer(Stream stream) // Look for the startxref element as per PDF Spec 7.5.5 while (true) { - byte b = NextByte(); + var b = NextByte(); switch ((char)b) { @@ -345,13 +345,13 @@ class PdfLexer(Stream stream) var token = NextToken(true); - if (token.type == TokenType.Keyword && (string)token.value == "startxref") + if (token.Type == TokenType.Keyword && (string)token.Value == "startxref") { token = NextToken(); - if (token.type == TokenType.Int) + if (token.Type == TokenType.Int) { - return (long)token.value; + return (long)token.Value; } else { @@ -382,8 +382,8 @@ class PdfLexer(Stream stream) if (obj == 0) { - obj = Convert.ToInt64(System.Text.Encoding.ASCII.GetString(_buffer, _pos, 10)); - generation = Convert.ToInt32(System.Text.Encoding.ASCII.GetString(_buffer, _pos + 11, 5)); + obj = Convert.ToInt64(Encoding.ASCII.GetString(_buffer, _pos, 10)); + generation = Convert.ToInt32(Encoding.ASCII.GetString(_buffer, _pos + 11, 5)); inUse = _buffer[_pos + 17] == 'n'; } @@ -404,7 +404,7 @@ class PdfLexer(Stream stream) if (_pos < _valid) { - int buffered = Math.Min(_valid - _pos, length); + var buffered = Math.Min(_valid - _pos, length); rawData.Write(_buffer, _pos, buffered); length -= buffered; _pos += buffered; @@ -412,8 +412,8 @@ class PdfLexer(Stream stream) while (length > 0) { - int buffered = Math.Min(length, _bufferSize); - stream.Read(_buffer, 0, buffered); + var buffered = Math.Min(length, BufferSize); + stream.ReadExactly(_buffer, 0, buffered); rawData.Write(_buffer, 0, buffered); _pos = 0; _valid = 0; @@ -432,17 +432,12 @@ class PdfLexer(Stream stream) } } - private const int _bufferSize = 1024; - private readonly byte[] _buffer = new byte[_bufferSize]; - private int _pos = 0; - private int _valid = 0; - private byte NextByte() { if (_pos >= _valid) { _pos = 0; - _valid = stream.Read(_buffer, 0, _bufferSize); + _valid = stream.Read(_buffer, 0, BufferSize); if (_valid <= 0) { @@ -478,7 +473,7 @@ class PdfLexer(Stream stream) Buffer.BlockCopy(_buffer, _pos, _buffer, 0, _valid - _pos); _valid -= _pos; _pos = 0; - _valid += stream.Read(_buffer, _valid, _bufferSize - _valid); + _valid += stream.Read(_buffer, _valid, BufferSize - _valid); } } @@ -486,7 +481,7 @@ class PdfLexer(Stream stream) { while (true) { - byte b = NextByte(); + var b = NextByte(); if (b == '\n') { @@ -507,14 +502,14 @@ class PdfLexer(Stream stream) private Token ScanNumber() { StringBuilder sb = new(); - bool hasDot = LastByte() == '.'; - bool followedBySpace = false; + var hasDot = LastByte() == '.'; + var followedBySpace = false; sb.Append((char)LastByte()); while (true) { - byte b = NextByte(); + var b = NextByte(); if (b == '.' || b >= '0' && b <= '9') { @@ -533,17 +528,19 @@ class PdfLexer(Stream stream) break; } } + if (hasDot) { return new Token(TokenType.Double, double.Parse(sb.ToString())); } + if (followedBySpace) { // Look ahead to see if it's an object reference (PDF Spec 7.3.10) WantLookahead(32); var savedPos = _pos; - byte b = NextByte(); + var b = NextByte(); while (b == ' ' || b == '\t') { @@ -578,32 +575,25 @@ class PdfLexer(Stream stream) return new Token(TokenType.Int, long.Parse(sb.ToString())); } - private int HexDigit(byte b) + private static int HexDigit(byte b) { - switch ((char)b) + return (char) b switch { - case >= '0' and <= '9': - return b - (byte)'0'; - - case >= 'a' and <= 'f': - return b - (byte)'a' + 10; - - case >= 'A' and <= 'F': - return b - (byte)'A' + 10; - - default: - throw new PdfMetadataExtractorException("Invalid hex digit, got {b}"); - } + >= '0' and <= '9' => b - (byte) '0', + >= 'a' and <= 'f' => b - (byte) 'a' + 10, + >= 'A' and <= 'F' => b - (byte) 'A' + 10, + _ => throw new PdfMetadataExtractorException("Invalid hex digit, got {b}") + }; } private Token ScanName() { // PDF Spec 7.3.5 - StringBuilder sb = new StringBuilder(); + var sb = new StringBuilder(); while (true) { - byte b = NextByte(); + var b = NextByte(); switch ((char)b) { case '(': @@ -628,8 +618,8 @@ class PdfLexer(Stream stream) return new Token(TokenType.Name, sb.ToString()); case '#': - byte b1 = NextByte(); - byte b2 = NextByte(); + var b1 = NextByte(); + var b2 = NextByte(); b = (byte)((HexDigit(b1) << 4) | HexDigit(b2)); goto default; @@ -646,11 +636,11 @@ class PdfLexer(Stream stream) // PDF Spec 7.3.4.2 PdfStringBuilder sb = new(); - int parenLevel = 1; + var parenLevel = 1; while (true) { - byte b = NextByte(); + var b = NextByte(); switch ((char)b) { @@ -698,9 +688,9 @@ class PdfLexer(Stream stream) break; case >= '0' and <= '7': - byte b1 = b; - byte b2 = NextByte(); - byte b3 = NextByte(); + var b1 = b; + var b2 = NextByte(); + var b3 = NextByte(); if (b2 < '0' || b2 > '7' || b3 < '0' || b3 > '7') { @@ -728,12 +718,12 @@ class PdfLexer(Stream stream) while (true) { - byte b = NextByte(); + var b = NextByte(); switch ((char)b) { case (>= '0' and <= '9') or (>= 'a' and <= 'f') or (>= 'A' and <= 'F'): - byte b1 = NextByte(); + var b1 = NextByte(); if (b1 == '>') { PutBack(); @@ -760,7 +750,7 @@ class PdfLexer(Stream stream) while (true) { - byte b = NextByte(); + var b = NextByte(); if ((b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z')) { sb.Append((char)b); @@ -796,38 +786,25 @@ class PdfLexer(Stream stream) } } -class PdfMetadataExtractor : IPdfMetadataExtractor +internal class PdfMetadataExtractor : IPdfMetadataExtractor { private readonly ILogger _logger; private readonly PdfLexer _lexer; private readonly FileStream _stream; private long[] _objectOffsets = new long[0]; - private readonly Dictionary _metadata = new(); + private readonly Dictionary _metadata = []; + private readonly Stack _metadataRef = new(); - private struct MetadataRef + private struct MetadataRef(long root, long info) { - public long root; - public long info; - - public MetadataRef(long root, long info) - { - this.root = root; - this.info = info; - } + public long Root = root; + public long Info = info; } - private readonly Stack metadataRef = new(); - - private struct XRefSection + private struct XRefSection(long first, long count) { - public long first; - public long count; - - public XRefSection(long first, long count) - { - this.first = first; - this.count = count; - } + public readonly long First = first; + public readonly long Count = count; } public PdfMetadataExtractor(ILogger logger, string filename) @@ -887,7 +864,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor var token = _lexer.NextToken(); - if (token.type != PdfLexer.TokenType.Keyword || (string)token.value != "xref") + if (token.Type != PdfLexer.TokenType.Keyword || (string)token.Value != "xref") { throw new PdfMetadataExtractorException("Expected xref keyword"); } @@ -896,17 +873,17 @@ class PdfMetadataExtractor : IPdfMetadataExtractor { token = _lexer.NextToken(); - if (token.type == PdfLexer.TokenType.Int) + if (token.Type == PdfLexer.TokenType.Int) { - var startObj = (long)token.value; + var startObj = (long)token.Value; token = _lexer.NextToken(); - if (token.type != PdfLexer.TokenType.Int) + if (token.Type != PdfLexer.TokenType.Int) { throw new PdfMetadataExtractorException("Expected number of objects in xref subsection"); } - var numObj = (long)token.value; + var numObj = (long)token.Value; if (_objectOffsets.Length < startObj + numObj) { @@ -927,7 +904,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor } } } - else if (token.type == PdfLexer.TokenType.Keyword && (string)token.value == "trailer") + else if (token.Type == PdfLexer.TokenType.Keyword && (string)token.Value == "trailer") { break; } @@ -946,7 +923,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor var token = _lexer.NextToken(); - if (token.type != PdfLexer.TokenType.ObjectStart) + if (token.Type != PdfLexer.TokenType.ObjectStart) { throw new PdfMetadataExtractorException("Expected obj keyword"); } @@ -967,7 +944,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor switch (key) { case "Type": - if (value.type != PdfLexer.TokenType.Name || (string)value.value != "XRef") + if (value.Type != PdfLexer.TokenType.Name || (string)value.Value != "XRef") { throw new PdfMetadataExtractorException("Expected /Type to be /XRef"); } @@ -975,37 +952,37 @@ class PdfMetadataExtractor : IPdfMetadataExtractor return true; case "Length": - if (value.type != PdfLexer.TokenType.Int) + if (value.Type != PdfLexer.TokenType.Int) { throw new PdfMetadataExtractorException("Expected integer after /Length"); } - length = (long)value.value; + length = (long)value.Value; return true; case "Size": - if (value.type != PdfLexer.TokenType.Int) + if (value.Type != PdfLexer.TokenType.Int) { throw new PdfMetadataExtractorException("Expected integer after /Size"); } - size = (long)value.value; + size = (long)value.Value; return true; case "Prev": - if (value.type != PdfLexer.TokenType.Int) + if (value.Type != PdfLexer.TokenType.Int) { throw new PdfMetadataExtractorException("Expected offset after /Prev"); } - prev = (long)value.value; + prev = (long)value.Value; return true; case "Index": - if (value.type != PdfLexer.TokenType.ArrayStart) + if (value.Type != PdfLexer.TokenType.ArrayStart) { throw new PdfMetadataExtractorException("Expected array after /Index"); } @@ -1014,31 +991,31 @@ class PdfMetadataExtractor : IPdfMetadataExtractor { token = _lexer.NextToken(); - if (token.type == PdfLexer.TokenType.ArrayEnd) + if (token.Type == PdfLexer.TokenType.ArrayEnd) { break; } - else if (token.type != PdfLexer.TokenType.Int) + else if (token.Type != PdfLexer.TokenType.Int) { throw new PdfMetadataExtractorException("Expected integer in /Index array"); } - var first = (long)token.value; + var first = (long)token.Value; token = _lexer.NextToken(); - if (token.type != PdfLexer.TokenType.Int) + if (token.Type != PdfLexer.TokenType.Int) { throw new PdfMetadataExtractorException("Expected integer pair in /Index array"); } - var count = (long)token.value; + var count = (long)token.Value; sections.Enqueue(new XRefSection(first, count)); } return true; case "W": - if (value.type != PdfLexer.TokenType.ArrayStart) + if (value.Type != PdfLexer.TokenType.ArrayStart) { throw new PdfMetadataExtractorException("Expected array after /W"); } @@ -1049,17 +1026,17 @@ class PdfMetadataExtractor : IPdfMetadataExtractor { token = _lexer.NextToken(); - if (token.type != PdfLexer.TokenType.Int) + if (token.Type != PdfLexer.TokenType.Int) { throw new PdfMetadataExtractorException("Expected integer in /W array"); } - widths[i] = (long)token.value; + widths[i] = (long)token.Value; } token = _lexer.NextToken(); - if (token.type != PdfLexer.TokenType.ArrayEnd) + if (token.Type != PdfLexer.TokenType.ArrayEnd) { throw new PdfMetadataExtractorException("Unclosed array after /W"); } @@ -1071,12 +1048,12 @@ class PdfMetadataExtractor : IPdfMetadataExtractor return true; case "Filter": - if (value.type != PdfLexer.TokenType.Name) + if (value.Type != PdfLexer.TokenType.Name) { throw new PdfMetadataExtractorException("Expected name after /Filter"); } - if ((string)value.value != "FlateDecode") + if ((string)value.Value != "FlateDecode") { throw new PdfMetadataExtractorException("Unsupported filter, only FlateDecode is supported"); } @@ -1086,22 +1063,22 @@ class PdfMetadataExtractor : IPdfMetadataExtractor return true; case "Root": - if (value.type != PdfLexer.TokenType.ObjectRef) + if (value.Type != PdfLexer.TokenType.ObjectRef) { throw new PdfMetadataExtractorException("Expected object reference after /Root"); } - meta.root = (long)value.value; + meta.Root = (long)value.Value; return true; case "Info": - if (value.type != PdfLexer.TokenType.ObjectRef) + if (value.Type != PdfLexer.TokenType.ObjectRef) { throw new PdfMetadataExtractorException("Expected object reference after /Info"); } - meta.info = (long)value.value; + meta.Info = (long)value.Value; return true; @@ -1112,7 +1089,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor token = _lexer.NextToken(); - if (token.type != PdfLexer.TokenType.StreamStart) + if (token.Type != PdfLexer.TokenType.StreamStart) { throw new PdfMetadataExtractorException("Expected xref stream after dictionary"); } @@ -1133,7 +1110,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor Array.Resize(ref _objectOffsets, (int)size); } - for (var i = section.first; i < section.first + section.count; ++i) + for (var i = section.First; i < section.First + section.Count; ++i) { long type = 0; long offset = 0; @@ -1146,17 +1123,17 @@ class PdfMetadataExtractor : IPdfMetadataExtractor for (var j = 0; j < typeWidth; ++j) { - type = (type << 8) | (UInt16)stream.ReadByte(); + type = (type << 8) | (ushort)stream.ReadByte(); } for (var j = 0; j < offsetWidth; ++j) { - offset = (offset << 8) | (UInt16)stream.ReadByte(); + offset = (offset << 8) | (ushort)stream.ReadByte(); } for (var j = 0; j < generationWidth; ++j) { - generation = (generation << 8) | (UInt16)stream.ReadByte(); + generation = (generation << 8) | (ushort)stream.ReadByte(); } if (type == 1 && _objectOffsets[i] == 0) @@ -1176,22 +1153,22 @@ class PdfMetadataExtractor : IPdfMetadataExtractor private void PushMetadataRef(MetadataRef meta) { - if (metadataRef.Count > 0) + if (_metadataRef.Count > 0) { - if (meta.root == metadataRef.Peek().root) + if (meta.Root == _metadataRef.Peek().Root) { - meta.root = -1; + meta.Root = -1; } - if (meta.info == metadataRef.Peek().info) + if (meta.Info == _metadataRef.Peek().Info) { - meta.info = -1; + meta.Info = -1; } } - if (meta.root != -1 || meta.info != -1) + if (meta.Root != -1 || meta.Info != -1) { - metadataRef.Push(meta); + _metadataRef.Push(meta); } } @@ -1209,40 +1186,40 @@ class PdfMetadataExtractor : IPdfMetadataExtractor switch (key) { case "Root": - if (value.type != PdfLexer.TokenType.ObjectRef) + if (value.Type != PdfLexer.TokenType.ObjectRef) { throw new PdfMetadataExtractorException("Expected object reference after /Root"); } - meta.root = (long)value.value; + meta.Root = (long)value.Value; return true; case "Prev": - if (value.type != PdfLexer.TokenType.Int) + if (value.Type != PdfLexer.TokenType.Int) { throw new PdfMetadataExtractorException("Expected offset after /Prev"); } - prev = (long)value.value; + prev = (long)value.Value; return true; case "Info": - if (value.type != PdfLexer.TokenType.ObjectRef) + if (value.Type != PdfLexer.TokenType.ObjectRef) { throw new PdfMetadataExtractorException("Expected object reference after /Info"); } - meta.info = (long)value.value; + meta.Info = (long)value.Value; return true; case "XRefStm": // Prefer encoded xref stream over xref table - if (value.type != PdfLexer.TokenType.Int) + if (value.Type != PdfLexer.TokenType.Int) { throw new PdfMetadataExtractorException("Expected offset after /XRefStm"); } - xrefStm = (long)value.value; + xrefStm = (long)value.Value; return true; @@ -1272,14 +1249,14 @@ class PdfMetadataExtractor : IPdfMetadataExtractor // We read potential metadata sources in backwards historical order, so // we can overwrite to our heart's content - while (metadataRef.Count > 0) + while (_metadataRef.Count > 0) { - var meta = metadataRef.Pop(); + var meta = _metadataRef.Pop(); - _logger.LogTrace("DocumentCatalog for {Path}: {Root}, Info: {Info}", filename, meta.root, meta.info); + //_logger.LogTrace("DocumentCatalog for {Path}: {Root}, Info: {Info}", filename, meta.root, meta.info); - ReadMetadataFromInfo(meta.info); - ReadMetadataFromXML(MetadataObjInObjectCatalog(meta.root)); + ReadMetadataFromInfo(meta.Info); + ReadMetadataFromXml(MetadataObjInObjectCatalog(meta.Root)); } } @@ -1298,12 +1275,12 @@ class PdfMetadataExtractor : IPdfMetadataExtractor var token = _lexer.NextToken(); - if (token.type != PdfLexer.TokenType.ObjectStart) + if (token.Type != PdfLexer.TokenType.ObjectStart) { throw new PdfMetadataExtractorException("Expected object header"); } - Dictionary indirectObjects = new(); + Dictionary indirectObjects = []; ParseDictionary(delegate(string key, PdfLexer.Token value) { @@ -1317,16 +1294,16 @@ class PdfMetadataExtractor : IPdfMetadataExtractor case "Producer": case "CreationDate": case "ModDate": - if (value.type == PdfLexer.TokenType.ObjectRef) { - indirectObjects[key] = (long)value.value; + if (value.Type == PdfLexer.TokenType.ObjectRef) { + indirectObjects[key] = (long)value.Value; } - else if (value.type != PdfLexer.TokenType.String) + else if (value.Type != PdfLexer.TokenType.String) { throw new PdfMetadataExtractorException("Expected string value"); } else { - _metadata[key] = (string)value.value; + _metadata[key] = (string)value.Value; } return true; @@ -1343,17 +1320,17 @@ class PdfMetadataExtractor : IPdfMetadataExtractor token = _lexer.NextToken(); - if (token.type != PdfLexer.TokenType.ObjectStart) { + if (token.Type != PdfLexer.TokenType.ObjectStart) { throw new PdfMetadataExtractorException("Expected object here"); } token = _lexer.NextToken(); - if (token.type != PdfLexer.TokenType.String) { + if (token.Type != PdfLexer.TokenType.String) { throw new PdfMetadataExtractorException("Expected string"); } - _metadata[key] = (string)token.value; + _metadata[key] = (string) token.Value; } } @@ -1371,7 +1348,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor var token = _lexer.NextToken(); - if (token.type != PdfLexer.TokenType.ObjectStart) + if (token.Type != PdfLexer.TokenType.ObjectStart) { throw new PdfMetadataExtractorException("Expected object header"); } @@ -1382,12 +1359,12 @@ class PdfMetadataExtractor : IPdfMetadataExtractor { switch (key) { case "Metadata": - if (value.type != PdfLexer.TokenType.ObjectRef) + if (value.Type != PdfLexer.TokenType.ObjectRef) { throw new PdfMetadataExtractorException("Expected object number after /Metadata"); } - meta = (long)value.value; + meta = (long)value.Value; return true; @@ -1403,13 +1380,13 @@ class PdfMetadataExtractor : IPdfMetadataExtractor // See XMP specification: https://developer.adobe.com/xmp/docs/XMPSpecifications/ // and Dublin Core: https://www.dublincore.org/specifications/dublin-core/ - private string? GetTextFromXmlNode(XmlDocument doc, XmlNamespaceManager ns, string path) + private static string? GetTextFromXmlNode(XmlDocument doc, XmlNamespaceManager ns, string path) { return (doc.DocumentElement?.SelectSingleNode(path + "//rdf:li", ns) ?? doc.DocumentElement?.SelectSingleNode(path, ns))?.InnerText; } - private string? GetListFromXmlNode(XmlDocument doc, XmlNamespaceManager ns, string path) + private static string? GetListFromXmlNode(XmlDocument doc, XmlNamespaceManager ns, string path) { var nodes = doc.DocumentElement?.SelectNodes(path + "//rdf:li", ns); @@ -1421,7 +1398,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor { if (list.Length > 0) { - list.Append(","); + list.Append(','); } list.Append(n.InnerText); @@ -1437,7 +1414,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor _metadata[key] = value; } - private void ReadMetadataFromXML(long meta) + private void ReadMetadataFromXml(long meta) { if (meta < 1 || meta >= _objectOffsets.Length || _objectOffsets[meta] == 0) return; @@ -1446,7 +1423,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor var token = _lexer.NextToken(); - if (token.type != PdfLexer.TokenType.ObjectStart) + if (token.Type != PdfLexer.TokenType.ObjectStart) { throw new PdfMetadataExtractorException("Expected object header"); } @@ -1460,7 +1437,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor { switch (key) { case "Type": - if (value.type != PdfLexer.TokenType.Name || (string)value.value != "Metadata") + if (value.Type != PdfLexer.TokenType.Name || (string)value.Value != "Metadata") { throw new PdfMetadataExtractorException("Expected /Type to be /Metadata"); } @@ -1468,7 +1445,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor return true; case "Subtype": - if (value.type != PdfLexer.TokenType.Name || (string)value.value != "XML") + if (value.Type != PdfLexer.TokenType.Name || (string)value.Value != "XML") { throw new PdfMetadataExtractorException("Expected /Subtype to be /XML"); } @@ -1476,22 +1453,22 @@ class PdfMetadataExtractor : IPdfMetadataExtractor return true; case "Length": - if (value.type != PdfLexer.TokenType.Int) + if (value.Type != PdfLexer.TokenType.Int) { throw new PdfMetadataExtractorException("Expected integer after /Length"); } - length = (long)value.value; + length = (long)value.Value; return true; case "Filter": - if (value.type != PdfLexer.TokenType.Name) + if (value.Type != PdfLexer.TokenType.Name) { throw new PdfMetadataExtractorException("Expected name after /Filter"); } - if ((string)value.value != "FlateDecode") + if ((string)value.Value != "FlateDecode") { throw new PdfMetadataExtractorException("Unsupported filter, only FlateDecode is supported"); } @@ -1507,7 +1484,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor token = _lexer.NextToken(); - if (token.type != PdfLexer.TokenType.StreamStart) + if (token.Type != PdfLexer.TokenType.StreamStart) { throw new PdfMetadataExtractorException("Expected xref stream after dictionary"); } @@ -1567,7 +1544,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor { var token = _lexer.NextToken(); - if (token.type != PdfLexer.TokenType.DictionaryStart) + if (token.Type != PdfLexer.TokenType.DictionaryStart) { throw new PdfMetadataExtractorException("Expected dictionary"); } @@ -1576,15 +1553,16 @@ class PdfMetadataExtractor : IPdfMetadataExtractor { token = _lexer.NextToken(); - if (token.type == PdfLexer.TokenType.DictionaryEnd) + if (token.Type == PdfLexer.TokenType.DictionaryEnd) { return; } - else if (token.type == PdfLexer.TokenType.Name) + + if (token.Type == PdfLexer.TokenType.Name) { var value = _lexer.NextToken(); - if (!handler((string)token.value, value)) { + if (!handler((string)token.Value, value)) { SkipValue(value); } } @@ -1599,7 +1577,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor { var token = existingToken ?? _lexer.NextToken(); - switch (token.type) + switch (token.Type) { case PdfLexer.TokenType.Bool: case PdfLexer.TokenType.Int: @@ -1608,17 +1586,16 @@ class PdfMetadataExtractor : IPdfMetadataExtractor case PdfLexer.TokenType.String: case PdfLexer.TokenType.ObjectRef: break; - case PdfLexer.TokenType.ArrayStart: + { SkipArray(); - break; - + } case PdfLexer.TokenType.DictionaryStart: + { SkipDictionary(); - break; - + } default: throw new PdfMetadataExtractorException("Unexpected token in SkipValue"); } @@ -1630,7 +1607,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor { var token = _lexer.NextToken(); - if (token.type == PdfLexer.TokenType.ArrayEnd) + if (token.Type == PdfLexer.TokenType.ArrayEnd) { break; } @@ -1645,11 +1622,11 @@ class PdfMetadataExtractor : IPdfMetadataExtractor { var token = _lexer.NextToken(); - if (token.type == PdfLexer.TokenType.DictionaryEnd) + if (token.Type == PdfLexer.TokenType.DictionaryEnd) { break; } - else if (token.type != PdfLexer.TokenType.Name) + if (token.Type != PdfLexer.TokenType.Name) { throw new PdfMetadataExtractorException("Expected name in dictionary"); } diff --git a/API/Program.cs b/API/Program.cs index fde52a2f3..ff6b67ef2 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -58,7 +58,7 @@ public class Program } Configuration.KavitaPlusApiUrl = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development - ? "http://localhost:5020" : "https://plus-next.kavitareader.com"; + ? "http://localhost:5020" : "https://plus.kavitareader.com"; try { diff --git a/API/Services/DirectoryService.cs b/API/Services/DirectoryService.cs index ed9e3431b..ae9383c7b 100644 --- a/API/Services/DirectoryService.cs +++ b/API/Services/DirectoryService.cs @@ -39,6 +39,10 @@ public interface IDirectoryService /// string BookmarkDirectory { get; } /// + /// Used for random files needed, like images to check against, list of countries, etc + /// + string AssetsDirectory { get; } + /// /// Lists out top-level folders for a given directory. Filters out System and Hidden folders. /// /// Absolute path of directory to scan. @@ -87,6 +91,7 @@ public class DirectoryService : IDirectoryService public string TempDirectory { get; } public string ConfigDirectory { get; } public string BookmarkDirectory { get; } + public string AssetsDirectory { get; } public string SiteThemeDirectory { get; } public string FaviconDirectory { get; } public string LocalizationDirectory { get; } @@ -120,6 +125,8 @@ public class DirectoryService : IDirectoryService ExistOrCreate(TempDirectory); BookmarkDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "bookmarks"); ExistOrCreate(BookmarkDirectory); + AssetsDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "Assets"); + ExistOrCreate(AssetsDirectory); SiteThemeDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "themes"); ExistOrCreate(SiteThemeDirectory); FaviconDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "favicons"); diff --git a/API/Services/ImageService.cs b/API/Services/ImageService.cs index fc39c426d..0255b785d 100644 --- a/API/Services/ImageService.cs +++ b/API/Services/ImageService.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Drawing; using System.IO; using System.Linq; using System.Numerics; @@ -11,9 +10,11 @@ using API.Entities.Interfaces; using API.Extensions; using Microsoft.Extensions.Logging; using NetVips; +using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; using SixLabors.ImageSharp.Processing.Processors.Quantization; +using Color = System.Drawing.Color; using Image = NetVips.Image; namespace API.Services; @@ -748,6 +749,7 @@ public class ImageService : IImageService entity.SecondaryColor = colors.Secondary; } + public static Color HexToRgb(string? hex) { if (string.IsNullOrEmpty(hex)) throw new ArgumentException("Hex cannot be null"); diff --git a/API/Services/Plus/ExternalMetadataService.cs b/API/Services/Plus/ExternalMetadataService.cs index 1781ba4c6..47cc6cd39 100644 --- a/API/Services/Plus/ExternalMetadataService.cs +++ b/API/Services/Plus/ExternalMetadataService.cs @@ -752,7 +752,7 @@ public class ExternalMetadataService : IExternalMetadataService _unitOfWork.SeriesRepository.Update(series); await _unitOfWork.CommitAsync(); - await DownloadAndSetCovers(upstreamArtists); + await DownloadAndSetPersonCovers(upstreamArtists); return true; } @@ -809,7 +809,7 @@ public class ExternalMetadataService : IExternalMetadataService _unitOfWork.SeriesRepository.Update(series); await _unitOfWork.CommitAsync(); - await DownloadAndSetCovers(upstreamWriters); + await DownloadAndSetPersonCovers(upstreamWriters); return true; } @@ -1058,7 +1058,7 @@ public class ExternalMetadataService : IExternalMetadataService { try { - await _coverDbService.SetSeriesCoverByUrl(series, coverUrl, false); + await _coverDbService.SetSeriesCoverByUrl(series, coverUrl, false, true); } catch (Exception ex) { @@ -1066,7 +1066,7 @@ public class ExternalMetadataService : IExternalMetadataService } } - private async Task DownloadAndSetCovers(List people) + private async Task DownloadAndSetPersonCovers(List people) { foreach (var staff in people) { @@ -1075,7 +1075,7 @@ public class ExternalMetadataService : IExternalMetadataService var person = await _unitOfWork.PersonRepository.GetPersonByAniListId(aniListId.Value); if (person != null && !string.IsNullOrEmpty(staff.ImageUrl) && string.IsNullOrEmpty(person.CoverImage)) { - await _coverDbService.SetPersonCoverByUrl(person, staff.ImageUrl, false); + await _coverDbService.SetPersonCoverByUrl(person, staff.ImageUrl, false, true); } } } @@ -1326,11 +1326,15 @@ public class ExternalMetadataService : IExternalMetadataService } try { - return await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-by-ids") + var ret = await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-by-ids") .WithKavitaPlusHeaders(license) .PostJsonAsync(payload) .ReceiveJson(); + ret.Summary = StringHelper.SquashBreaklines(ret.Summary); + + return ret; + } catch (Exception e) { diff --git a/API/Services/Tasks/CleanupService.cs b/API/Services/Tasks/CleanupService.cs index 4faf59e6c..c4ad40fe8 100644 --- a/API/Services/Tasks/CleanupService.cs +++ b/API/Services/Tasks/CleanupService.cs @@ -33,6 +33,8 @@ public interface ICleanupService /// /// Task CleanupWantToRead(); + + Task ConsolidateProgress(); } /// /// Cleans up after operations on reoccurring basis @@ -74,13 +76,21 @@ public class CleanupService : ICleanupService _logger.LogInformation("Starting Cleanup"); await SendProgress(0F, "Starting cleanup"); + _logger.LogInformation("Cleaning temp directory"); _directoryService.ClearDirectory(_directoryService.TempDirectory); + await SendProgress(0.1F, "Cleaning temp directory"); CleanupCacheAndTempDirectories(); + await SendProgress(0.25F, "Cleaning old database backups"); _logger.LogInformation("Cleaning old database backups"); await CleanupBackups(); + + await SendProgress(0.35F, "Consolidating Progress Events"); + _logger.LogInformation("Consolidating Progress Events"); + await ConsolidateProgress(); + await SendProgress(0.50F, "Cleaning deleted cover images"); _logger.LogInformation("Cleaning deleted cover images"); await DeleteSeriesCoverImages(); @@ -226,6 +236,61 @@ public class CleanupService : ICleanupService _logger.LogInformation("Finished cleanup of Database backups at {Time}", DateTime.Now); } + /// + /// Find any progress events that have duplicate, find the highest page read event, then copy over information from that and delete others, to leave one. + /// + public async Task ConsolidateProgress() + { + // AppUserProgress + var allProgress = await _unitOfWork.AppUserProgressRepository.GetAllProgress(); + + // Group by the unique identifiers that would make a progress entry unique + var duplicateGroups = allProgress + .GroupBy(p => new + { + p.AppUserId, + p.ChapterId, + }) + .Where(g => g.Count() > 1); + + foreach (var group in duplicateGroups) + { + // Find the entry with the highest pages read + var highestProgress = group + .OrderByDescending(p => p.PagesRead) + .ThenByDescending(p => p.LastModifiedUtc) + .First(); + + // Get the duplicate entries to remove (all except the highest progress) + var duplicatesToRemove = group + .Where(p => p.Id != highestProgress.Id) + .ToList(); + + // Copy over any non-null BookScrollId if the highest progress entry doesn't have one + if (string.IsNullOrEmpty(highestProgress.BookScrollId)) + { + var firstValidScrollId = duplicatesToRemove + .FirstOrDefault(p => !string.IsNullOrEmpty(p.BookScrollId)) + ?.BookScrollId; + + if (firstValidScrollId != null) + { + highestProgress.BookScrollId = firstValidScrollId; + highestProgress.MarkModified(); + } + } + + // Remove the duplicates + foreach (var duplicate in duplicatesToRemove) + { + _unitOfWork.AppUserProgressRepository.Remove(duplicate); + } + } + + // Save changes + await _unitOfWork.CommitAsync(); + } + public async Task CleanupLogs() { _logger.LogInformation("Performing cleanup of logs directory"); diff --git a/API/Services/Tasks/Metadata/CoverDbService.cs b/API/Services/Tasks/Metadata/CoverDbService.cs index ced75565d..a75c17b76 100644 --- a/API/Services/Tasks/Metadata/CoverDbService.cs +++ b/API/Services/Tasks/Metadata/CoverDbService.cs @@ -19,6 +19,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using NetVips; + namespace API.Services.Tasks.Metadata; #nullable enable @@ -28,8 +29,8 @@ public interface ICoverDbService Task DownloadPublisherImageAsync(string publisherName, EncodeFormat encodeFormat); Task DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat); Task DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat, string url); - Task SetPersonCoverByUrl(Person person, string url, bool fromBase64 = true); - Task SetSeriesCoverByUrl(Series series, string url, bool fromBase64 = true); + Task SetPersonCoverByUrl(Person person, string url, bool fromBase64 = true, bool checkNoImagePlaceholder = false); + Task SetSeriesCoverByUrl(Series series, string url, bool fromBase64 = true, bool chooseBetterImage = false); } @@ -461,13 +462,39 @@ public class CoverDbService : ICoverDbService return null; } - - public async Task SetPersonCoverByUrl(Person person, string url, bool fromBase64 = true) + /// + /// + /// + /// + /// + /// + /// Will check against all known null image placeholders to avoid writing it + public async Task SetPersonCoverByUrl(Person person, string url, bool fromBase64 = true, bool checkNoImagePlaceholder = false) { + // TODO: Refactor checkNoImagePlaceholder bool to an action that evaluates how to process Image if (!string.IsNullOrEmpty(url)) { var filePath = await CreateThumbnail(url, $"{ImageService.GetPersonFormat(person.Id)}", fromBase64); + // Additional check to see if downloaded image is similar and we have a higher resolution + if (checkNoImagePlaceholder) + { + var matchRating = Path.Join(_directoryService.AssetsDirectory, "anilist-no-image-placeholder.jpg").GetSimilarity(Path.Join(_directoryService.CoverImageDirectory, filePath))!; + + if (matchRating >= 0.9f) + { + if (string.IsNullOrEmpty(person.CoverImage)) + { + filePath = null; + } + else + { + filePath = Path.GetFileName(Path.Join(_directoryService.CoverImageDirectory, person.CoverImage)); + } + + } + } + if (!string.IsNullOrEmpty(filePath)) { person.CoverImage = filePath; @@ -498,7 +525,8 @@ public class CoverDbService : ICoverDbService /// /// /// - public async Task SetSeriesCoverByUrl(Series series, string url, bool fromBase64 = true) + /// If images are similar, will choose the higher quality image + public async Task SetSeriesCoverByUrl(Series series, string url, bool fromBase64 = true, bool chooseBetterImage = false) { if (!string.IsNullOrEmpty(url)) { @@ -506,6 +534,13 @@ public class CoverDbService : ICoverDbService if (!string.IsNullOrEmpty(filePath)) { + // Additional check to see if downloaded image is similar and we have a higher resolution + if (chooseBetterImage) + { + var betterImage = Path.Join(_directoryService.CoverImageDirectory, series.CoverImage).GetBetterImage(Path.Join(_directoryService.CoverImageDirectory, filePath))!; + filePath = Path.GetFileName(betterImage); + } + series.CoverImage = filePath; series.CoverImageLocked = true; _imageService.UpdateColorScape(series); @@ -540,6 +575,6 @@ public class CoverDbService : ICoverDbService filename, encodeFormat, coverImageSize.GetDimensions().Width); } - return await DownloadImageFromUrl(filename, encodeFormat, url); + return await DownloadImageFromUrl(filename, encodeFormat, url); } } diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index 12521a039..d22fe4e68 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -515,7 +515,7 @@ public class ScannerService : IScannerService var shouldUseLibraryScan = !(await _unitOfWork.LibraryRepository.DoAnySeriesFoldersMatch(libraryFolderPaths)); if (!shouldUseLibraryScan) { - _logger.LogError("[ScannerService] Library {LibraryName} consists of one or more Series folders, using series scan", library.Name); + _logger.LogError("[ScannerService] Library {LibraryName} consists of one or more Series folders as a library root, using series scan", library.Name); } diff --git a/API/Services/Tasks/VersionUpdaterService.cs b/API/Services/Tasks/VersionUpdaterService.cs index 88d1096b4..a52fec020 100644 --- a/API/Services/Tasks/VersionUpdaterService.cs +++ b/API/Services/Tasks/VersionUpdaterService.cs @@ -67,7 +67,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService [GeneratedRegex(@"^\n*(.*?)\n+#{1,2}\s", RegexOptions.Singleline)] private static partial Regex BlogPartRegex(); - private static string _cacheFilePath; + private readonly string _cacheFilePath; private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(1); public VersionUpdaterService(ILogger logger, IEventHub eventHub, IDirectoryService directoryService) @@ -131,6 +131,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService Theme = sections.TryGetValue("Theme", out var theme) ? theme : [], Developer = sections.TryGetValue("Developer", out var developer) ? developer : [], Api = sections.TryGetValue("Api", out var api) ? api : [], + FeatureRequests = sections.TryGetValue("Feature Requests", out var frs) ? frs : [], BlogPart = _markdown.Transform(blogPart.Trim()), UpdateBody = _markdown.Transform(prInfo.Body.Trim()) }; @@ -305,7 +306,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService return updateDtos; } - private static async Task?> TryGetCachedReleases() + private async Task?> TryGetCachedReleases() { if (!File.Exists(_cacheFilePath)) return null; @@ -376,6 +377,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService Theme = parsedSections.TryGetValue("Theme", out var theme) ? theme : [], Developer = parsedSections.TryGetValue("Developer", out var developer) ? developer : [], Api = parsedSections.TryGetValue("Api", out var api) ? api : [], + FeatureRequests = parsedSections.TryGetValue("Feature Requests", out var frs) ? frs : [], BlogPart = blogPart }; } @@ -492,7 +494,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService return item; } - sealed class PullRequestInfo + private sealed class PullRequestInfo { public required string Title { get; init; } public required string Body { get; init; } @@ -501,25 +503,25 @@ public partial class VersionUpdaterService : IVersionUpdaterService public required int Number { get; init; } } - sealed class CommitInfo + private sealed class CommitInfo { public required string Sha { get; init; } public required CommitDetail Commit { get; init; } public required string Html_Url { get; init; } } - sealed class CommitDetail + private sealed class CommitDetail { public required string Message { get; init; } public required CommitAuthor Author { get; init; } } - sealed class CommitAuthor + private sealed class CommitAuthor { public required string Date { get; init; } } - sealed class NightlyInfo + private sealed class NightlyInfo { public required string Version { get; init; } public required int PrNumber { get; init; } diff --git a/API/Services/TokenService.cs b/API/Services/TokenService.cs index 7e3c3c0dc..721eb0481 100644 --- a/API/Services/TokenService.cs +++ b/API/Services/TokenService.cs @@ -4,6 +4,7 @@ using System.IdentityModel.Tokens.Jwt; using System.Linq; using System.Security.Claims; using System.Text; +using System.Threading; using System.Threading.Tasks; using API.Data; using API.DTOs.Account; @@ -36,6 +37,7 @@ public class TokenService : ITokenService private readonly IUnitOfWork _unitOfWork; private readonly SymmetricSecurityKey _key; private const string RefreshTokenName = "RefreshToken"; + private static readonly SemaphoreSlim _refreshTokenLock = new SemaphoreSlim(1, 1); public TokenService(IConfiguration config, UserManager userManager, ILogger logger, IUnitOfWork unitOfWork) { @@ -81,6 +83,8 @@ public class TokenService : ITokenService public async Task ValidateRefreshToken(TokenRequestDto request) { + await _refreshTokenLock.WaitAsync(); + try { var tokenHandler = new JwtSecurityTokenHandler(); @@ -91,6 +95,7 @@ public class TokenService : ITokenService _logger.LogDebug("[RefreshToken] failed to validate due to not finding user in RefreshToken"); return null; } + var user = await _userManager.FindByNameAsync(username); if (user == null) { @@ -98,13 +103,19 @@ public class TokenService : ITokenService return null; } - var validated = await _userManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, RefreshTokenName, request.RefreshToken); + var validated = await _userManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, + RefreshTokenName, request.RefreshToken); if (!validated && tokenContent.ValidTo <= DateTime.UtcNow.Add(TimeSpan.FromHours(1))) { _logger.LogDebug("[RefreshToken] failed to validate due to invalid refresh token"); return null; } + // Remove the old refresh token first + await _userManager.RemoveAuthenticationTokenAsync(user, + TokenOptions.DefaultProvider, + RefreshTokenName); + try { user.UpdateLastActive(); @@ -121,7 +132,8 @@ public class TokenService : ITokenService Token = await CreateToken(user), RefreshToken = await CreateRefreshToken(user) }; - } catch (SecurityTokenExpiredException ex) + } + catch (SecurityTokenExpiredException ex) { // Handle expired token _logger.LogError(ex, "Failed to validate refresh token"); @@ -133,6 +145,10 @@ public class TokenService : ITokenService _logger.LogError(ex, "Failed to validate refresh token"); return null; } + finally + { + _refreshTokenLock.Release(); + } } public async Task GetJwtFromUser(AppUser user) diff --git a/UI/Web/src/_card-item-common.scss b/UI/Web/src/_card-item-common.scss index f27a8768b..1c6f916f3 100644 --- a/UI/Web/src/_card-item-common.scss +++ b/UI/Web/src/_card-item-common.scss @@ -163,11 +163,15 @@ $image-width: 160px; align-items: center; padding: 0 5px; + :first-child { + min-width: 22px; + } + .card-title { font-size: 0.8rem; margin: 0; text-align: center; - max-width: 98px; + max-width: 90px; a { overflow: hidden; diff --git a/UI/Web/src/app/_models/events/update-version-event.ts b/UI/Web/src/app/_models/events/update-version-event.ts index a25f528f0..63661e5e5 100644 --- a/UI/Web/src/app/_models/events/update-version-event.ts +++ b/UI/Web/src/app/_models/events/update-version-event.ts @@ -17,6 +17,7 @@ export interface UpdateVersionEvent { theme: Array; developer: Array; api: Array; + featureRequests: Array; /** * The part above the changelog part */ diff --git a/UI/Web/src/app/_services/theme.service.ts b/UI/Web/src/app/_services/theme.service.ts index 95293baea..3e186f8ac 100644 --- a/UI/Web/src/app/_services/theme.service.ts +++ b/UI/Web/src/app/_services/theme.service.ts @@ -44,6 +44,9 @@ export class ThemeService { private themesSource = new ReplaySubject(1); public themes$ = this.themesSource.asObservable(); + + private darkModeSource = new ReplaySubject(1); + public isDarkMode$ = this.darkModeSource.asObservable(); /** * Maintain a cache of themes. SignalR will inform us if we need to refresh cache @@ -237,9 +240,11 @@ export class ThemeService { } this.currentThemeSource.next(theme); + this.darkModeSource.next(this.isDarkTheme()); }); } else { this.currentThemeSource.next(theme); + this.darkModeSource.next(this.isDarkTheme()); } } else { // Only time themes isn't already loaded is on first load diff --git a/UI/Web/src/app/_services/version.service.ts b/UI/Web/src/app/_services/version.service.ts index 45331fad2..e16a18d1f 100644 --- a/UI/Web/src/app/_services/version.service.ts +++ b/UI/Web/src/app/_services/version.service.ts @@ -76,7 +76,7 @@ export class VersionService implements OnDestroy{ this.modalOpen = true; this.serverService.getChangelog(1).subscribe(changelog => { - const ref = this.modalService.open(NewUpdateModalComponent, {size: 'lg'}); + const ref = this.modalService.open(NewUpdateModalComponent, {size: 'lg', keyboard: false}); ref.componentInstance.version = version; ref.componentInstance.update = changelog[0]; diff --git a/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.html b/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.html index 6e36c6e88..c34a3d888 100644 --- a/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.html +++ b/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.html @@ -32,7 +32,7 @@
-
+
@@ -50,8 +50,7 @@ @if (!formGroup.get('dontMatch')?.value) { @for(item of matches; track item.series.name) { - -
+ } @empty { @if (!isLoading) { {{t('no-results')}} diff --git a/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.scss b/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.scss index e69de29bb..d3a1cb9a9 100644 --- a/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.scss +++ b/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.scss @@ -0,0 +1,3 @@ +.setting-section-break { + margin: 0 !important; +} \ No newline at end of file diff --git a/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.ts b/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.ts index 77166a22a..be670684e 100644 --- a/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.ts +++ b/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.ts @@ -10,11 +10,14 @@ import {ExternalSeriesMatch} from "../../_models/series-detail/external-series-m import {ToastrService} from "ngx-toastr"; import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component"; import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component"; +import { ThemeService } from 'src/app/_services/theme.service'; +import { AsyncPipe } from '@angular/common'; @Component({ selector: 'app-match-series-modal', standalone: true, imports: [ + AsyncPipe, TranslocoDirective, MatchSeriesResultItemComponent, LoadingComponent, @@ -31,6 +34,7 @@ export class MatchSeriesModalComponent implements OnInit { private readonly seriesService = inject(SeriesService); private readonly modalService = inject(NgbActiveModal); private readonly toastr = inject(ToastrService); + protected readonly themeService = inject(ThemeService); @Input({required: true}) series!: Series; diff --git a/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.html b/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.html index a8ac15b3a..1da736e18 100644 --- a/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.html +++ b/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.html @@ -1,13 +1,13 @@ - -
-
+
+
+
@if (item.series.coverUrl) { - + }
-
{{item.series.name}}
+
{{item.series.name}} ({{item.matchRating | translocoPercent}})
@for(synm of item.series.synonyms; track synm; let last = $last) { {{synm}} @@ -19,6 +19,7 @@ @if (item.series.summary) { }
@@ -30,8 +31,7 @@ {{t('updating-metadata-status')}}
} @else { -
- {{t('details')}} +
@if ((item.series.volumes || 0) > 0 || (item.series.chapters || 0) > 0) { {{t('volume-count', {num: item.series.volumes})}} {{t('chapter-count', {num: item.series.chapters})}} @@ -40,11 +40,8 @@ } {{item.series.plusMediaFormat | plusMediaFormat}} - ({{item.matchRating | translocoPercent}})
} - - - - + +
diff --git a/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.scss b/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.scss index e69de29bb..5df806397 100644 --- a/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.scss +++ b/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.scss @@ -0,0 +1,33 @@ +.search-result { + img { + max-width: 100px; + min-width: 100px; + } +} +.title { + font-size: 1.2rem; + font-weight: bold; + margin: 0; + padding: 0; +} + +.match-item-container { + &.dark { + background-color: var(--elevation-layer6-dark); + } + + &.light { + background-color: var(--elevation-layer6); + } + border-radius: 15px; + + &:hover { + &.dark { + background-color: var(--elevation-layer11-dark); + } + + &.light { + background-color: var(--elevation-layer11); + } + } +} \ No newline at end of file diff --git a/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.ts b/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.ts index 4bb02f72e..a8126dabc 100644 --- a/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.ts +++ b/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.ts @@ -37,6 +37,7 @@ export class MatchSeriesResultItemComponent { private readonly cdRef = inject(ChangeDetectorRef); @Input({required: true}) item!: ExternalSeriesMatch; + @Input({required: true}) isDarkMode = true; @Output() selected: EventEmitter = new EventEmitter(); isSelected = false; diff --git a/UI/Web/src/app/admin/manage-metadata-settings/manage-metadata-settings.component.html b/UI/Web/src/app/admin/manage-metadata-settings/manage-metadata-settings.component.html index 5277e95bc..dd55d8069 100644 --- a/UI/Web/src/app/admin/manage-metadata-settings/manage-metadata-settings.component.html +++ b/UI/Web/src/app/admin/manage-metadata-settings/manage-metadata-settings.component.html @@ -165,7 +165,7 @@ @if(settingsForm.get('blacklist'); as formControl) { - @let val = (formControl.value || '').split(','); + @let val = breakTags(formControl.value); @for(opt of val; track opt) { {{opt.trim()}} @@ -184,7 +184,7 @@ @if(settingsForm.get('whitelist'); as formControl) { - @let val = (formControl.value || '').split(','); + @let val = breakTags(formControl.value); @for(opt of val; track opt) { {{opt.trim()}} diff --git a/UI/Web/src/app/admin/manage-metadata-settings/manage-metadata-settings.component.ts b/UI/Web/src/app/admin/manage-metadata-settings/manage-metadata-settings.component.ts index b1719fdf5..589107998 100644 --- a/UI/Web/src/app/admin/manage-metadata-settings/manage-metadata-settings.component.ts +++ b/UI/Web/src/app/admin/manage-metadata-settings/manage-metadata-settings.component.ts @@ -149,6 +149,15 @@ export class ManageMetadataSettingsComponent implements OnInit { } + breakTags(csString: string) { + if (csString) { + return csString.split(','); + } + + return []; + } + + packData(withFieldMappings: boolean = true) { const model = this.settingsForm.value; diff --git a/UI/Web/src/app/announcements/_components/changelog-update-item/changelog-update-item.component.html b/UI/Web/src/app/announcements/_components/changelog-update-item/changelog-update-item.component.html index 9367f9388..8d95bc84b 100644 --- a/UI/Web/src/app/announcements/_components/changelog-update-item/changelog-update-item.component.html +++ b/UI/Web/src/app/announcements/_components/changelog-update-item/changelog-update-item.component.html @@ -15,6 +15,7 @@ +
@if (showExtras) { diff --git a/UI/Web/src/app/announcements/_components/changelog/changelog.component.ts b/UI/Web/src/app/announcements/_components/changelog/changelog.component.ts index c625ce1d4..0a8b3a908 100644 --- a/UI/Web/src/app/announcements/_components/changelog/changelog.component.ts +++ b/UI/Web/src/app/announcements/_components/changelog/changelog.component.ts @@ -33,7 +33,7 @@ export class ChangelogComponent implements OnInit { isLoading: boolean = true; ngOnInit(): void { - this.serverService.getChangelog(10).subscribe(updates => { + this.serverService.getChangelog(30).subscribe(updates => { this.updates = updates; this.isLoading = false; this.cdRef.markForCheck(); diff --git a/UI/Web/src/app/announcements/_components/new-update-modal/new-update-modal.component.html b/UI/Web/src/app/announcements/_components/new-update-modal/new-update-modal.component.html index 3aa8b8131..d5842315b 100644 --- a/UI/Web/src/app/announcements/_components/new-update-modal/new-update-modal.component.html +++ b/UI/Web/src/app/announcements/_components/new-update-modal/new-update-modal.component.html @@ -1,7 +1,6 @@ diff --git a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html index d29b6f44b..a8a549f01 100644 --- a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html +++ b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html @@ -649,7 +649,7 @@

{{t('volumes-title')}}

@if (isLoadingVolumes) { -
+
{{t('loading')}}
} @else { diff --git a/UI/Web/src/app/manga-reader/_components/canvas-renderer/canvas-renderer.component.ts b/UI/Web/src/app/manga-reader/_components/canvas-renderer/canvas-renderer.component.ts index 6065a4c8e..7257cd55a 100644 --- a/UI/Web/src/app/manga-reader/_components/canvas-renderer/canvas-renderer.component.ts +++ b/UI/Web/src/app/manga-reader/_components/canvas-renderer/canvas-renderer.component.ts @@ -18,7 +18,7 @@ import { LayoutMode } from '../../_models/layout-mode'; import { FITTING_OPTION, PAGING_DIRECTION, SPLIT_PAGE_PART } from '../../_models/reader-enums'; import { ReaderSetting } from '../../_models/reader-setting'; import { ImageRenderer } from '../../_models/renderer'; -import { ManagaReaderService } from '../../_service/managa-reader.service'; +import { MangaReaderService } from '../../_service/manga-reader.service'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import { SafeStylePipe } from '../../../_pipes/safe-style.pipe'; import { NgClass, AsyncPipe } from '@angular/common'; @@ -67,7 +67,7 @@ export class CanvasRendererComponent implements OnInit, AfterViewInit, ImageRend - constructor(private readonly cdRef: ChangeDetectorRef, private mangaReaderService: ManagaReaderService, private readerService: ReaderService) { } + constructor(private readonly cdRef: ChangeDetectorRef, private mangaReaderService: MangaReaderService, private readerService: ReaderService) { } ngOnInit(): void { this.readerSettings$.pipe(takeUntilDestroyed(this.destroyRef), tap((value: ReaderSetting) => { diff --git a/UI/Web/src/app/manga-reader/_components/double-renderer-no-cover/double-no-cover-renderer.component.ts b/UI/Web/src/app/manga-reader/_components/double-renderer-no-cover/double-no-cover-renderer.component.ts index 3978111bd..fc619dbd4 100644 --- a/UI/Web/src/app/manga-reader/_components/double-renderer-no-cover/double-no-cover-renderer.component.ts +++ b/UI/Web/src/app/manga-reader/_components/double-renderer-no-cover/double-no-cover-renderer.component.ts @@ -18,7 +18,7 @@ import { LayoutMode } from '../../_models/layout-mode'; import { FITTING_OPTION, PAGING_DIRECTION } from '../../_models/reader-enums'; import { ReaderSetting } from '../../_models/reader-setting'; import { DEBUG_MODES } from '../../_models/renderer'; -import { ManagaReaderService } from '../../_service/managa-reader.service'; +import { MangaReaderService } from '../../_service/manga-reader.service'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import { SafeStylePipe } from '../../../_pipes/safe-style.pipe'; @@ -82,7 +82,7 @@ export class DoubleNoCoverRendererComponent implements OnInit { - constructor(private readonly cdRef: ChangeDetectorRef, public mangaReaderService: ManagaReaderService, + constructor(private readonly cdRef: ChangeDetectorRef, public mangaReaderService: MangaReaderService, @Inject(DOCUMENT) private document: Document, public readerService: ReaderService) { } ngOnInit(): void { diff --git a/UI/Web/src/app/manga-reader/_components/double-renderer/double-renderer.component.ts b/UI/Web/src/app/manga-reader/_components/double-renderer/double-renderer.component.ts index 8a2f04fc0..8a495ca43 100644 --- a/UI/Web/src/app/manga-reader/_components/double-renderer/double-renderer.component.ts +++ b/UI/Web/src/app/manga-reader/_components/double-renderer/double-renderer.component.ts @@ -18,7 +18,7 @@ import { LayoutMode } from '../../_models/layout-mode'; import { FITTING_OPTION, PAGING_DIRECTION } from '../../_models/reader-enums'; import { ReaderSetting } from '../../_models/reader-setting'; import { DEBUG_MODES, ImageRenderer } from '../../_models/renderer'; -import { ManagaReaderService } from '../../_service/managa-reader.service'; +import { MangaReaderService } from '../../_service/manga-reader.service'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import { SafeStylePipe } from '../../../_pipes/safe-style.pipe'; @@ -80,7 +80,7 @@ export class DoubleRendererComponent implements OnInit, ImageRenderer { protected readonly LayoutMode = LayoutMode; - constructor(private readonly cdRef: ChangeDetectorRef, public mangaReaderService: ManagaReaderService, + constructor(private readonly cdRef: ChangeDetectorRef, public mangaReaderService: MangaReaderService, @Inject(DOCUMENT) private document: Document, public readerService: ReaderService) { } ngOnInit(): void { diff --git a/UI/Web/src/app/manga-reader/_components/double-reverse-renderer/double-reverse-renderer.component.ts b/UI/Web/src/app/manga-reader/_components/double-reverse-renderer/double-reverse-renderer.component.ts index 8acfdca3c..6c3b83743 100644 --- a/UI/Web/src/app/manga-reader/_components/double-reverse-renderer/double-reverse-renderer.component.ts +++ b/UI/Web/src/app/manga-reader/_components/double-reverse-renderer/double-reverse-renderer.component.ts @@ -18,7 +18,7 @@ import { LayoutMode } from '../../_models/layout-mode'; import { FITTING_OPTION, PAGING_DIRECTION } from '../../_models/reader-enums'; import { ReaderSetting } from '../../_models/reader-setting'; import { DEBUG_MODES, ImageRenderer } from '../../_models/renderer'; -import { ManagaReaderService } from '../../_service/managa-reader.service'; +import { MangaReaderService } from '../../_service/manga-reader.service'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import { SafeStylePipe } from '../../../_pipes/safe-style.pipe'; @@ -84,7 +84,7 @@ export class DoubleReverseRendererComponent implements OnInit, ImageRenderer { - constructor(private readonly cdRef: ChangeDetectorRef, public mangaReaderService: ManagaReaderService, + constructor(private readonly cdRef: ChangeDetectorRef, public mangaReaderService: MangaReaderService, @Inject(DOCUMENT) private document: Document, public readerService: ReaderService) { } ngOnInit(): void { diff --git a/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.ts b/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.ts index 0aef10674..14f182f7b 100644 --- a/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.ts +++ b/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.ts @@ -22,7 +22,7 @@ import { ScrollService } from 'src/app/_services/scroll.service'; import { ReaderService } from '../../../_services/reader.service'; import { PAGING_DIRECTION } from '../../_models/reader-enums'; import { WebtoonImage } from '../../_models/webtoon-image'; -import { ManagaReaderService } from '../../_service/managa-reader.service'; +import { MangaReaderService } from '../../_service/manga-reader.service'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {TranslocoDirective} from "@jsverse/transloco"; import {InfiniteScrollModule} from "ngx-infinite-scroll"; @@ -66,7 +66,7 @@ const enum DEBUG_MODES { }) export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy, AfterViewInit { - private readonly mangaReaderService = inject(ManagaReaderService); + private readonly mangaReaderService = inject(MangaReaderService); private readonly readerService = inject(ReaderService); private readonly renderer = inject(Renderer2); private readonly scrollService = inject(ScrollService); diff --git a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts index f04121444..a2abf8b0e 100644 --- a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts +++ b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts @@ -52,7 +52,7 @@ import {ReaderService} from 'src/app/_services/reader.service'; import {LayoutMode} from '../../_models/layout-mode'; import {FITTING_OPTION, PAGING_DIRECTION} from '../../_models/reader-enums'; import {ReaderSetting} from '../../_models/reader-setting'; -import {ManagaReaderService} from '../../_service/managa-reader.service'; +import {MangaReaderService} from '../../_service/manga-reader.service'; import {CanvasRendererComponent} from '../canvas-renderer/canvas-renderer.component'; import {DoubleRendererComponent} from '../double-renderer/double-renderer.component'; import {DoubleReverseRendererComponent} from '../double-reverse-renderer/double-reverse-renderer.component'; @@ -99,7 +99,7 @@ enum KeyDirection { templateUrl: './manga-reader.component.html', styleUrls: ['./manga-reader.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, - providers: [ManagaReaderService], + providers: [MangaReaderService], animations: [ trigger('slideFromTop', [ state('in', style({ transform: 'translateY(0)' })), @@ -153,7 +153,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { private readonly toastr = inject(ToastrService); public readonly readerService = inject(ReaderService); public readonly utilityService = inject(UtilityService); - public readonly mangaReaderService = inject(ManagaReaderService); + public readonly mangaReaderService = inject(MangaReaderService); protected readonly KeyDirection = KeyDirection; protected readonly ReaderMode = ReaderMode; diff --git a/UI/Web/src/app/manga-reader/_components/single-renderer/single-renderer.component.ts b/UI/Web/src/app/manga-reader/_components/single-renderer/single-renderer.component.ts index b1366d17e..981473a9c 100644 --- a/UI/Web/src/app/manga-reader/_components/single-renderer/single-renderer.component.ts +++ b/UI/Web/src/app/manga-reader/_components/single-renderer/single-renderer.component.ts @@ -17,7 +17,7 @@ import { LayoutMode } from '../../_models/layout-mode'; import { FITTING_OPTION, PAGING_DIRECTION } from '../../_models/reader-enums'; import { ReaderSetting } from '../../_models/reader-setting'; import { ImageRenderer } from '../../_models/renderer'; -import { ManagaReaderService } from '../../_service/managa-reader.service'; +import { MangaReaderService } from '../../_service/manga-reader.service'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import { SafeStylePipe } from '../../../_pipes/safe-style.pipe'; @@ -61,7 +61,7 @@ export class SingleRendererComponent implements OnInit, ImageRenderer { get ReaderMode() {return ReaderMode;} get LayoutMode() {return LayoutMode;} - constructor(private readonly cdRef: ChangeDetectorRef, public mangaReaderService: ManagaReaderService, + constructor(private readonly cdRef: ChangeDetectorRef, public mangaReaderService: MangaReaderService, @Inject(DOCUMENT) private document: Document) { } ngOnInit(): void { diff --git a/UI/Web/src/app/manga-reader/_service/managa-reader.service.ts b/UI/Web/src/app/manga-reader/_service/manga-reader.service.ts similarity index 97% rename from UI/Web/src/app/manga-reader/_service/managa-reader.service.ts rename to UI/Web/src/app/manga-reader/_service/manga-reader.service.ts index b623af6b1..a2975fd24 100644 --- a/UI/Web/src/app/manga-reader/_service/managa-reader.service.ts +++ b/UI/Web/src/app/manga-reader/_service/manga-reader.service.ts @@ -6,12 +6,11 @@ import { ChapterInfo } from '../_models/chapter-info'; import { DimensionMap } from '../_models/file-dimension'; import { FITTING_OPTION } from '../_models/reader-enums'; import { BookmarkInfo } from 'src/app/_models/manga-reader/bookmark-info'; -import {ReaderMode} from "../../_models/preferences/reader-mode"; @Injectable({ providedIn: 'root' }) -export class ManagaReaderService { +export class MangaReaderService { private pageDimensions: DimensionMap = {}; private pairs: {[key: number]: number} = {}; @@ -168,7 +167,7 @@ export class ManagaReaderService { } // Boost score if width is small (≤ 800px, common in webtoons) - if (info.width <= 800) { + if (info.width <= 750) { score += 0.5; // Adjust weight as needed } diff --git a/UI/Web/src/app/settings/_components/setting-item/setting-item.component.html b/UI/Web/src/app/settings/_components/setting-item/setting-item.component.html index b6d32d413..2a9478751 100644 --- a/UI/Web/src/app/settings/_components/setting-item/setting-item.component.html +++ b/UI/Web/src/app/settings/_components/setting-item/setting-item.component.html @@ -1,7 +1,7 @@
-
+
@if (labelId) { @@ -13,7 +13,7 @@ }
-
+
@if (showEdit) {