diff --git a/API.Tests/API.Tests.csproj b/API.Tests/API.Tests.csproj index 170a14022..50d08c811 100644 --- a/API.Tests/API.Tests.csproj +++ b/API.Tests/API.Tests.csproj @@ -6,11 +6,11 @@ - + - - + + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/API.Tests/AbstractDbTest.cs b/API.Tests/AbstractDbTest.cs index c19092635..ade0cceab 100644 --- a/API.Tests/AbstractDbTest.cs +++ b/API.Tests/AbstractDbTest.cs @@ -108,4 +108,19 @@ public abstract class AbstractDbTest : AbstractFsTest , IDisposable _context.Dispose(); _connection.Dispose(); } + + /// + /// Add a role to an existing User. Commits. + /// + /// + /// + protected async Task AddUserWithRole(int userId, string roleName) + { + var role = new AppRole { Id = userId, Name = roleName, NormalizedName = roleName.ToUpper() }; + + await _context.Roles.AddAsync(role); + await _context.UserRoles.AddAsync(new AppUserRole { UserId = userId, RoleId = userId }); + + await _context.SaveChangesAsync(); + } } diff --git a/API.Tests/Extensions/EncodeFormatExtensionsTests.cs b/API.Tests/Extensions/EncodeFormatExtensionsTests.cs new file mode 100644 index 000000000..a02de84aa --- /dev/null +++ b/API.Tests/Extensions/EncodeFormatExtensionsTests.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using API.Entities.Enums; +using API.Extensions; +using Xunit; + +namespace API.Tests.Extensions; + +public class EncodeFormatExtensionsTests +{ + [Fact] + public void GetExtension_ShouldReturnCorrectExtensionForAllValues() + { + // Arrange + var expectedExtensions = new Dictionary + { + { EncodeFormat.PNG, ".png" }, + { EncodeFormat.WEBP, ".webp" }, + { EncodeFormat.AVIF, ".avif" } + }; + + // Act & Assert + foreach (var format in Enum.GetValues(typeof(EncodeFormat)).Cast()) + { + var extension = format.GetExtension(); + Assert.Equal(expectedExtensions[format], extension); + } + } + +} diff --git a/API.Tests/Extensions/VersionExtensionTests.cs b/API.Tests/Extensions/VersionExtensionTests.cs new file mode 100644 index 000000000..e19fd7312 --- /dev/null +++ b/API.Tests/Extensions/VersionExtensionTests.cs @@ -0,0 +1,81 @@ +using System; +using API.Extensions; +using Xunit; + +namespace API.Tests.Extensions; + +public class VersionHelperTests +{ + [Fact] + public void CompareWithoutRevision_ShouldReturnTrue_WhenMajorMinorBuildMatch() + { + // Arrange + var v1 = new Version(1, 2, 3, 4); + var v2 = new Version(1, 2, 3, 5); + + // Act + var result = v1.CompareWithoutRevision(v2); + + // Assert + Assert.True(result); + } + + [Fact] + public void CompareWithoutRevision_ShouldHandleBuildlessVersions() + { + // Arrange + var v1 = new Version(1, 2); + var v2 = new Version(1, 2); + + // Act + var result = v1.CompareWithoutRevision(v2); + + // Assert + Assert.True(result); + } + + [Theory] + [InlineData(1, 2, 3, 1, 2, 4)] + [InlineData(1, 2, 3, 1, 2, 0)] + public void CompareWithoutRevision_ShouldReturnFalse_WhenBuildDiffers( + int major1, int minor1, int build1, + int major2, int minor2, int build2) + { + var v1 = new Version(major1, minor1, build1); + var v2 = new Version(major2, minor2, build2); + + var result = v1.CompareWithoutRevision(v2); + + Assert.False(result); + } + + [Theory] + [InlineData(1, 2, 3, 1, 3, 3)] + [InlineData(1, 2, 3, 1, 0, 3)] + public void CompareWithoutRevision_ShouldReturnFalse_WhenMinorDiffers( + int major1, int minor1, int build1, + int major2, int minor2, int build2) + { + var v1 = new Version(major1, minor1, build1); + var v2 = new Version(major2, minor2, build2); + + var result = v1.CompareWithoutRevision(v2); + + Assert.False(result); + } + + [Theory] + [InlineData(1, 2, 3, 2, 2, 3)] + [InlineData(1, 2, 3, 0, 2, 3)] + public void CompareWithoutRevision_ShouldReturnFalse_WhenMajorDiffers( + int major1, int minor1, int build1, + int major2, int minor2, int build2) + { + var v1 = new Version(major1, minor1, build1); + var v2 = new Version(major2, minor2, build2); + + var result = v1.CompareWithoutRevision(v2); + + Assert.False(result); + } +} diff --git a/API.Tests/Helpers/ReviewHelperTests.cs b/API.Tests/Helpers/ReviewHelperTests.cs new file mode 100644 index 000000000..b221c3c70 --- /dev/null +++ b/API.Tests/Helpers/ReviewHelperTests.cs @@ -0,0 +1,258 @@ +using API.Helpers; +using System.Collections.Generic; +using System.Linq; +using Xunit; +using API.DTOs.SeriesDetail; + +namespace API.Tests.Helpers; + +public class ReviewHelperTests +{ + #region SelectSpectrumOfReviews Tests + + [Fact] + public void SelectSpectrumOfReviews_WhenLessThan10Reviews_ReturnsAllReviews() + { + // Arrange + var reviews = CreateReviewList(8); + + // Act + var result = ReviewHelper.SelectSpectrumOfReviews(reviews).ToList(); + + // Assert + Assert.Equal(8, result.Count); + Assert.Equal(reviews, result.OrderByDescending(r => r.Score)); + } + + [Fact] + public void SelectSpectrumOfReviews_WhenMoreThan10Reviews_Returns10Reviews() + { + // Arrange + var reviews = CreateReviewList(20); + + // Act + var result = ReviewHelper.SelectSpectrumOfReviews(reviews).ToList(); + + // Assert + Assert.Equal(10, result.Count); + Assert.Equal(reviews[0], result.First()); + Assert.Equal(reviews[19], result.Last()); + } + + [Fact] + public void SelectSpectrumOfReviews_WithExactly10Reviews_ReturnsAllReviews() + { + // Arrange + var reviews = CreateReviewList(10); + + // Act + var result = ReviewHelper.SelectSpectrumOfReviews(reviews).ToList(); + + // Assert + Assert.Equal(10, result.Count); + } + + [Fact] + public void SelectSpectrumOfReviews_WithLargeNumberOfReviews_ReturnsCorrectSpectrum() + { + // Arrange + var reviews = CreateReviewList(100); + + // Act + var result = ReviewHelper.SelectSpectrumOfReviews(reviews).ToList(); + + // Assert + Assert.Equal(10, result.Count); + Assert.Contains(reviews[0], result); + Assert.Contains(reviews[1], result); + Assert.Contains(reviews[98], result); + Assert.Contains(reviews[99], result); + } + + [Fact] + public void SelectSpectrumOfReviews_WithEmptyList_ReturnsEmptyList() + { + // Arrange + var reviews = new List(); + + // Act + var result = ReviewHelper.SelectSpectrumOfReviews(reviews).ToList(); + + // Assert + Assert.Empty(result); + } + + [Fact] + public void SelectSpectrumOfReviews_ResultsOrderedByScoreDescending() + { + // Arrange + var reviews = new List + { + new UserReviewDto { Tagline = "1", Score = 3 }, + new UserReviewDto { Tagline = "2", Score = 5 }, + new UserReviewDto { Tagline = "3", Score = 1 }, + new UserReviewDto { Tagline = "4", Score = 4 }, + new UserReviewDto { Tagline = "5", Score = 2 } + }; + + // Act + var result = ReviewHelper.SelectSpectrumOfReviews(reviews).ToList(); + + // Assert + Assert.Equal(5, result.Count); + Assert.Equal(5, result[0].Score); + Assert.Equal(4, result[1].Score); + Assert.Equal(3, result[2].Score); + Assert.Equal(2, result[3].Score); + Assert.Equal(1, result[4].Score); + } + + #endregion + + #region GetCharacters Tests + + [Fact] + public void GetCharacters_WithNullBody_ReturnsNull() + { + // Arrange + string body = null; + + // Act + var result = ReviewHelper.GetCharacters(body); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetCharacters_WithEmptyBody_ReturnsEmptyString() + { + // Arrange + var body = string.Empty; + + // Act + var result = ReviewHelper.GetCharacters(body); + + // Assert + Assert.Equal(string.Empty, result); + } + + [Fact] + public void GetCharacters_WithNoTextNodes_ReturnsEmptyString() + { + // Arrange + const string body = "
"; + + // Act + var result = ReviewHelper.GetCharacters(body); + + // Assert + Assert.Equal(string.Empty, result); + } + + [Fact] + public void GetCharacters_WithLessCharactersThanLimit_ReturnsFullText() + { + // Arrange + var body = "

This is a short review.

"; + + // Act + var result = ReviewHelper.GetCharacters(body); + + // Assert + Assert.Equal("This is a short review.…", result); + } + + [Fact] + public void GetCharacters_WithMoreCharactersThanLimit_TruncatesText() + { + // Arrange + var body = "

" + new string('a', 200) + "

"; + + // Act + var result = ReviewHelper.GetCharacters(body); + + // Assert + Assert.Equal(new string('a', 175) + "…", result); + Assert.Equal(176, result.Length); // 175 characters + ellipsis + } + + [Fact] + public void GetCharacters_IgnoresScriptTags() + { + // Arrange + const string body = "

Visible text

"; + + // Act + var result = ReviewHelper.GetCharacters(body); + + // Assert + Assert.Equal("Visible text…", result); + Assert.DoesNotContain("hidden", result); + } + + [Fact] + public void GetCharacters_RemovesMarkdownSymbols() + { + // Arrange + const string body = "

This is **bold** and _italic_ text with [link](url).

"; + + // Act + var result = ReviewHelper.GetCharacters(body); + + // Assert + Assert.Equal("This is bold and italic text with link.…", result); + } + + [Fact] + public void GetCharacters_HandlesComplexMarkdownAndHtml() + { + // Arrange + const string body = """ + +
+

# Header

+

This is ~~strikethrough~~ and __underlined__ text

+

~~~code block~~~

+

+++highlighted+++

+

img123(image.jpg)

+
+ """; + + // Act + var result = ReviewHelper.GetCharacters(body); + + // Assert + Assert.DoesNotContain("~~", result); + Assert.DoesNotContain("__", result); + Assert.DoesNotContain("~~~", result); + Assert.DoesNotContain("+++", result); + Assert.DoesNotContain("img123(", result); + Assert.Contains("Header", result); + Assert.Contains("strikethrough", result); + Assert.Contains("underlined", result); + Assert.Contains("code block", result); + Assert.Contains("highlighted", result); + } + + #endregion + + #region Helper Methods + + private static List CreateReviewList(int count) + { + var reviews = new List(); + for (var i = 0; i < count; i++) + { + reviews.Add(new UserReviewDto + { + Tagline = $"{i + 1}", + Score = count - i // This makes them ordered by score descending initially + }); + } + return reviews; + } + + #endregion +} + diff --git a/API.Tests/Parsers/BasicParserTests.cs b/API.Tests/Parsers/BasicParserTests.cs index ad040d59e..32673e0e6 100644 --- a/API.Tests/Parsers/BasicParserTests.cs +++ b/API.Tests/Parsers/BasicParserTests.cs @@ -1,4 +1,5 @@ -using System.IO.Abstractions.TestingHelpers; +using System.IO; +using System.IO.Abstractions.TestingHelpers; using API.Entities.Enums; using API.Services; using API.Services.Tasks.Scanner.Parser; @@ -8,59 +9,54 @@ using Xunit; namespace API.Tests.Parsers; -public class BasicParserTests +public class BasicParserTests : AbstractFsTest { private readonly BasicParser _parser; private readonly ILogger _dsLogger = Substitute.For>(); - private const string RootDirectory = "C:/Books/"; + private readonly string _rootDirectory; public BasicParserTests() { - var fileSystem = new MockFileSystem(); - fileSystem.AddDirectory("C:/Books/"); - fileSystem.AddFile("C:/Books/Harry Potter/Harry Potter - Vol 1.epub", new MockFileData("")); + var fileSystem = CreateFileSystem(); + _rootDirectory = Path.Join(DataDirectory, "Books/"); + fileSystem.AddDirectory(_rootDirectory); + fileSystem.AddFile($"{_rootDirectory}Harry Potter/Harry Potter - Vol 1.epub", new MockFileData("")); - fileSystem.AddFile("C:/Books/Accel World/Accel World - Volume 1.cbz", new MockFileData("")); - fileSystem.AddFile("C:/Books/Accel World/Accel World - Volume 1 Chapter 2.cbz", new MockFileData("")); - fileSystem.AddFile("C:/Books/Accel World/Accel World - Chapter 3.cbz", new MockFileData("")); - fileSystem.AddFile("C:/Books/Accel World/Accel World Gaiden SP01.cbz", new MockFileData("")); + fileSystem.AddFile($"{_rootDirectory}Accel World/Accel World - Volume 1.cbz", new MockFileData("")); + fileSystem.AddFile($"{_rootDirectory}Accel World/Accel World - Volume 1 Chapter 2.cbz", new MockFileData("")); + fileSystem.AddFile($"{_rootDirectory}Accel World/Accel World - Chapter 3.cbz", new MockFileData("")); + fileSystem.AddFile("$\"{RootDirectory}Accel World/Accel World Gaiden SP01.cbz", new MockFileData("")); - fileSystem.AddFile("C:/Books/Accel World/cover.png", new MockFileData("")); + fileSystem.AddFile($"{_rootDirectory}Accel World/cover.png", new MockFileData("")); - fileSystem.AddFile("C:/Books/Batman/Batman #1.cbz", new MockFileData("")); + fileSystem.AddFile($"{_rootDirectory}Batman/Batman #1.cbz", new MockFileData("")); var ds = new DirectoryService(_dsLogger, fileSystem); _parser = new BasicParser(ds, new ImageParser(ds)); } - #region Parse_Books - - - - #endregion - #region Parse_Manga /// - /// Tests that when there is a loose leaf cover in the manga library, that it is ignored + /// Tests that when there is a loose-leaf cover in the manga library, that it is ignored /// [Fact] public void Parse_MangaLibrary_JustCover_ShouldReturnNull() { - var actual = _parser.Parse(@"C:/Books/Accel World/cover.png", "C:/Books/Accel World/", - RootDirectory, LibraryType.Manga, null); + var actual = _parser.Parse($"{_rootDirectory}Accel World/cover.png", $"{_rootDirectory}Accel World/", + _rootDirectory, LibraryType.Manga); Assert.Null(actual); } /// - /// Tests that when there is a loose leaf cover in the manga library, that it is ignored + /// Tests that when there is a loose-leaf cover in the manga library, that it is ignored /// [Fact] public void Parse_MangaLibrary_OtherImage_ShouldReturnNull() { - var actual = _parser.Parse(@"C:/Books/Accel World/page 01.png", "C:/Books/Accel World/", - RootDirectory, LibraryType.Manga, null); + var actual = _parser.Parse($"{_rootDirectory}Accel World/page 01.png", $"{_rootDirectory}Accel World/", + _rootDirectory, LibraryType.Manga); Assert.NotNull(actual); } @@ -70,8 +66,8 @@ public class BasicParserTests [Fact] public void Parse_MangaLibrary_VolumeAndChapterInFilename() { - var actual = _parser.Parse("C:/Books/Mujaki no Rakuen/Mujaki no Rakuen Vol12 ch76.cbz", "C:/Books/Mujaki no Rakuen/", - RootDirectory, LibraryType.Manga, null); + var actual = _parser.Parse($"{_rootDirectory}Mujaki no Rakuen/Mujaki no Rakuen Vol12 ch76.cbz", $"{_rootDirectory}Mujaki no Rakuen/", + _rootDirectory, LibraryType.Manga); Assert.NotNull(actual); Assert.Equal("Mujaki no Rakuen", actual.Series); @@ -86,9 +82,9 @@ public class BasicParserTests [Fact] public void Parse_MangaLibrary_JustVolumeInFilename() { - var actual = _parser.Parse("C:/Books/Shimoneta to Iu Gainen ga Sonzai Shinai Taikutsu na Sekai Man-hen/Vol 1.cbz", - "C:/Books/Shimoneta to Iu Gainen ga Sonzai Shinai Taikutsu na Sekai Man-hen/", - RootDirectory, LibraryType.Manga, null); + var actual = _parser.Parse($"{_rootDirectory}Shimoneta to Iu Gainen ga Sonzai Shinai Taikutsu na Sekai Man-hen/Vol 1.cbz", + $"{_rootDirectory}Shimoneta to Iu Gainen ga Sonzai Shinai Taikutsu na Sekai Man-hen/", + _rootDirectory, LibraryType.Manga); Assert.NotNull(actual); Assert.Equal("Shimoneta to Iu Gainen ga Sonzai Shinai Taikutsu na Sekai Man-hen", actual.Series); @@ -103,9 +99,9 @@ public class BasicParserTests [Fact] public void Parse_MangaLibrary_JustChapterInFilename() { - var actual = _parser.Parse("C:/Books/Beelzebub/Beelzebub_01_[Noodles].zip", - "C:/Books/Beelzebub/", - RootDirectory, LibraryType.Manga, null); + var actual = _parser.Parse($"{_rootDirectory}Beelzebub/Beelzebub_01_[Noodles].zip", + $"{_rootDirectory}Beelzebub/", + _rootDirectory, LibraryType.Manga); Assert.NotNull(actual); Assert.Equal("Beelzebub", actual.Series); @@ -120,9 +116,9 @@ public class BasicParserTests [Fact] public void Parse_MangaLibrary_SpecialMarkerInFilename() { - var actual = _parser.Parse("C:/Books/Summer Time Rendering/Specials/Record 014 (between chapter 083 and ch084) SP11.cbr", - "C:/Books/Summer Time Rendering/", - RootDirectory, LibraryType.Manga, null); + var actual = _parser.Parse($"{_rootDirectory}Summer Time Rendering/Specials/Record 014 (between chapter 083 and ch084) SP11.cbr", + $"{_rootDirectory}Summer Time Rendering/", + _rootDirectory, LibraryType.Manga); Assert.NotNull(actual); Assert.Equal("Summer Time Rendering", actual.Series); @@ -133,36 +129,54 @@ public class BasicParserTests /// - /// Tests that when the filename parses as a speical, it appropriately parses + /// Tests that when the filename parses as a special, it appropriately parses /// [Fact] public void Parse_MangaLibrary_SpecialInFilename() { - var actual = _parser.Parse("C:/Books/Summer Time Rendering/Volume SP01.cbr", - "C:/Books/Summer Time Rendering/", - RootDirectory, LibraryType.Manga, null); + var actual = _parser.Parse($"{_rootDirectory}Summer Time Rendering/Volume SP01.cbr", + $"{_rootDirectory}Summer Time Rendering/", + _rootDirectory, LibraryType.Manga); Assert.NotNull(actual); Assert.Equal("Summer Time Rendering", actual.Series); - Assert.Equal("Volume SP01", actual.Title); + Assert.Equal("Volume", actual.Title); Assert.Equal(Parser.SpecialVolume, actual.Volumes); Assert.Equal(Parser.DefaultChapter, actual.Chapters); Assert.True(actual.IsSpecial); } /// - /// Tests that when the filename parses as a speical, it appropriately parses + /// Tests that when the filename parses as a special, it appropriately parses /// [Fact] public void Parse_MangaLibrary_SpecialInFilename2() { var actual = _parser.Parse("M:/Kimi wa Midara na Boku no Joou/Specials/[Renzokusei] Special 1 SP02.zip", "M:/Kimi wa Midara na Boku no Joou/", - RootDirectory, LibraryType.Manga, null); + _rootDirectory, LibraryType.Manga); Assert.NotNull(actual); Assert.Equal("Kimi wa Midara na Boku no Joou", actual.Series); - Assert.Equal("[Renzokusei] Special 1 SP02", actual.Title); + Assert.Equal("[Renzokusei] Special 1", actual.Title); + Assert.Equal(Parser.SpecialVolume, actual.Volumes); + Assert.Equal(Parser.DefaultChapter, actual.Chapters); + Assert.True(actual.IsSpecial); + } + + /// + /// Tests that when the filename parses as a special, it appropriately parses + /// + [Fact] + public void Parse_MangaLibrary_SpecialInFilename_StrangeNaming() + { + var actual = _parser.Parse($"{_rootDirectory}My Dress-Up Darling/SP01 1. Special Name.cbz", + _rootDirectory, + _rootDirectory, LibraryType.Manga); + Assert.NotNull(actual); + + Assert.Equal("My Dress-Up Darling", actual.Series); + Assert.Equal("1. Special Name", actual.Title); Assert.Equal(Parser.SpecialVolume, actual.Volumes); Assert.Equal(Parser.DefaultChapter, actual.Chapters); Assert.True(actual.IsSpecial); @@ -174,9 +188,9 @@ public class BasicParserTests [Fact] public void Parse_MangaLibrary_EditionInFilename() { - var actual = _parser.Parse("C:/Books/Air Gear/Air Gear Omnibus v01 (2016) (Digital) (Shadowcat-Empire).cbz", - "C:/Books/Air Gear/", - RootDirectory, LibraryType.Manga, null); + var actual = _parser.Parse($"{_rootDirectory}Air Gear/Air Gear Omnibus v01 (2016) (Digital) (Shadowcat-Empire).cbz", + $"{_rootDirectory}Air Gear/", + _rootDirectory, LibraryType.Manga); Assert.NotNull(actual); Assert.Equal("Air Gear", actual.Series); @@ -195,9 +209,9 @@ public class BasicParserTests [Fact] public void Parse_MangaBooks_JustVolumeInFilename() { - var actual = _parser.Parse("C:/Books/Epubs/Harrison, Kim - The Good, The Bad, and the Undead - Hollows Vol 2.5.epub", - "C:/Books/Epubs/", - RootDirectory, LibraryType.Manga, null); + var actual = _parser.Parse($"{_rootDirectory}Epubs/Harrison, Kim - The Good, The Bad, and the Undead - Hollows Vol 2.5.epub", + $"{_rootDirectory}Epubs/", + _rootDirectory, LibraryType.Manga); Assert.NotNull(actual); Assert.Equal("Harrison, Kim - The Good, The Bad, and the Undead - Hollows", actual.Series); diff --git a/API.Tests/Parsing/ParserInfoTests.cs b/API.Tests/Parsing/ParserInfoTests.cs index 61ae8ecf2..cbb8ae99a 100644 --- a/API.Tests/Parsing/ParserInfoTests.cs +++ b/API.Tests/Parsing/ParserInfoTests.cs @@ -11,14 +11,14 @@ public class ParserInfoTests { var p1 = new ParserInfo() { - Chapters = API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, + Chapters = Parser.DefaultChapter, Edition = "", Format = MangaFormat.Archive, FullFilePath = "/manga/darker than black.cbz", IsSpecial = false, Series = "darker than black", Title = "darker than black", - Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume + Volumes = Parser.LooseLeafVolume }; var p2 = new ParserInfo() @@ -30,7 +30,7 @@ public class ParserInfoTests IsSpecial = false, Series = "darker than black", Title = "Darker Than Black", - Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume + Volumes = Parser.LooseLeafVolume }; var expected = new ParserInfo() @@ -42,7 +42,7 @@ public class ParserInfoTests IsSpecial = false, Series = "darker than black", Title = "darker than black", - Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume + Volumes = Parser.LooseLeafVolume }; p1.Merge(p2); @@ -62,12 +62,12 @@ public class ParserInfoTests IsSpecial = true, Series = "darker than black", Title = "darker than black", - Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume + Volumes = Parser.LooseLeafVolume }; var p2 = new ParserInfo() { - Chapters = API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, + Chapters = Parser.DefaultChapter, Edition = "", Format = MangaFormat.Archive, FullFilePath = "/manga/darker than black.cbz", diff --git a/API.Tests/Parsing/ParsingTests.cs b/API.Tests/Parsing/ParsingTests.cs index 3c4f35973..85ea1a858 100644 --- a/API.Tests/Parsing/ParsingTests.cs +++ b/API.Tests/Parsing/ParsingTests.cs @@ -44,6 +44,7 @@ public class ParsingTests [InlineData("DEAD Tube Prologue", "DEAD Tube Prologue")] [InlineData("DEAD Tube Prologue SP01", "DEAD Tube Prologue")] [InlineData("DEAD_Tube_Prologue SP01", "DEAD Tube Prologue")] + [InlineData("SP01 1. DEAD Tube Prologue", "1. DEAD Tube Prologue")] public void CleanSpecialTitleTest(string input, string expected) { Assert.Equal(expected, CleanSpecialTitle(input)); @@ -251,6 +252,7 @@ public class ParsingTests [InlineData("ch1/backcover.png", false)] [InlineData("backcover.png", false)] [InlineData("back_cover.png", false)] + [InlineData("LD Blacklands #1 35 (back cover).png", false)] public void IsCoverImageTest(string inputPath, bool expected) { Assert.Equal(expected, IsCoverImage(inputPath)); diff --git a/API.Tests/Services/CollectionTagServiceTests.cs b/API.Tests/Services/CollectionTagServiceTests.cs index 85e8391fe..f2fe14a81 100644 --- a/API.Tests/Services/CollectionTagServiceTests.cs +++ b/API.Tests/Services/CollectionTagServiceTests.cs @@ -1,6 +1,8 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using API.Constants; using API.Data; using API.Data.Repositories; using API.DTOs.Collection; @@ -10,6 +12,8 @@ using API.Helpers.Builders; using API.Services; using API.Services.Plus; using API.SignalR; +using Kavita.Common; +using Microsoft.EntityFrameworkCore; using NSubstitute; using Xunit; @@ -53,6 +57,64 @@ public class CollectionTagServiceTests : AbstractDbTest await _unitOfWork.CommitAsync(); } + #region DeleteTag + + [Fact] + public async Task DeleteTag_ShouldDeleteTag_WhenTagExists() + { + // Arrange + await SeedSeries(); + + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + Assert.NotNull(user); + + // Act + var result = await _service.DeleteTag(1, user); + + // Assert + Assert.True(result); + var deletedTag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.Null(deletedTag); + Assert.Single(user.Collections); // Only one collection should remain + } + + [Fact] + public async Task DeleteTag_ShouldReturnTrue_WhenTagDoesNotExist() + { + // Arrange + await SeedSeries(); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + Assert.NotNull(user); + + // Act - Try to delete a non-existent tag + var result = await _service.DeleteTag(999, user); + + // Assert + Assert.True(result); // Should return true because the tag is already "deleted" + Assert.Equal(2, user.Collections.Count); // Both collections should remain + } + + [Fact] + public async Task DeleteTag_ShouldNotAffectOtherTags() + { + // Arrange + await SeedSeries(); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + Assert.NotNull(user); + + // Act + var result = await _service.DeleteTag(1, user); + + // Assert + Assert.True(result); + var remainingTag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(2); + Assert.NotNull(remainingTag); + Assert.Equal("Tag 2", remainingTag.Title); + Assert.True(remainingTag.Promoted); + } + + #endregion + #region UpdateTag [Fact] @@ -111,6 +173,189 @@ public class CollectionTagServiceTests : AbstractDbTest Assert.Equal("UpdateTag_ShouldNotChangeTitle_WhenNotKavitaSource", tag.Title); Assert.False(string.IsNullOrEmpty(tag.Summary)); } + + [Fact] + public async Task UpdateTag_ShouldThrowException_WhenTagDoesNotExist() + { + // Arrange + await SeedSeries(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => _service.UpdateTag(new AppUserCollectionDto() + { + Title = "Non-existent Tag", + Id = 999, // Non-existent ID + Promoted = false + }, 1)); + + Assert.Equal("collection-doesnt-exist", exception.Message); + } + + [Fact] + public async Task UpdateTag_ShouldThrowException_WhenUserDoesNotOwnTag() + { + // Arrange + await SeedSeries(); + + // Create a second user + var user2 = new AppUserBuilder("user2", "user2", Seed.DefaultThemes.First()).Build(); + _unitOfWork.UserRepository.Add(user2); + await _unitOfWork.CommitAsync(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => _service.UpdateTag(new AppUserCollectionDto() + { + Title = "Tag 1", + Id = 1, // This belongs to user1 + Promoted = false + }, 2)); // User with ID 2 + + Assert.Equal("access-denied", exception.Message); + } + + [Fact] + public async Task UpdateTag_ShouldThrowException_WhenTitleIsEmpty() + { + // Arrange + await SeedSeries(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => _service.UpdateTag(new AppUserCollectionDto() + { + Title = " ", // Empty after trimming + Id = 1, + Promoted = false + }, 1)); + + Assert.Equal("collection-tag-title-required", exception.Message); + } + + [Fact] + public async Task UpdateTag_ShouldThrowException_WhenTitleAlreadyExists() + { + // Arrange + await SeedSeries(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => _service.UpdateTag(new AppUserCollectionDto() + { + Title = "Tag 2", // Already exists + Id = 1, // Trying to rename Tag 1 to Tag 2 + Promoted = false + }, 1)); + + Assert.Equal("collection-tag-duplicate", exception.Message); + } + + [Fact] + public async Task UpdateTag_ShouldUpdateCoverImageSettings() + { + // Arrange + await SeedSeries(); + + // Act + await _service.UpdateTag(new AppUserCollectionDto() + { + Title = "Tag 1", + Id = 1, + CoverImageLocked = true + }, 1); + + // Assert + var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.NotNull(tag); + Assert.True(tag.CoverImageLocked); + + // Now test unlocking the cover image + await _service.UpdateTag(new AppUserCollectionDto() + { + Title = "Tag 1", + Id = 1, + CoverImageLocked = false + }, 1); + + tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.NotNull(tag); + Assert.False(tag.CoverImageLocked); + Assert.Equal(string.Empty, tag.CoverImage); + } + + [Fact] + public async Task UpdateTag_ShouldAllowPromoteForAdminRole() + { + // Arrange + await SeedSeries(); + + // Setup a user with admin role + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + Assert.NotNull(user); + await AddUserWithRole(user.Id, PolicyConstants.AdminRole); + + + // Act - Try to promote a tag that wasn't previously promoted + await _service.UpdateTag(new AppUserCollectionDto() + { + Title = "Tag 1", + Id = 1, + Promoted = true + }, 1); + + // Assert + var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.NotNull(tag); + Assert.True(tag.Promoted); + } + + [Fact] + public async Task UpdateTag_ShouldAllowPromoteForPromoteRole() + { + // Arrange + await SeedSeries(); + + // Setup a user with promote role + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + Assert.NotNull(user); + + // Mock to return promote role for the user + await AddUserWithRole(user.Id, PolicyConstants.PromoteRole); + + // Act - Try to promote a tag that wasn't previously promoted + await _service.UpdateTag(new AppUserCollectionDto() + { + Title = "Tag 1", + Id = 1, + Promoted = true + }, 1); + + // Assert + var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.NotNull(tag); + Assert.True(tag.Promoted); + } + + [Fact] + public async Task UpdateTag_ShouldNotChangePromotion_WhenUserHasNoPermission() + { + // Arrange + await SeedSeries(); + + // Setup a user with no special roles + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + Assert.NotNull(user); + + // Act - Try to promote a tag without proper role + await _service.UpdateTag(new AppUserCollectionDto() + { + Title = "Tag 1", + Id = 1, + Promoted = true + }, 1); + + // Assert + var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.NotNull(tag); + Assert.False(tag.Promoted); // Should remain unpromoted + } #endregion @@ -131,7 +376,7 @@ public class CollectionTagServiceTests : AbstractDbTest await _service.RemoveTagFromSeries(tag, new[] {1}); var userCollections = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); Assert.Equal(2, userCollections!.Collections.Count); - Assert.Equal(1, tag.Items.Count); + Assert.Single(tag.Items); Assert.Equal(2, tag.Items.First().Id); } @@ -175,6 +420,111 @@ public class CollectionTagServiceTests : AbstractDbTest Assert.Null(tag2); } + [Fact] + public async Task RemoveTagFromSeries_ShouldReturnFalse_WhenTagIsNull() + { + // Act + var result = await _service.RemoveTagFromSeries(null, [1]); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task RemoveTagFromSeries_ShouldHandleEmptySeriesIdsList() + { + // Arrange + await SeedSeries(); + + var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.NotNull(tag); + var initialItemCount = tag.Items.Count; + + // Act + var result = await _service.RemoveTagFromSeries(tag, Array.Empty()); + + // Assert + Assert.True(result); + tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.NotNull(tag); + Assert.Equal(initialItemCount, tag.Items.Count); // No items should be removed + } + + [Fact] + public async Task RemoveTagFromSeries_ShouldHandleNonExistentSeriesIds() + { + // Arrange + await SeedSeries(); + + var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.NotNull(tag); + var initialItemCount = tag.Items.Count; + + // Act - Try to remove a series that doesn't exist in the tag + var result = await _service.RemoveTagFromSeries(tag, [999]); + + // Assert + Assert.True(result); + tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.NotNull(tag); + Assert.Equal(initialItemCount, tag.Items.Count); // No items should be removed + } + + [Fact] + public async Task RemoveTagFromSeries_ShouldHandleNullItemsList() + { + // Arrange + await SeedSeries(); + + var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.NotNull(tag); + + // Force null items list + tag.Items = null; + _unitOfWork.CollectionTagRepository.Update(tag); + await _unitOfWork.CommitAsync(); + + // Act + var result = await _service.RemoveTagFromSeries(tag, [1]); + + // Assert + Assert.True(result); + // The tag should not be removed since the items list was null, not empty + var tagAfter = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.Null(tagAfter); + } + + [Fact] + public async Task RemoveTagFromSeries_ShouldUpdateAgeRating_WhenMultipleSeriesRemain() + { + // Arrange + await SeedSeries(); + + // Add a third series with a different age rating + var s3 = new SeriesBuilder("Series 3").WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.PG).Build()).Build(); + _context.Library.First().Series.Add(s3); + await _unitOfWork.CommitAsync(); + + // Add series 3 to tag 2 + var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(2); + Assert.NotNull(tag); + tag.Items.Add(s3); + _unitOfWork.CollectionTagRepository.Update(tag); + await _unitOfWork.CommitAsync(); + + // Act - Remove the series with Mature rating + await _service.RemoveTagFromSeries(tag, new[] {1}); + + // Assert + tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(2); + Assert.NotNull(tag); + Assert.Equal(2, tag.Items.Count); + + // The age rating should be updated to the highest remaining rating (PG) + Assert.Equal(AgeRating.PG, tag.AgeRating); + } + + #endregion } diff --git a/API.Tests/Services/ExternalMetadataServiceTests.cs b/API.Tests/Services/ExternalMetadataServiceTests.cs index 436cd47fd..27e40c3e9 100644 --- a/API.Tests/Services/ExternalMetadataServiceTests.cs +++ b/API.Tests/Services/ExternalMetadataServiceTests.cs @@ -11,6 +11,7 @@ using API.DTOs.Scrobbling; using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; +using API.Entities.MetadataMatching; using API.Helpers.Builders; using API.Services.Plus; using API.Services.Tasks.Metadata; diff --git a/API.Tests/Services/ScannerServiceTests.cs b/API.Tests/Services/ScannerServiceTests.cs index 57f2293eb..aea254e51 100644 --- a/API.Tests/Services/ScannerServiceTests.cs +++ b/API.Tests/Services/ScannerServiceTests.cs @@ -167,7 +167,6 @@ public class ScannerServiceTests : AbstractDbTest Assert.NotNull(postLib.Series.First().Volumes.FirstOrDefault(v => v.Chapters.FirstOrDefault(c => c.IsSpecial) != null)); } - [Fact] public async Task ScanLibrary_SeriesWithUnbalancedParenthesis() { diff --git a/API.Tests/Services/SeriesServiceTests.cs b/API.Tests/Services/SeriesServiceTests.cs index 7500cb29a..66f89713d 100644 --- a/API.Tests/Services/SeriesServiceTests.cs +++ b/API.Tests/Services/SeriesServiceTests.cs @@ -59,6 +59,7 @@ public class SeriesServiceTests : AbstractDbTest Substitute.For(), Substitute.For>(), Substitute.For(), locService, Substitute.For()); } + #region Setup protected override async Task ResetDb() diff --git a/API.Tests/Services/SettingsServiceTests.cs b/API.Tests/Services/SettingsServiceTests.cs new file mode 100644 index 000000000..a3c6b67b8 --- /dev/null +++ b/API.Tests/Services/SettingsServiceTests.cs @@ -0,0 +1,292 @@ +using System.Collections.Generic; +using System.IO.Abstractions; +using System.Threading.Tasks; +using API.Data; +using API.Data.Repositories; +using API.DTOs.KavitaPlus.Metadata; +using API.Entities; +using API.Entities.Enums; +using API.Entities.MetadataMatching; +using API.Services; +using API.Services.Tasks.Scanner; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace API.Tests.Services; + +public class SettingsServiceTests +{ + private readonly ISettingsService _settingsService; + private readonly IUnitOfWork _mockUnitOfWork; + + public SettingsServiceTests() + { + var ds = new DirectoryService(Substitute.For>(), new FileSystem()); + + _mockUnitOfWork = Substitute.For(); + _settingsService = new SettingsService(_mockUnitOfWork, ds, + Substitute.For(), Substitute.For(), + Substitute.For>()); + } + + #region UpdateMetadataSettings + + [Fact] + public async Task UpdateMetadataSettings_ShouldUpdateExistingSettings() + { + // Arrange + var existingSettings = new MetadataSettings + { + Id = 1, + Enabled = false, + EnableSummary = false, + EnableLocalizedName = false, + EnablePublicationStatus = false, + EnableRelationships = false, + EnablePeople = false, + EnableStartDate = false, + EnableGenres = false, + EnableTags = false, + FirstLastPeopleNaming = false, + EnableCoverImage = false, + AgeRatingMappings = new Dictionary(), + Blacklist = [], + Whitelist = [], + Overrides = [], + PersonRoles = [], + FieldMappings = [] + }; + + var settingsRepo = Substitute.For(); + settingsRepo.GetMetadataSettings().Returns(Task.FromResult(existingSettings)); + settingsRepo.GetMetadataSettingDto().Returns(Task.FromResult(new MetadataSettingsDto())); + _mockUnitOfWork.SettingsRepository.Returns(settingsRepo); + + var updateDto = new MetadataSettingsDto + { + Enabled = true, + EnableSummary = true, + EnableLocalizedName = true, + EnablePublicationStatus = true, + EnableRelationships = true, + EnablePeople = true, + EnableStartDate = true, + EnableGenres = true, + EnableTags = true, + FirstLastPeopleNaming = true, + EnableCoverImage = true, + AgeRatingMappings = new Dictionary { { "Adult", AgeRating.R18Plus } }, + Blacklist = ["blacklisted-tag"], + Whitelist = ["whitelisted-tag"], + Overrides = [MetadataSettingField.Summary], + PersonRoles = [PersonRole.Writer], + FieldMappings = + [ + new MetadataFieldMappingDto + { + SourceType = MetadataFieldType.Genre, + DestinationType = MetadataFieldType.Tag, + SourceValue = "Action", + DestinationValue = "Fight", + ExcludeFromSource = true + } + ] + }; + + // Act + await _settingsService.UpdateMetadataSettings(updateDto); + + // Assert + await _mockUnitOfWork.Received(1).CommitAsync(); + + // Verify properties were updated + Assert.True(existingSettings.Enabled); + Assert.True(existingSettings.EnableSummary); + Assert.True(existingSettings.EnableLocalizedName); + Assert.True(existingSettings.EnablePublicationStatus); + Assert.True(existingSettings.EnableRelationships); + Assert.True(existingSettings.EnablePeople); + Assert.True(existingSettings.EnableStartDate); + Assert.True(existingSettings.EnableGenres); + Assert.True(existingSettings.EnableTags); + Assert.True(existingSettings.FirstLastPeopleNaming); + Assert.True(existingSettings.EnableCoverImage); + + // Verify collections were updated + Assert.Single(existingSettings.AgeRatingMappings); + Assert.Equal(AgeRating.R18Plus, existingSettings.AgeRatingMappings["Adult"]); + + Assert.Single(existingSettings.Blacklist); + Assert.Equal("blacklisted-tag", existingSettings.Blacklist[0]); + + Assert.Single(existingSettings.Whitelist); + Assert.Equal("whitelisted-tag", existingSettings.Whitelist[0]); + + Assert.Single(existingSettings.Overrides); + Assert.Equal(MetadataSettingField.Summary, existingSettings.Overrides[0]); + + Assert.Single(existingSettings.PersonRoles); + Assert.Equal(PersonRole.Writer, existingSettings.PersonRoles[0]); + + Assert.Single(existingSettings.FieldMappings); + Assert.Equal(MetadataFieldType.Genre, existingSettings.FieldMappings[0].SourceType); + Assert.Equal(MetadataFieldType.Tag, existingSettings.FieldMappings[0].DestinationType); + Assert.Equal("Action", existingSettings.FieldMappings[0].SourceValue); + Assert.Equal("Fight", existingSettings.FieldMappings[0].DestinationValue); + Assert.True(existingSettings.FieldMappings[0].ExcludeFromSource); + } + + [Fact] + public async Task UpdateMetadataSettings_WithNullCollections_ShouldUseEmptyCollections() + { + // Arrange + var existingSettings = new MetadataSettings + { + Id = 1, + FieldMappings = [new MetadataFieldMapping {Id = 1, SourceValue = "OldValue"}] + }; + + var settingsRepo = Substitute.For(); + settingsRepo.GetMetadataSettings().Returns(Task.FromResult(existingSettings)); + settingsRepo.GetMetadataSettingDto().Returns(Task.FromResult(new MetadataSettingsDto())); + _mockUnitOfWork.SettingsRepository.Returns(settingsRepo); + + var updateDto = new MetadataSettingsDto + { + AgeRatingMappings = null, + Blacklist = null, + Whitelist = null, + Overrides = null, + PersonRoles = null, + FieldMappings = null + }; + + // Act + await _settingsService.UpdateMetadataSettings(updateDto); + + // Assert + await _mockUnitOfWork.Received(1).CommitAsync(); + + Assert.Empty(existingSettings.AgeRatingMappings); + Assert.Empty(existingSettings.Blacklist); + Assert.Empty(existingSettings.Whitelist); + Assert.Empty(existingSettings.Overrides); + Assert.Empty(existingSettings.PersonRoles); + + // Verify existing field mappings were cleared + settingsRepo.Received(1).RemoveRange(Arg.Any>()); + Assert.Empty(existingSettings.FieldMappings); + } + + [Fact] + public async Task UpdateMetadataSettings_WithFieldMappings_ShouldReplaceExistingMappings() + { + // Arrange + var existingSettings = new MetadataSettings + { + Id = 1, + FieldMappings = + [ + new MetadataFieldMapping + { + Id = 1, + SourceType = MetadataFieldType.Genre, + DestinationType = MetadataFieldType.Genre, + SourceValue = "OldValue", + DestinationValue = "OldDestination", + ExcludeFromSource = false + } + ] + }; + + var settingsRepo = Substitute.For(); + settingsRepo.GetMetadataSettings().Returns(Task.FromResult(existingSettings)); + settingsRepo.GetMetadataSettingDto().Returns(Task.FromResult(new MetadataSettingsDto())); + _mockUnitOfWork.SettingsRepository.Returns(settingsRepo); + + var updateDto = new MetadataSettingsDto + { + FieldMappings = + [ + new MetadataFieldMappingDto + { + SourceType = MetadataFieldType.Tag, + DestinationType = MetadataFieldType.Genre, + SourceValue = "NewValue", + DestinationValue = "NewDestination", + ExcludeFromSource = true + }, + + new MetadataFieldMappingDto + { + SourceType = MetadataFieldType.Tag, + DestinationType = MetadataFieldType.Tag, + SourceValue = "AnotherValue", + DestinationValue = "AnotherDestination", + ExcludeFromSource = false + } + ] + }; + + // Act + await _settingsService.UpdateMetadataSettings(updateDto); + + // Assert + await _mockUnitOfWork.Received(1).CommitAsync(); + + // Verify existing field mappings were cleared and new ones added + settingsRepo.Received(1).RemoveRange(Arg.Any>()); + Assert.Equal(2, existingSettings.FieldMappings.Count); + + // Verify first mapping + Assert.Equal(MetadataFieldType.Tag, existingSettings.FieldMappings[0].SourceType); + Assert.Equal(MetadataFieldType.Genre, existingSettings.FieldMappings[0].DestinationType); + Assert.Equal("NewValue", existingSettings.FieldMappings[0].SourceValue); + Assert.Equal("NewDestination", existingSettings.FieldMappings[0].DestinationValue); + Assert.True(existingSettings.FieldMappings[0].ExcludeFromSource); + + // Verify second mapping + Assert.Equal(MetadataFieldType.Tag, existingSettings.FieldMappings[1].SourceType); + Assert.Equal(MetadataFieldType.Tag, existingSettings.FieldMappings[1].DestinationType); + Assert.Equal("AnotherValue", existingSettings.FieldMappings[1].SourceValue); + Assert.Equal("AnotherDestination", existingSettings.FieldMappings[1].DestinationValue); + Assert.False(existingSettings.FieldMappings[1].ExcludeFromSource); + } + + [Fact] + public async Task UpdateMetadataSettings_WithBlacklistWhitelist_ShouldNormalizeAndDeduplicateEntries() + { + // Arrange + var existingSettings = new MetadataSettings + { + Id = 1, + Blacklist = [], + Whitelist = [] + }; + + // We need to mock the repository and provide a custom implementation for ToNormalized + var settingsRepo = Substitute.For(); + settingsRepo.GetMetadataSettings().Returns(Task.FromResult(existingSettings)); + settingsRepo.GetMetadataSettingDto().Returns(Task.FromResult(new MetadataSettingsDto())); + _mockUnitOfWork.SettingsRepository.Returns(settingsRepo); + + var updateDto = new MetadataSettingsDto + { + // Include duplicates with different casing and whitespace + Blacklist = ["tag1", "Tag1", " tag2 ", "", " ", "tag3"], + Whitelist = ["allowed1", "Allowed1", " allowed2 ", "", "allowed3"] + }; + + // Act + await _settingsService.UpdateMetadataSettings(updateDto); + + // Assert + await _mockUnitOfWork.Received(1).CommitAsync(); + + Assert.Equal(3, existingSettings.Blacklist.Count); + Assert.Equal(3, existingSettings.Whitelist.Count); + } + + #endregion +} diff --git a/API.Tests/Services/VersionUpdaterServiceTests.cs b/API.Tests/Services/VersionUpdaterServiceTests.cs index 9132db4df..5bdc25c31 100644 --- a/API.Tests/Services/VersionUpdaterServiceTests.cs +++ b/API.Tests/Services/VersionUpdaterServiceTests.cs @@ -65,13 +65,13 @@ public class VersionUpdaterServiceTests : IDisposable [Fact] public async Task CheckForUpdate_ShouldReturnNull_WhenGithubApiReturnsNull() { - // Arrange + _httpTest.RespondWith("null"); - // Act + var result = await _service.CheckForUpdate(); - // Assert + Assert.Null(result); } @@ -79,7 +79,7 @@ public class VersionUpdaterServiceTests : IDisposable //[Fact] public async Task CheckForUpdate_ShouldReturnUpdateNotification_WhenNewVersionIsAvailable() { - // Arrange + var githubResponse = new { tag_name = "v0.6.0", @@ -91,10 +91,10 @@ public class VersionUpdaterServiceTests : IDisposable _httpTest.RespondWithJson(githubResponse); - // Act + var result = await _service.CheckForUpdate(); - // Assert + Assert.NotNull(result); Assert.Equal("0.6.0", result.UpdateVersion); Assert.Equal("0.5.0.0", result.CurrentVersion); @@ -121,10 +121,10 @@ public class VersionUpdaterServiceTests : IDisposable _httpTest.RespondWithJson(githubResponse); - // Act + var result = await _service.CheckForUpdate(); - // Assert + Assert.NotNull(result); Assert.True(result.IsReleaseEqual); Assert.False(result.IsReleaseNewer); @@ -134,7 +134,7 @@ public class VersionUpdaterServiceTests : IDisposable //[Fact] public async Task PushUpdate_ShouldSendUpdateEvent_WhenNewerVersionAvailable() { - // Arrange + var update = new UpdateNotificationDto { UpdateVersion = "0.6.0", @@ -145,10 +145,10 @@ public class VersionUpdaterServiceTests : IDisposable PublishDate = null }; - // Act + await _service.PushUpdate(update); - // Assert + await _eventHub.Received(1).SendMessageAsync( Arg.Is(MessageFactory.UpdateAvailable), Arg.Any(), @@ -159,7 +159,7 @@ public class VersionUpdaterServiceTests : IDisposable [Fact] public async Task PushUpdate_ShouldNotSendUpdateEvent_WhenVersionIsEqual() { - // Arrange + var update = new UpdateNotificationDto { UpdateVersion = "0.5.0.0", @@ -170,10 +170,10 @@ public class VersionUpdaterServiceTests : IDisposable PublishDate = null }; - // Act + await _service.PushUpdate(update); - // Assert + await _eventHub.DidNotReceive().SendMessageAsync( Arg.Any(), Arg.Any(), @@ -184,7 +184,7 @@ public class VersionUpdaterServiceTests : IDisposable [Fact] public async Task GetAllReleases_ShouldReturnReleases_LimitedByCount() { - // Arrange + var releases = new List { new @@ -215,10 +215,10 @@ public class VersionUpdaterServiceTests : IDisposable _httpTest.RespondWithJson(releases); - // Act + var result = await _service.GetAllReleases(2); - // Assert + Assert.Equal(2, result.Count); Assert.Equal("0.7.0.0", result[0].UpdateVersion); Assert.Equal("0.6.0", result[1].UpdateVersion); @@ -227,7 +227,7 @@ public class VersionUpdaterServiceTests : IDisposable [Fact] public async Task GetAllReleases_ShouldUseCachedData_WhenCacheIsValid() { - // Arrange + var releases = new List { new() @@ -257,10 +257,10 @@ public class VersionUpdaterServiceTests : IDisposable await File.WriteAllTextAsync(cacheFilePath, System.Text.Json.JsonSerializer.Serialize(releases)); File.SetLastWriteTimeUtc(cacheFilePath, DateTime.UtcNow); // Ensure it's fresh - // Act + var result = await _service.GetAllReleases(); - // Assert + Assert.Equal(2, result.Count); Assert.Empty(_httpTest.CallLog); // No HTTP calls made } @@ -268,7 +268,7 @@ public class VersionUpdaterServiceTests : IDisposable [Fact] public async Task GetAllReleases_ShouldFetchNewData_WhenCacheIsExpired() { - // Arrange + var releases = new List { new() @@ -303,10 +303,10 @@ public class VersionUpdaterServiceTests : IDisposable _httpTest.RespondWithJson(newReleases); - // Act + var result = await _service.GetAllReleases(); - // Assert + Assert.Equal(1, result.Count); Assert.Equal("0.7.0.0", result[0].UpdateVersion); Assert.NotEmpty(_httpTest.CallLog); // HTTP call was made @@ -314,7 +314,7 @@ public class VersionUpdaterServiceTests : IDisposable public async Task GetNumberOfReleasesBehind_ShouldReturnCorrectCount() { - // Arrange + var releases = new List { new @@ -345,16 +345,16 @@ public class VersionUpdaterServiceTests : IDisposable _httpTest.RespondWithJson(releases); - // Act + var result = await _service.GetNumberOfReleasesBehind(); - // Assert + Assert.Equal(2 + 1, result); // Behind 0.7.0 and 0.6.0 - We have to add 1 because the current release is > 0.7.0 } public async Task GetNumberOfReleasesBehind_ShouldReturnCorrectCount_WithNightlies() { - // Arrange + var releases = new List { new @@ -377,17 +377,17 @@ public class VersionUpdaterServiceTests : IDisposable _httpTest.RespondWithJson(releases); - // Act + var result = await _service.GetNumberOfReleasesBehind(); - // Assert + Assert.Equal(2, result); // We have to add 1 because the current release is > 0.7.0 } [Fact] public async Task ParseReleaseBody_ShouldExtractSections() { - // Arrange + var githubResponse = new { tag_name = "v0.6.0", @@ -399,10 +399,10 @@ public class VersionUpdaterServiceTests : IDisposable _httpTest.RespondWithJson(githubResponse); - // Act + var result = await _service.CheckForUpdate(); - // Assert + Assert.NotNull(result); Assert.Equal(2, result.Added.Count); Assert.Equal(2, result.Fixed.Count); @@ -414,7 +414,7 @@ public class VersionUpdaterServiceTests : IDisposable [Fact] public async Task GetAllReleases_ShouldHandleNightlyBuilds() { - // Arrange + // Set BuildInfo.Version to a nightly build version typeof(BuildInfo).GetProperty(nameof(BuildInfo.Version))?.SetValue(null, new Version("0.7.1.0")); @@ -444,10 +444,10 @@ public class VersionUpdaterServiceTests : IDisposable // Mock commit info for develop branch _httpTest.RespondWithJson(new List()); - // Act + var result = await _service.GetAllReleases(); - // Assert + Assert.NotNull(result); Assert.True(result[0].IsOnNightlyInRelease); } diff --git a/API/API.csproj b/API/API.csproj index 5cfaf42c0..71397c49c 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -51,8 +51,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -66,20 +66,20 @@ - + - - - - - + + + + + - + @@ -96,11 +96,11 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + - - - + + + diff --git a/API/Controllers/LocaleController.cs b/API/Controllers/LocaleController.cs index e6e85658c..3117a9b41 100644 --- a/API/Controllers/LocaleController.cs +++ b/API/Controllers/LocaleController.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.Linq; using System.Threading.Tasks; using API.Constants; +using API.DTOs; using API.DTOs.Filtering; using API.Services; using EasyCaching.Core; diff --git a/API/Controllers/MetadataController.cs b/API/Controllers/MetadataController.cs index 1e6ec0ae8..9757186bb 100644 --- a/API/Controllers/MetadataController.cs +++ b/API/Controllers/MetadataController.cs @@ -13,6 +13,7 @@ using API.DTOs.Recommendation; using API.DTOs.SeriesDetail; using API.Entities.Enums; using API.Extensions; +using API.Helpers; using API.Services; using API.Services.Plus; using Kavita.Common.Extensions; @@ -225,7 +226,7 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc var isAdmin = User.IsInRole(PolicyConstants.AdminRole); var user = await unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId())!; - userReviews.AddRange(ReviewService.SelectSpectrumOfReviews(ret.Reviews.ToList())); + userReviews.AddRange(ReviewHelper.SelectSpectrumOfReviews(ret.Reviews.ToList())); ret.Reviews = userReviews; if (!isAdmin && ret.Recommendations != null && user != null) diff --git a/API/Controllers/PersonController.cs b/API/Controllers/PersonController.cs index bb35b5974..1094a1137 100644 --- a/API/Controllers/PersonController.cs +++ b/API/Controllers/PersonController.cs @@ -55,7 +55,7 @@ public class PersonController : BaseApiController } /// - /// Returns a list of authors & artists for browsing + /// Returns a list of authors and artists for browsing /// /// /// diff --git a/API/Controllers/ServerController.cs b/API/Controllers/ServerController.cs index 38b72c65b..79f6391e8 100644 --- a/API/Controllers/ServerController.cs +++ b/API/Controllers/ServerController.cs @@ -203,10 +203,11 @@ public class ServerController : BaseApiController /// /// Returns how many versions out of date this install is /// + /// Only count Stable releases [HttpGet("check-out-of-date")] - public async Task> CheckHowOutOfDate() + public async Task> CheckHowOutOfDate(bool stableOnly = true) { - return Ok(await _versionUpdaterService.GetNumberOfReleasesBehind()); + return Ok(await _versionUpdaterService.GetNumberOfReleasesBehind(stableOnly)); } diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs index ff92964ff..b859f8b5e 100644 --- a/API/Controllers/SettingsController.cs +++ b/API/Controllers/SettingsController.cs @@ -40,10 +40,11 @@ public class SettingsController : BaseApiController private readonly IEmailService _emailService; private readonly ILibraryWatcher _libraryWatcher; private readonly ILocalizationService _localizationService; + private readonly ISettingsService _settingsService; public SettingsController(ILogger logger, IUnitOfWork unitOfWork, ITaskScheduler taskScheduler, IDirectoryService directoryService, IMapper mapper, IEmailService emailService, ILibraryWatcher libraryWatcher, - ILocalizationService localizationService) + ILocalizationService localizationService, ISettingsService settingsService) { _logger = logger; _unitOfWork = unitOfWork; @@ -53,6 +54,7 @@ public class SettingsController : BaseApiController _emailService = emailService; _libraryWatcher = libraryWatcher; _localizationService = localizationService; + _settingsService = settingsService; } [HttpGet("base-url")] @@ -139,346 +141,32 @@ public class SettingsController : BaseApiController } - + /// + /// Update Server settings + /// + /// + /// [Authorize(Policy = "RequireAdminRole")] [HttpPost] public async Task> UpdateSettings(ServerSettingDto updateSettingsDto) { _logger.LogInformation("{UserName} is updating Server Settings", User.GetUsername()); - // We do not allow CacheDirectory changes, so we will ignore. - var currentSettings = await _unitOfWork.SettingsRepository.GetSettingsAsync(); - var updateBookmarks = false; - var originalBookmarkDirectory = _directoryService.BookmarkDirectory; - - var bookmarkDirectory = updateSettingsDto.BookmarksDirectory; - if (!updateSettingsDto.BookmarksDirectory.EndsWith("bookmarks") && - !updateSettingsDto.BookmarksDirectory.EndsWith("bookmarks/")) - { - bookmarkDirectory = - _directoryService.FileSystem.Path.Join(updateSettingsDto.BookmarksDirectory, "bookmarks"); - } - - if (string.IsNullOrEmpty(updateSettingsDto.BookmarksDirectory)) - { - bookmarkDirectory = _directoryService.BookmarkDirectory; - } - - var updateTask = false; - foreach (var setting in currentSettings) - { - if (setting.Key == ServerSettingKey.OnDeckProgressDays && - updateSettingsDto.OnDeckProgressDays + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.OnDeckProgressDays + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.OnDeckUpdateDays && - updateSettingsDto.OnDeckUpdateDays + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.OnDeckUpdateDays + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.Port && updateSettingsDto.Port + string.Empty != setting.Value) - { - if (OsInfo.IsDocker) continue; - setting.Value = updateSettingsDto.Port + string.Empty; - // Port is managed in appSetting.json - Configuration.Port = updateSettingsDto.Port; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.CacheSize && - updateSettingsDto.CacheSize + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.CacheSize + string.Empty; - // CacheSize is managed in appSetting.json - Configuration.CacheSize = updateSettingsDto.CacheSize; - _unitOfWork.SettingsRepository.Update(setting); - } - - updateTask = updateTask || UpdateSchedulingSettings(setting, updateSettingsDto); - - UpdateEmailSettings(setting, updateSettingsDto); - - - - if (setting.Key == ServerSettingKey.IpAddresses && updateSettingsDto.IpAddresses != setting.Value) - { - if (OsInfo.IsDocker) continue; - // Validate IP addresses - foreach (var ipAddress in updateSettingsDto.IpAddresses.Split(',', - StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) - { - if (!IPAddress.TryParse(ipAddress.Trim(), out _)) - { - return BadRequest(await _localizationService.Translate(User.GetUserId(), "ip-address-invalid", - ipAddress)); - } - } - - setting.Value = updateSettingsDto.IpAddresses; - // IpAddresses is managed in appSetting.json - Configuration.IpAddresses = updateSettingsDto.IpAddresses; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.BaseUrl && updateSettingsDto.BaseUrl + string.Empty != setting.Value) - { - var path = !updateSettingsDto.BaseUrl.StartsWith('/') - ? $"/{updateSettingsDto.BaseUrl}" - : updateSettingsDto.BaseUrl; - path = !path.EndsWith('/') - ? $"{path}/" - : path; - setting.Value = path; - Configuration.BaseUrl = updateSettingsDto.BaseUrl; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.LoggingLevel && - updateSettingsDto.LoggingLevel + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.LoggingLevel + string.Empty; - LogLevelOptions.SwitchLogLevel(updateSettingsDto.LoggingLevel); - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EnableOpds && - updateSettingsDto.EnableOpds + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.EnableOpds + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EncodeMediaAs && - ((int)updateSettingsDto.EncodeMediaAs).ToString() != setting.Value) - { - setting.Value = ((int)updateSettingsDto.EncodeMediaAs).ToString(); - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.CoverImageSize && - ((int)updateSettingsDto.CoverImageSize).ToString() != setting.Value) - { - setting.Value = ((int)updateSettingsDto.CoverImageSize).ToString(); - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.HostName && updateSettingsDto.HostName + string.Empty != setting.Value) - { - setting.Value = (updateSettingsDto.HostName + string.Empty).Trim(); - setting.Value = UrlHelper.RemoveEndingSlash(setting.Value); - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.BookmarkDirectory && bookmarkDirectory != setting.Value) - { - // Validate new directory can be used - if (!await _directoryService.CheckWriteAccess(bookmarkDirectory)) - { - return BadRequest( - await _localizationService.Translate(User.GetUserId(), "bookmark-dir-permissions")); - } - - originalBookmarkDirectory = setting.Value; - // Normalize the path deliminators. Just to look nice in DB, no functionality - setting.Value = _directoryService.FileSystem.Path.GetFullPath(bookmarkDirectory); - _unitOfWork.SettingsRepository.Update(setting); - updateBookmarks = true; - - } - - if (setting.Key == ServerSettingKey.AllowStatCollection && - updateSettingsDto.AllowStatCollection + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.AllowStatCollection + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.TotalBackups && - updateSettingsDto.TotalBackups + string.Empty != setting.Value) - { - if (updateSettingsDto.TotalBackups > 30 || updateSettingsDto.TotalBackups < 1) - { - return BadRequest(await _localizationService.Translate(User.GetUserId(), "total-backups")); - } - - setting.Value = updateSettingsDto.TotalBackups + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.TotalLogs && - updateSettingsDto.TotalLogs + string.Empty != setting.Value) - { - if (updateSettingsDto.TotalLogs > 30 || updateSettingsDto.TotalLogs < 1) - { - return BadRequest(await _localizationService.Translate(User.GetUserId(), "total-logs")); - } - - setting.Value = updateSettingsDto.TotalLogs + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EnableFolderWatching && - updateSettingsDto.EnableFolderWatching + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.EnableFolderWatching + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - } - - if (!_unitOfWork.HasChanges()) return Ok(updateSettingsDto); - try { - await _unitOfWork.CommitAsync(); - - if (!updateSettingsDto.AllowStatCollection) - { - _taskScheduler.CancelStatsTasks(); - } - else - { - await _taskScheduler.ScheduleStatsTasks(); - } - - if (updateBookmarks) - { - UpdateBookmarkDirectory(originalBookmarkDirectory, bookmarkDirectory); - } - - if (updateTask) - { - BackgroundJob.Enqueue(() => _taskScheduler.ScheduleTasks()); - } - - if (updateSettingsDto.EnableFolderWatching) - { - BackgroundJob.Enqueue(() => _libraryWatcher.StartWatching()); - } - else - { - BackgroundJob.Enqueue(() => _libraryWatcher.StopWatching()); - } + return Ok(await _settingsService.UpdateSettings(updateSettingsDto)); + } + catch (KavitaException ex) + { + return BadRequest(await _localizationService.Translate(User.GetUserId(), ex.Message)); } catch (Exception ex) { _logger.LogError(ex, "There was an exception when updating server settings"); - await _unitOfWork.RollbackAsync(); return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error")); } - - - _logger.LogInformation("Server Settings updated"); - BackgroundJob.Enqueue(() => _taskScheduler.ScheduleTasks()); - - return Ok(updateSettingsDto); } - - private void UpdateBookmarkDirectory(string originalBookmarkDirectory, string bookmarkDirectory) - { - _directoryService.ExistOrCreate(bookmarkDirectory); - _directoryService.CopyDirectoryToDirectory(originalBookmarkDirectory, bookmarkDirectory); - _directoryService.ClearAndDeleteDirectory(originalBookmarkDirectory); - } - - private bool UpdateSchedulingSettings(ServerSetting setting, ServerSettingDto updateSettingsDto) - { - if (setting.Key == ServerSettingKey.TaskBackup && updateSettingsDto.TaskBackup != setting.Value) - { - setting.Value = updateSettingsDto.TaskBackup; - _unitOfWork.SettingsRepository.Update(setting); - - return true; - } - - if (setting.Key == ServerSettingKey.TaskScan && updateSettingsDto.TaskScan != setting.Value) - { - setting.Value = updateSettingsDto.TaskScan; - _unitOfWork.SettingsRepository.Update(setting); - return true; - } - - if (setting.Key == ServerSettingKey.TaskCleanup && updateSettingsDto.TaskCleanup != setting.Value) - { - setting.Value = updateSettingsDto.TaskCleanup; - _unitOfWork.SettingsRepository.Update(setting); - return true; - } - return false; - } - - private void UpdateEmailSettings(ServerSetting setting, ServerSettingDto updateSettingsDto) - { - if (setting.Key == ServerSettingKey.EmailHost && - updateSettingsDto.SmtpConfig.Host + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.SmtpConfig.Host + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EmailPort && - updateSettingsDto.SmtpConfig.Port + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.SmtpConfig.Port + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EmailAuthPassword && - updateSettingsDto.SmtpConfig.Password + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.SmtpConfig.Password + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EmailAuthUserName && - updateSettingsDto.SmtpConfig.UserName + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.SmtpConfig.UserName + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EmailSenderAddress && - updateSettingsDto.SmtpConfig.SenderAddress + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.SmtpConfig.SenderAddress + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EmailSenderDisplayName && - updateSettingsDto.SmtpConfig.SenderDisplayName + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.SmtpConfig.SenderDisplayName + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EmailSizeLimit && - updateSettingsDto.SmtpConfig.SizeLimit + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.SmtpConfig.SizeLimit + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EmailEnableSsl && - updateSettingsDto.SmtpConfig.EnableSsl + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.SmtpConfig.EnableSsl + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EmailCustomizedTemplates && - updateSettingsDto.SmtpConfig.CustomizedTemplates + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.SmtpConfig.CustomizedTemplates + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - } - - /// /// All values allowed for Task Scheduling APIs. A custom cron job is not included. Disabled is not applicable for Cleanup. /// @@ -549,7 +237,7 @@ public class SettingsController : BaseApiController } /// - /// Update the metadata settings for Kavita+ users + /// Update the metadata settings for Kavita+ Metadata feature /// /// /// @@ -557,54 +245,14 @@ public class SettingsController : BaseApiController [HttpPost("metadata-settings")] public async Task> UpdateMetadataSettings(MetadataSettingsDto dto) { - var existingMetadataSetting = await _unitOfWork.SettingsRepository.GetMetadataSettings(); - existingMetadataSetting.Enabled = dto.Enabled; - existingMetadataSetting.EnableSummary = dto.EnableSummary; - existingMetadataSetting.EnableLocalizedName = dto.EnableLocalizedName; - existingMetadataSetting.EnablePublicationStatus = dto.EnablePublicationStatus; - existingMetadataSetting.EnableRelationships = dto.EnableRelationships; - existingMetadataSetting.EnablePeople = dto.EnablePeople; - existingMetadataSetting.EnableStartDate = dto.EnableStartDate; - existingMetadataSetting.EnableGenres = dto.EnableGenres; - existingMetadataSetting.EnableTags = dto.EnableTags; - 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) + try { - // Clear existing mappings - existingMetadataSetting.FieldMappings ??= []; - _unitOfWork.SettingsRepository.RemoveRange(existingMetadataSetting.FieldMappings); - - existingMetadataSetting.FieldMappings.Clear(); - - - // Add new mappings - foreach (var mappingDto in dto.FieldMappings) - { - existingMetadataSetting.FieldMappings.Add(new MetadataFieldMapping - { - SourceType = mappingDto.SourceType, - DestinationType = mappingDto.DestinationType, - SourceValue = mappingDto.SourceValue, - DestinationValue = mappingDto.DestinationValue, - ExcludeFromSource = mappingDto.ExcludeFromSource - }); - } + return Ok(await _settingsService.UpdateMetadataSettings(dto)); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an issue when updating metadata settings"); + return BadRequest(ex.Message); } - - // Save changes - await _unitOfWork.CommitAsync(); - - // Return updated settings - return Ok(await _unitOfWork.SettingsRepository.GetMetadataSettingDto()); } } diff --git a/API/DTOs/Collection/AppUserCollectionDto.cs b/API/DTOs/Collection/AppUserCollectionDto.cs index cde0c1c14..ecfb5c062 100644 --- a/API/DTOs/Collection/AppUserCollectionDto.cs +++ b/API/DTOs/Collection/AppUserCollectionDto.cs @@ -10,7 +10,7 @@ public class AppUserCollectionDto : IHasCoverImage { public int Id { get; init; } public string Title { get; set; } = default!; - public string Summary { get; set; } = default!; + public string? Summary { get; set; } = default!; public bool Promoted { get; set; } public AgeRating AgeRating { get; set; } diff --git a/API/DTOs/KavitaLocale.cs b/API/DTOs/KavitaLocale.cs new file mode 100644 index 000000000..decfb7395 --- /dev/null +++ b/API/DTOs/KavitaLocale.cs @@ -0,0 +1,10 @@ +namespace API.DTOs; + +public class KavitaLocale +{ + public string FileName { get; set; } // Key + public string RenderName { get; set; } + public float TranslationCompletion { get; set; } + public bool IsRtL { get; set; } + public string Hash { get; set; } // ETAG hash so I can run my own localization busting implementation +} diff --git a/API/DTOs/KavitaPlus/Metadata/MetadataSettingsDto.cs b/API/DTOs/KavitaPlus/Metadata/MetadataSettingsDto.cs index 3c8687f49..d2e8247cb 100644 --- a/API/DTOs/KavitaPlus/Metadata/MetadataSettingsDto.cs +++ b/API/DTOs/KavitaPlus/Metadata/MetadataSettingsDto.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using API.Entities; using API.Entities.Enums; +using API.Entities.MetadataMatching; using NotImplementedException = System.NotImplementedException; namespace API.DTOs.KavitaPlus.Metadata; diff --git a/API/DTOs/Statistics/KavitaPlusMetadataBreakdownDto.cs b/API/DTOs/Statistics/KavitaPlusMetadataBreakdownDto.cs deleted file mode 100644 index 9ce44b6fa..000000000 --- a/API/DTOs/Statistics/KavitaPlusMetadataBreakdownDto.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace API.DTOs.Statistics; - -public class KavitaPlusMetadataBreakdownDto -{ - /// - /// Total amount of Series - /// - public int TotalSeries { get; set; } - /// - /// Series on the Blacklist (errored or bad match) - /// - public int ErroredSeries { get; set; } - /// - /// Completed so far - /// - public int SeriesCompleted { get; set; } -} diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index 7ab71c992..0937be22f 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -11,6 +11,7 @@ using API.Entities.Enums.UserPreferences; using API.Entities.History; using API.Entities.Interfaces; using API.Entities.Metadata; +using API.Entities.MetadataMatching; using API.Entities.Scrobble; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; diff --git a/API/Data/Repositories/SettingsRepository.cs b/API/Data/Repositories/SettingsRepository.cs index 4ffe59a00..433ab6edb 100644 --- a/API/Data/Repositories/SettingsRepository.cs +++ b/API/Data/Repositories/SettingsRepository.cs @@ -7,6 +7,7 @@ using API.DTOs.Settings; using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; +using API.Entities.MetadataMatching; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index 8fe413e99..0e90b617f 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -546,7 +546,16 @@ public class UserRepository : IUserRepository public async Task> GetRoles(int userId) { var user = await _context.Users.FirstOrDefaultAsync(u => u.Id == userId); - if (user == null || _userManager == null) return ArraySegment.Empty; // userManager is null on Unit Tests only + if (user == null) return ArraySegment.Empty; + + if (_userManager == null) + { + // userManager is null on Unit Tests only + return await _context.UserRoles + .Where(ur => ur.UserId == userId) + .Select(ur => ur.Role.Name) + .ToListAsync(); + } return await _userManager.GetRolesAsync(user); } diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index 71de39df5..2c385a852 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -11,6 +11,7 @@ using API.Data.Repositories; using API.Entities; using API.Entities.Enums; using API.Entities.Enums.Theme; +using API.Entities.MetadataMatching; using API.Extensions; using API.Services; using Kavita.Common; diff --git a/API/Entities/MetadataMatching/MetadataFieldMapping.cs b/API/Entities/MetadataMatching/MetadataFieldMapping.cs index 309135f1d..e7dd88c03 100644 --- a/API/Entities/MetadataMatching/MetadataFieldMapping.cs +++ b/API/Entities/MetadataMatching/MetadataFieldMapping.cs @@ -1,4 +1,5 @@ using API.Entities.Enums; +using API.Entities.MetadataMatching; namespace API.Entities; diff --git a/API/Entities/MetadataMatching/MetadataSettingField.cs b/API/Entities/MetadataMatching/MetadataSettingField.cs new file mode 100644 index 000000000..89ca5ee3e --- /dev/null +++ b/API/Entities/MetadataMatching/MetadataSettingField.cs @@ -0,0 +1,17 @@ +namespace API.Entities.MetadataMatching; + +/// +/// Represents which field that can be written to as an override when already locked +/// +public enum MetadataSettingField +{ + Summary = 1, + PublicationStatus = 2, + StartDate = 3, + Genres = 4, + Tags = 5, + LocalizedName = 6, + Covers = 7, + AgeRating = 8, + People = 9 +} diff --git a/API/Entities/MetadataMatching/MetadataSettings.cs b/API/Entities/MetadataMatching/MetadataSettings.cs index 7f982d6b1..bdf7f979f 100644 --- a/API/Entities/MetadataMatching/MetadataSettings.cs +++ b/API/Entities/MetadataMatching/MetadataSettings.cs @@ -1,24 +1,7 @@ using System.Collections.Generic; -using System.Linq; using API.Entities.Enums; -namespace API.Entities; - -/// -/// Represents which field that can be written to as an override when already locked -/// -public enum MetadataSettingField -{ - Summary = 1, - PublicationStatus = 2, - StartDate = 3, - Genres = 4, - Tags = 5, - LocalizedName = 6, - Covers = 7, - AgeRating = 8, - People = 9 -} +namespace API.Entities.MetadataMatching; /// /// Handles the metadata settings for Kavita+ diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index 7afdf4ace..774413e8e 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -69,6 +69,7 @@ public static class ApplicationServiceExtensions services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); diff --git a/API/Extensions/VersionExtensions.cs b/API/Extensions/VersionExtensions.cs index 4198c2e42..1877b48b1 100644 --- a/API/Extensions/VersionExtensions.cs +++ b/API/Extensions/VersionExtensions.cs @@ -14,18 +14,4 @@ public static class VersionExtensions return v1.Build == v2.Build; return true; } - - - /// - /// v0.8.2.3 is within v0.8.2 (v1). Essentially checks if this is a Nightly of a stable release - /// - /// - /// - /// - public static bool IsWithinStableRelease(this Version v1, Version v2) - { - return v1.Major == v2.Major && v1.Minor != v2.Minor && v1.Build != v2.Build; - } - - } diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index 6bf3b3fc2..3d9de9ea7 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -29,6 +29,7 @@ using API.DTOs.Theme; using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; +using API.Entities.MetadataMatching; using API.Entities.Scrobble; using API.Extensions.QueryExtensions.Filtering; using API.Helpers.Converters; @@ -336,7 +337,7 @@ public class AutoMapperProfiles : Profile CreateMap() .ForMember(dest => dest.BodyJustText, opt => - opt.MapFrom(src => ReviewService.GetCharacters(src.Body))); + opt.MapFrom(src => ReviewHelper.GetCharacters(src.Body))); CreateMap(); CreateMap() diff --git a/API/Helpers/DayOfWeekHelper.cs b/API/Helpers/DayOfWeekHelper.cs index 4d523a8f9..10cdb4170 100644 --- a/API/Helpers/DayOfWeekHelper.cs +++ b/API/Helpers/DayOfWeekHelper.cs @@ -1,6 +1,6 @@ using System; -namespace API.Extensions; +namespace API.Helpers; public static class DayOfWeekHelper { diff --git a/API/Services/ReviewService.cs b/API/Helpers/ReviewHelper.cs similarity index 89% rename from API/Services/ReviewService.cs rename to API/Helpers/ReviewHelper.cs index e9468ecba..03c50a4cf 100644 --- a/API/Services/ReviewService.cs +++ b/API/Helpers/ReviewHelper.cs @@ -5,10 +5,9 @@ using System.Text.RegularExpressions; using API.DTOs.SeriesDetail; using HtmlAgilityPack; +namespace API.Helpers; -namespace API.Services; - -public static class ReviewService +public static class ReviewHelper { private const int BodyTextLimit = 175; public static IEnumerable SelectSpectrumOfReviews(IList reviews) @@ -60,6 +59,9 @@ public static class ReviewService .Where(s => !s.Equals("\n"))); // Clean any leftover markdown out + plainText = Regex.Replace(plainText, @"\*\*(.*?)\*\*", "$1"); // Bold with ** + plainText = Regex.Replace(plainText, @"_(.*?)_", "$1"); // Italic with _ + plainText = Regex.Replace(plainText, @"\[(.*?)\]\((.*?)\)", "$1"); // Links [text](url) plainText = Regex.Replace(plainText, @"[_*\[\]~]", string.Empty); plainText = Regex.Replace(plainText, @"img\d*\((.*?)\)", string.Empty); plainText = Regex.Replace(plainText, @"~~~(.*?)~~~", "$1"); @@ -68,6 +70,7 @@ public static class ReviewService plainText = Regex.Replace(plainText, @"__(.*?)__", "$1"); plainText = Regex.Replace(plainText, @"#\s(.*?)", "$1"); + // Just strip symbols plainText = Regex.Replace(plainText, @"[_*\[\]~]", string.Empty); plainText = Regex.Replace(plainText, @"img\d*\((.*?)\)", string.Empty); diff --git a/API/Services/AccountService.cs b/API/Services/AccountService.cs index 241198811..ff8e12592 100644 --- a/API/Services/AccountService.cs +++ b/API/Services/AccountService.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using System.Web; using API.Constants; using API.Data; +using API.DTOs.Account; using API.Entities; using API.Errors; using Kavita.Common; @@ -46,7 +47,7 @@ public class AccountService : IAccountService public async Task> ChangeUserPassword(AppUser user, string newPassword) { var passwordValidationIssues = (await ValidatePassword(user, newPassword)).ToList(); - if (passwordValidationIssues.Any()) return passwordValidationIssues; + if (passwordValidationIssues.Count != 0) return passwordValidationIssues; var result = await _userManager.RemovePasswordAsync(user); if (!result.Succeeded) @@ -55,15 +56,11 @@ public class AccountService : IAccountService return result.Errors.Select(e => new ApiException(400, e.Code, e.Description)); } - result = await _userManager.AddPasswordAsync(user, newPassword); - if (!result.Succeeded) - { - _logger.LogError("Could not update password"); - return result.Errors.Select(e => new ApiException(400, e.Code, e.Description)); - } + if (result.Succeeded) return []; - return new List(); + _logger.LogError("Could not update password"); + return result.Errors.Select(e => new ApiException(400, e.Code, e.Description)); } public async Task> ValidatePassword(AppUser user, string password) @@ -81,15 +78,16 @@ public class AccountService : IAccountService } public async Task> ValidateUsername(string username) { - if (await _userManager.Users.AnyAsync(x => x.NormalizedUserName == username.ToUpper())) + if (await _userManager.Users.AnyAsync(x => x.NormalizedUserName != null + && x.NormalizedUserName.Equals(username, StringComparison.CurrentCultureIgnoreCase))) { - return new List() - { - new ApiException(400, "Username is already taken") - }; + return + [ + new(400, "Username is already taken") + ]; } - return Array.Empty(); + return []; } public async Task> ValidateEmail(string email) @@ -112,6 +110,7 @@ public class AccountService : IAccountService { if (user == null) return false; var roles = await _userManager.GetRolesAsync(user); + return roles.Contains(PolicyConstants.BookmarkRole) || roles.Contains(PolicyConstants.AdminRole); } @@ -124,6 +123,7 @@ public class AccountService : IAccountService { if (user == null) return false; var roles = await _userManager.GetRolesAsync(user); + return roles.Contains(PolicyConstants.DownloadRole) || roles.Contains(PolicyConstants.AdminRole); } @@ -135,9 +135,10 @@ public class AccountService : IAccountService public async Task CanChangeAgeRestriction(AppUser? user) { if (user == null) return false; + var roles = await _userManager.GetRolesAsync(user); if (roles.Contains(PolicyConstants.ReadOnlyRole)) return false; + return roles.Contains(PolicyConstants.ChangePasswordRole) || roles.Contains(PolicyConstants.AdminRole); } - } diff --git a/API/Services/CollectionTagService.cs b/API/Services/CollectionTagService.cs index 645cffcfa..a73c0cea2 100644 --- a/API/Services/CollectionTagService.cs +++ b/API/Services/CollectionTagService.cs @@ -58,7 +58,7 @@ public class CollectionTagService : ICollectionTagService if (!title.Equals(existingTag.Title) && await _unitOfWork.CollectionTagRepository.CollectionExists(dto.Title, userId)) throw new KavitaException("collection-tag-duplicate"); - existingTag.Items ??= new List(); + existingTag.Items ??= []; if (existingTag.Source == ScrobbleProvider.Kavita) { existingTag.Title = title; @@ -74,7 +74,7 @@ public class CollectionTagService : ICollectionTagService _unitOfWork.CollectionTagRepository.Update(existingTag); // Check if Tag has updated (Summary) - var summary = dto.Summary.Trim(); + var summary = (dto.Summary ?? string.Empty).Trim(); if (existingTag.Summary == null || !existingTag.Summary.Equals(summary)) { existingTag.Summary = summary; @@ -105,7 +105,7 @@ public class CollectionTagService : ICollectionTagService { if (tag == null) return false; - tag.Items ??= new List(); + tag.Items ??= []; tag.Items = tag.Items.Where(s => !seriesIds.Contains(s.Id)).ToList(); if (tag.Items.Count == 0) diff --git a/API/Services/LocalizationService.cs b/API/Services/LocalizationService.cs index 3bc3cf3b2..30384a757 100644 --- a/API/Services/LocalizationService.cs +++ b/API/Services/LocalizationService.cs @@ -4,20 +4,14 @@ using System.Linq; using System.Text.Json; using System.Threading.Tasks; using API.Data; +using API.DTOs; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Hosting; namespace API.Services; #nullable enable -public class KavitaLocale -{ - public string FileName { get; set; } // Key - public string RenderName { get; set; } - public float TranslationCompletion { get; set; } - public bool IsRtL { get; set; } - public string Hash { get; set; } // ETAG hash so I can run my own localization busting implementation -} + public interface ILocalizationService diff --git a/API/Services/Plus/ExternalMetadataService.cs b/API/Services/Plus/ExternalMetadataService.cs index a73701cf8..2183bc43e 100644 --- a/API/Services/Plus/ExternalMetadataService.cs +++ b/API/Services/Plus/ExternalMetadataService.cs @@ -16,6 +16,7 @@ using API.DTOs.SeriesDetail; using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; +using API.Entities.MetadataMatching; using API.Extensions; using API.Helpers; using API.Services.Tasks.Metadata; diff --git a/API/Services/Plus/LicenseService.cs b/API/Services/Plus/LicenseService.cs index de5bbd1ae..6f2a76f0d 100644 --- a/API/Services/Plus/LicenseService.cs +++ b/API/Services/Plus/LicenseService.cs @@ -278,7 +278,7 @@ public class LicenseService( var releases = await versionUpdaterService.GetAllReleases(); response.IsValidVersion = releases .Where(r => !r.UpdateTitle.Contains("Hotfix")) // We don't care about Hotfix releases - .Where(r => !r.IsPrerelease || BuildInfo.Version.IsWithinStableRelease(new Version(r.UpdateVersion))) // Ensure we don't take current nightlies within the current/last stable + .Where(r => !r.IsPrerelease) // Ensure we don't take current nightlies within the current/last stable .Take(3) .All(r => new Version(r.UpdateVersion) <= BuildInfo.Version); diff --git a/API/Services/SettingsService.cs b/API/Services/SettingsService.cs new file mode 100644 index 000000000..172bcfedb --- /dev/null +++ b/API/Services/SettingsService.cs @@ -0,0 +1,441 @@ +using System; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using API.Data; +using API.DTOs.KavitaPlus.Metadata; +using API.DTOs.Settings; +using API.Entities; +using API.Entities.Enums; +using API.Extensions; +using API.Logging; +using API.Services.Tasks.Scanner; +using Hangfire; +using Kavita.Common; +using Kavita.Common.EnvironmentInfo; +using Kavita.Common.Helpers; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace API.Services; + +public interface ISettingsService +{ + Task> UpdateMetadataSettings(MetadataSettingsDto dto); + Task> UpdateSettings(ServerSettingDto updateSettingsDto); +} + + +public class SettingsService : ISettingsService +{ + private readonly IUnitOfWork _unitOfWork; + private readonly IDirectoryService _directoryService; + private readonly ILibraryWatcher _libraryWatcher; + private readonly ITaskScheduler _taskScheduler; + private readonly ILogger _logger; + + public SettingsService(IUnitOfWork unitOfWork, IDirectoryService directoryService, + ILibraryWatcher libraryWatcher, ITaskScheduler taskScheduler, + ILogger logger) + { + _unitOfWork = unitOfWork; + _directoryService = directoryService; + _libraryWatcher = libraryWatcher; + _taskScheduler = taskScheduler; + _logger = logger; + } + + /// + /// Update the metadata settings for Kavita+ Metadata feature + /// + /// + /// + public async Task> UpdateMetadataSettings(MetadataSettingsDto dto) + { + var existingMetadataSetting = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + existingMetadataSetting.Enabled = dto.Enabled; + existingMetadataSetting.EnableSummary = dto.EnableSummary; + existingMetadataSetting.EnableLocalizedName = dto.EnableLocalizedName; + existingMetadataSetting.EnablePublicationStatus = dto.EnablePublicationStatus; + existingMetadataSetting.EnableRelationships = dto.EnableRelationships; + existingMetadataSetting.EnablePeople = dto.EnablePeople; + existingMetadataSetting.EnableStartDate = dto.EnableStartDate; + existingMetadataSetting.EnableGenres = dto.EnableGenres; + existingMetadataSetting.EnableTags = dto.EnableTags; + 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 ?? []]; + existingMetadataSetting.PersonRoles = dto.PersonRoles ?? []; + + // Handle Field Mappings + + // Clear existing mappings + existingMetadataSetting.FieldMappings ??= []; + _unitOfWork.SettingsRepository.RemoveRange(existingMetadataSetting.FieldMappings); + existingMetadataSetting.FieldMappings.Clear(); + + if (dto.FieldMappings != null) + { + // Add new mappings + foreach (var mappingDto in dto.FieldMappings) + { + existingMetadataSetting.FieldMappings.Add(new MetadataFieldMapping + { + SourceType = mappingDto.SourceType, + DestinationType = mappingDto.DestinationType, + SourceValue = mappingDto.SourceValue, + DestinationValue = mappingDto.DestinationValue, + ExcludeFromSource = mappingDto.ExcludeFromSource + }); + } + } + + // Save changes + await _unitOfWork.CommitAsync(); + + // Return updated settings + return await _unitOfWork.SettingsRepository.GetMetadataSettingDto(); + } + + /// + /// Update Server Settings + /// + /// + /// + /// + public async Task> UpdateSettings(ServerSettingDto updateSettingsDto) + { + // We do not allow CacheDirectory changes, so we will ignore. + var currentSettings = await _unitOfWork.SettingsRepository.GetSettingsAsync(); + var updateBookmarks = false; + var originalBookmarkDirectory = _directoryService.BookmarkDirectory; + + var bookmarkDirectory = updateSettingsDto.BookmarksDirectory; + if (!updateSettingsDto.BookmarksDirectory.EndsWith("bookmarks") && + !updateSettingsDto.BookmarksDirectory.EndsWith("bookmarks/")) + { + bookmarkDirectory = + _directoryService.FileSystem.Path.Join(updateSettingsDto.BookmarksDirectory, "bookmarks"); + } + + if (string.IsNullOrEmpty(updateSettingsDto.BookmarksDirectory)) + { + bookmarkDirectory = _directoryService.BookmarkDirectory; + } + + var updateTask = false; + foreach (var setting in currentSettings) + { + if (setting.Key == ServerSettingKey.OnDeckProgressDays && + updateSettingsDto.OnDeckProgressDays + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.OnDeckProgressDays + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.OnDeckUpdateDays && + updateSettingsDto.OnDeckUpdateDays + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.OnDeckUpdateDays + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.Port && updateSettingsDto.Port + string.Empty != setting.Value) + { + if (OsInfo.IsDocker) continue; + setting.Value = updateSettingsDto.Port + string.Empty; + // Port is managed in appSetting.json + Configuration.Port = updateSettingsDto.Port; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.CacheSize && + updateSettingsDto.CacheSize + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.CacheSize + string.Empty; + // CacheSize is managed in appSetting.json + Configuration.CacheSize = updateSettingsDto.CacheSize; + _unitOfWork.SettingsRepository.Update(setting); + } + + updateTask = updateTask || UpdateSchedulingSettings(setting, updateSettingsDto); + + UpdateEmailSettings(setting, updateSettingsDto); + + + + if (setting.Key == ServerSettingKey.IpAddresses && updateSettingsDto.IpAddresses != setting.Value) + { + if (OsInfo.IsDocker) continue; + // Validate IP addresses + foreach (var ipAddress in updateSettingsDto.IpAddresses.Split(',', + StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + { + if (!IPAddress.TryParse(ipAddress.Trim(), out _)) + { + throw new KavitaException("ip-address-invalid"); + } + } + + setting.Value = updateSettingsDto.IpAddresses; + // IpAddresses is managed in appSetting.json + Configuration.IpAddresses = updateSettingsDto.IpAddresses; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.BaseUrl && updateSettingsDto.BaseUrl + string.Empty != setting.Value) + { + var path = !updateSettingsDto.BaseUrl.StartsWith('/') + ? $"/{updateSettingsDto.BaseUrl}" + : updateSettingsDto.BaseUrl; + path = !path.EndsWith('/') + ? $"{path}/" + : path; + setting.Value = path; + Configuration.BaseUrl = updateSettingsDto.BaseUrl; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.LoggingLevel && + updateSettingsDto.LoggingLevel + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.LoggingLevel + string.Empty; + LogLevelOptions.SwitchLogLevel(updateSettingsDto.LoggingLevel); + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EnableOpds && + updateSettingsDto.EnableOpds + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.EnableOpds + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EncodeMediaAs && + ((int)updateSettingsDto.EncodeMediaAs).ToString() != setting.Value) + { + setting.Value = ((int)updateSettingsDto.EncodeMediaAs).ToString(); + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.CoverImageSize && + ((int)updateSettingsDto.CoverImageSize).ToString() != setting.Value) + { + setting.Value = ((int)updateSettingsDto.CoverImageSize).ToString(); + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.HostName && updateSettingsDto.HostName + string.Empty != setting.Value) + { + setting.Value = (updateSettingsDto.HostName + string.Empty).Trim(); + setting.Value = UrlHelper.RemoveEndingSlash(setting.Value); + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.BookmarkDirectory && bookmarkDirectory != setting.Value) + { + // Validate new directory can be used + if (!await _directoryService.CheckWriteAccess(bookmarkDirectory)) + { + throw new KavitaException("bookmark-dir-permissions"); + } + + originalBookmarkDirectory = setting.Value; + + // Normalize the path deliminators. Just to look nice in DB, no functionality + setting.Value = _directoryService.FileSystem.Path.GetFullPath(bookmarkDirectory); + _unitOfWork.SettingsRepository.Update(setting); + updateBookmarks = true; + + } + + if (setting.Key == ServerSettingKey.AllowStatCollection && + updateSettingsDto.AllowStatCollection + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.AllowStatCollection + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.TotalBackups && + updateSettingsDto.TotalBackups + string.Empty != setting.Value) + { + if (updateSettingsDto.TotalBackups > 30 || updateSettingsDto.TotalBackups < 1) + { + throw new KavitaException("total-backups"); + } + + setting.Value = updateSettingsDto.TotalBackups + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.TotalLogs && + updateSettingsDto.TotalLogs + string.Empty != setting.Value) + { + if (updateSettingsDto.TotalLogs > 30 || updateSettingsDto.TotalLogs < 1) + { + throw new KavitaException("total-logs"); + } + + setting.Value = updateSettingsDto.TotalLogs + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EnableFolderWatching && + updateSettingsDto.EnableFolderWatching + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.EnableFolderWatching + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + } + + if (!_unitOfWork.HasChanges()) return updateSettingsDto; + + try + { + await _unitOfWork.CommitAsync(); + + if (!updateSettingsDto.AllowStatCollection) + { + _taskScheduler.CancelStatsTasks(); + } + else + { + await _taskScheduler.ScheduleStatsTasks(); + } + + if (updateBookmarks) + { + UpdateBookmarkDirectory(originalBookmarkDirectory, bookmarkDirectory); + } + + if (updateTask) + { + BackgroundJob.Enqueue(() => _taskScheduler.ScheduleTasks()); + } + + if (updateSettingsDto.EnableFolderWatching) + { + BackgroundJob.Enqueue(() => _libraryWatcher.StartWatching()); + } + else + { + BackgroundJob.Enqueue(() => _libraryWatcher.StopWatching()); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception when updating server settings"); + await _unitOfWork.RollbackAsync(); + throw new KavitaException("generic-error"); + } + + + _logger.LogInformation("Server Settings updated"); + + return updateSettingsDto; + } + + private void UpdateBookmarkDirectory(string originalBookmarkDirectory, string bookmarkDirectory) + { + _directoryService.ExistOrCreate(bookmarkDirectory); + _directoryService.CopyDirectoryToDirectory(originalBookmarkDirectory, bookmarkDirectory); + _directoryService.ClearAndDeleteDirectory(originalBookmarkDirectory); + } + + private bool UpdateSchedulingSettings(ServerSetting setting, ServerSettingDto updateSettingsDto) + { + if (setting.Key == ServerSettingKey.TaskBackup && updateSettingsDto.TaskBackup != setting.Value) + { + setting.Value = updateSettingsDto.TaskBackup; + _unitOfWork.SettingsRepository.Update(setting); + + return true; + } + + if (setting.Key == ServerSettingKey.TaskScan && updateSettingsDto.TaskScan != setting.Value) + { + setting.Value = updateSettingsDto.TaskScan; + _unitOfWork.SettingsRepository.Update(setting); + return true; + } + + if (setting.Key == ServerSettingKey.TaskCleanup && updateSettingsDto.TaskCleanup != setting.Value) + { + setting.Value = updateSettingsDto.TaskCleanup; + _unitOfWork.SettingsRepository.Update(setting); + return true; + } + return false; + } + + private void UpdateEmailSettings(ServerSetting setting, ServerSettingDto updateSettingsDto) + { + if (setting.Key == ServerSettingKey.EmailHost && + updateSettingsDto.SmtpConfig.Host + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.Host + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EmailPort && + updateSettingsDto.SmtpConfig.Port + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.Port + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EmailAuthPassword && + updateSettingsDto.SmtpConfig.Password + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.Password + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EmailAuthUserName && + updateSettingsDto.SmtpConfig.UserName + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.UserName + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EmailSenderAddress && + updateSettingsDto.SmtpConfig.SenderAddress + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.SenderAddress + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EmailSenderDisplayName && + updateSettingsDto.SmtpConfig.SenderDisplayName + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.SenderDisplayName + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EmailSizeLimit && + updateSettingsDto.SmtpConfig.SizeLimit + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.SizeLimit + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EmailEnableSsl && + updateSettingsDto.SmtpConfig.EnableSsl + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.EnableSsl + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EmailCustomizedTemplates && + updateSettingsDto.SmtpConfig.CustomizedTemplates + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.CustomizedTemplates + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + } +} diff --git a/API/Services/StatisticService.cs b/API/Services/StatisticService.cs index 96a12f440..006bad184 100644 --- a/API/Services/StatisticService.cs +++ b/API/Services/StatisticService.cs @@ -36,7 +36,6 @@ public interface IStatisticService IEnumerable> GetWordsReadCountByYear(int userId = 0); Task UpdateServerStatistics(); Task TimeSpentReadingForUsersAsync(IList userIds, IList libraryIds); - Task GetKavitaPlusMetadataBreakdown(); Task> GetFilesByExtension(string fileExtension); } @@ -139,7 +138,9 @@ public class StatisticService : IStatisticService } else { +#pragma warning disable S6561 var timeDifference = DateTime.Now - earliestReadDate; +#pragma warning restore S6561 var deltaWeeks = (int)Math.Ceiling(timeDifference.TotalDays / 7); averageReadingTimePerWeek /= deltaWeeks; @@ -554,29 +555,6 @@ public class StatisticService : IStatisticService p.chapter.AvgHoursToRead * (p.progress.PagesRead / (1.0f * p.chapter.Pages)))); } - public async Task GetKavitaPlusMetadataBreakdown() - { - // We need to count number of Series that have an external series record - // Then count how many series are blacklisted - // Then get total count of series that are Kavita+ eligible - var plusLibraries = await _context.Library - .Where(l => !ExternalMetadataService.NonEligibleLibraryTypes.Contains(l.Type)) - .Select(l => l.Id) - .ToListAsync(); - - var countOfBlacklisted = await _context.SeriesBlacklist.CountAsync(); - var totalSeries = await _context.Series.Where(s => plusLibraries.Contains(s.LibraryId)).CountAsync(); - var seriesWithMetadata = await _context.ExternalSeriesMetadata.CountAsync(); - - return new KavitaPlusMetadataBreakdownDto() - { - TotalSeries = totalSeries, - ErroredSeries = countOfBlacklisted, - SeriesCompleted = seriesWithMetadata - }; - - } - public async Task> GetFilesByExtension(string fileExtension) { var query = _context.MangaFile diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index 3000bbd31..46ba18abf 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -7,6 +7,7 @@ using API.Data; using API.Data.Repositories; using API.Entities.Enums; using API.Extensions; +using API.Helpers; using API.Helpers.Converters; using API.Services.Plus; using API.Services.Tasks; diff --git a/API/Services/Tasks/Scanner/Parser/BasicParser.cs b/API/Services/Tasks/Scanner/Parser/BasicParser.cs index 039e3acd6..1462ab3d3 100644 --- a/API/Services/Tasks/Scanner/Parser/BasicParser.cs +++ b/API/Services/Tasks/Scanner/Parser/BasicParser.cs @@ -86,7 +86,7 @@ public class BasicParser(IDirectoryService directoryService, IDefaultParser imag { ParseFromFallbackFolders(filePath, tempRootPath, type, ref ret); } - + ret.Title = Parser.CleanSpecialTitle(fileName); } if (string.IsNullOrEmpty(ret.Series)) diff --git a/API/Services/Tasks/Scanner/Parser/Parser.cs b/API/Services/Tasks/Scanner/Parser/Parser.cs index 12374d67f..992dcf108 100644 --- a/API/Services/Tasks/Scanner/Parser/Parser.cs +++ b/API/Services/Tasks/Scanner/Parser/Parser.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Immutable; -using System.Globalization; using System.IO; using System.Linq; using System.Text.RegularExpressions; @@ -44,87 +43,83 @@ public static partial class Parser "One Shot", "One-Shot", "Prologue", "TPB", "Trade Paper Back", "Omnibus", "Compendium", "Absolute", "Graphic Novel", "GN", "FCBD", "Giant Size"); - private static readonly char[] LeadingZeroesTrimChars = new[] { '0' }; + private static readonly char[] LeadingZeroesTrimChars = ['0']; - private static readonly char[] SpacesAndSeparators = { '\0', '\t', '\r', ' ', '-', ','}; + private static readonly char[] SpacesAndSeparators = ['\0', '\t', '\r', ' ', '-', ',']; private const string Number = @"\d+(\.\d)?"; private const string NumberRange = Number + @"(-" + Number + @")?"; /// - /// non greedy matching of a string where parenthesis are balanced + /// non-greedy matching of a string where parenthesis are balanced /// public const string BalancedParen = @"(?:[^()]|(?\()|(?<-open>\)))*?(?(open)(?!))"; /// - /// non greedy matching of a string where square brackets are balanced + /// non-greedy matching of a string where square brackets are balanced /// public const string BalancedBracket = @"(?:[^\[\]]|(?\[)|(?<-open>\]))*?(?(open)(?!))"; /// /// Matches [Complete], release tags like [kmts] but not [ Complete ] or [kmts ] /// private const string TagsInBrackets = $@"\[(?!\s){BalancedBracket}(? - /// Common regex patterns present in both Comics and Mangas - /// - private const string CommonSpecial = @"Specials?|One[- ]?Shot|Extra(?:\sChapter)?(?=\s)|Art Collection|Side Stories|Bonus"; /// /// Matches against font-family css syntax. Does not match if url import has data: starting, as that is binary data /// /// See here for some examples https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face - public static readonly Regex FontSrcUrlRegex = new Regex(@"(?(?:src:\s?)?(?:url|local)\((?!data:)" + "(?:[\"']?)" + @"(?!data:))" - + "(?(?!data:)[^\"']+?)" + "(?[\"']?" + @"\);?)", + public static readonly Regex FontSrcUrlRegex = new(@"(?(?:src:\s?)?(?:url|local)\((?!data:)" + "(?:[\"']?)" + @"(?!data:))" + + "(?(?!data:)[^\"']+?)" + "(?[\"']?" + @"\);?)", MatchOptions, RegexTimeout); /// /// https://developer.mozilla.org/en-US/docs/Web/CSS/@import /// - public static readonly Regex CssImportUrlRegex = new Regex("(@import\\s([\"|']|url\\([\"|']))(?[^'\"]+)([\"|']\\)?);", + public static readonly Regex CssImportUrlRegex = new("(@import\\s([\"|']|url\\([\"|']))(?[^'\"]+)([\"|']\\)?);", MatchOptions | RegexOptions.Multiline, RegexTimeout); /// /// Misc css image references, like background-image: url(), border-image, or list-style-image /// /// Original prepend: (background|border|list-style)-image:\s?)? - public static readonly Regex CssImageUrlRegex = new Regex(@"(url\((?!data:).(?!data:))" + "(?(?!data:)[^\"']*)" + @"(.\))", + public static readonly Regex CssImageUrlRegex = new(@"(url\((?!data:).(?!data:))" + "(?(?!data:)[^\"']*)" + @"(.\))", MatchOptions, RegexTimeout); - private static readonly Regex ImageRegex = new Regex(ImageFileExtensions, + private static readonly Regex ImageRegex = new(ImageFileExtensions, MatchOptions, RegexTimeout); - private static readonly Regex ArchiveFileRegex = new Regex(ArchiveFileExtensions, + private static readonly Regex ArchiveFileRegex = new(ArchiveFileExtensions, MatchOptions, RegexTimeout); - private static readonly Regex ComicInfoArchiveRegex = new Regex(@"\.cbz|\.cbr|\.cb7|\.cbt", + private static readonly Regex ComicInfoArchiveRegex = new(@"\.cbz|\.cbr|\.cb7|\.cbt", MatchOptions, RegexTimeout); - private static readonly Regex XmlRegex = new Regex(XmlRegexExtensions, + private static readonly Regex XmlRegex = new(XmlRegexExtensions, MatchOptions, RegexTimeout); - private static readonly Regex BookFileRegex = new Regex(BookFileExtensions, + private static readonly Regex BookFileRegex = new(BookFileExtensions, MatchOptions, RegexTimeout); - private static readonly Regex CoverImageRegex = new Regex(@"(? /// Normalize everything within Kavita. Some characters don't fall under Unicode, like full-width characters and need to be /// added on a case-by-case basis. /// - private static readonly Regex NormalizeRegex = new Regex(@"[^\p{L}0-9\+!*!+]", + private static readonly Regex NormalizeRegex = new(@"[^\p{L}0-9\+!*!+]", MatchOptions, RegexTimeout); /// /// Supports Batman (2020) or Batman (2) /// - private static readonly Regex SeriesAndYearRegex = new Regex(@"^\D+\s\((?\d+)\)$", + private static readonly Regex SeriesAndYearRegex = new(@"^\D+\s\((?\d+)\)$", MatchOptions, RegexTimeout); /// /// Recognizes the Special token only /// - private static readonly Regex SpecialTokenRegex = new Regex(@"SP\d+", + private static readonly Regex SpecialTokenRegex = new(@"SP\d+", MatchOptions, RegexTimeout); - private static readonly Regex[] MangaVolumeRegex = new[] - { + private static readonly Regex[] MangaVolumeRegex = + [ // Thai Volume: เล่ม n -> Volume n new Regex( @"(เล่ม|เล่มที่)(\s)?(\.?)(\s|_)?(?\d+(\-\d+)?(\.\d+)?)", @@ -197,11 +192,11 @@ public static partial class Parser // Russian Volume: n Том -> Volume n new Regex( @"(\s|_)?(?\d+(?:(\-)\d+)?)(\s|_)Том(а?)", - MatchOptions, RegexTimeout), - }; + MatchOptions, RegexTimeout) + ]; - private static readonly Regex[] MangaSeriesRegex = new[] - { + private static readonly Regex[] MangaSeriesRegex = + [ // Thai Volume: เล่ม n -> Volume n new Regex( @"(?.+?)(เล่ม|เล่มที่)(\s)?(\.?)(\s|_)?(?\d+(\-\d+)?(\.\d+)?)", @@ -374,12 +369,12 @@ public static partial class Parser // Japanese Volume: n巻 -> Volume n new Regex( @"(?.+?)第(?\d+(?:(\-)\d+)?)巻", - MatchOptions, RegexTimeout), + MatchOptions, RegexTimeout) - }; + ]; - private static readonly Regex[] ComicSeriesRegex = new[] - { + private static readonly Regex[] ComicSeriesRegex = + [ // Thai Volume: เล่ม n -> Volume n new Regex( @"(?.+?)(เล่ม|เล่มที่)(\s)?(\.?)(\s|_)?(?\d+(\-\d+)?(\.\d+)?)", @@ -467,11 +462,11 @@ public static partial class Parser // MUST BE LAST: Batman & Daredevil - King of New York new Regex( @"^(?.*)", - MatchOptions, RegexTimeout), - }; + MatchOptions, RegexTimeout) + ]; - private static readonly Regex[] ComicVolumeRegex = new[] - { + private static readonly Regex[] ComicVolumeRegex = + [ // Thai Volume: เล่ม n -> Volume n new Regex( @"(เล่ม|เล่มที่)(\s)?(\.?)(\s|_)?(?\d+(\-\d+)?(\.\d+)?)", @@ -507,11 +502,11 @@ public static partial class Parser // Russian Volume: n Том -> Volume n new Regex( @"(\s|_)?(?\d+(?:(\-)\d+)?)(\s|_)Том(а?)", - MatchOptions, RegexTimeout), - }; + MatchOptions, RegexTimeout) + ]; - private static readonly Regex[] ComicChapterRegex = new[] - { + private static readonly Regex[] ComicChapterRegex = + [ // Thai Volume: บทที่ n -> Chapter n, ตอนที่ n -> Chapter n new Regex( @"(บทที่|ตอนที่)(\s)?(\.?)(\s|_)?(?\d+(\-\d+)?(\.\d+)?)", @@ -576,11 +571,11 @@ public static partial class Parser // spawn-123, spawn-chapter-123 (from https://github.com/Girbons/comics-downloader) new Regex( @"^(?.+?)-(chapter-)?(?\d+)", - MatchOptions, RegexTimeout), - }; + MatchOptions, RegexTimeout) + ]; - private static readonly Regex[] MangaChapterRegex = new[] - { + private static readonly Regex[] MangaChapterRegex = + [ // Thai Chapter: บทที่ n -> Chapter n, ตอนที่ n -> Chapter n, เล่ม n -> Volume n, เล่มที่ n -> Volume n new Regex( @"(?((เล่ม|เล่มที่))?(\s|_)?\.?\d+)(\s|_)(บทที่|ตอนที่)\.?(\s|_)?(?\d+)", @@ -645,8 +640,8 @@ public static partial class Parser // Russian Chapter: n Главa -> Chapter n new Regex( @"(?!Том)(?\d+(?:\.\d+|-\d+)?)(\s|_)(Глава|глава|Главы|Глава)", - MatchOptions, RegexTimeout), - }; + MatchOptions, RegexTimeout) + ]; private static readonly Regex MangaEditionRegex = new Regex( // Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz @@ -661,25 +656,6 @@ public static partial class Parser MatchOptions, RegexTimeout ); - private static readonly Regex MangaSpecialRegex = new Regex( - // All Keywords, does not account for checking if contains volume/chapter identification. Parser.Parse() will handle. - $@"\b(?:{CommonSpecial}|Omake)\b", - MatchOptions, RegexTimeout - ); - - private static readonly Regex ComicSpecialRegex = new Regex( - // All Keywords, does not account for checking if contains volume/chapter identification. Parser.Parse() will handle. - $@"\b(?:{CommonSpecial}|\d.+?(\W|-|^)Annual|Annual(\W|-|$|\s#)|Book \d.+?|Compendium(\W|-|$|\s.+?)|Omnibus(\W|-|$|\s.+?)|FCBD \d.+?|Absolute(\W|-|$|\s.+?)|Preview(\W|-|$|\s.+?)|Hors[ -]S[ée]rie|TPB|HS|THS)\b", - MatchOptions, RegexTimeout - ); - - private static readonly Regex EuropeanComicRegex = new Regex( - // All Keywords, does not account for checking if contains volume/chapter identification. Parser.Parse() will handle. - @"\b(?:Bd[-\s]Fr)\b", - MatchOptions, RegexTimeout - ); - - // If SP\d+ is in the filename, we force treat it as a special regardless if volume or chapter might have been found. private static readonly Regex SpecialMarkerRegex = new Regex( @"SP\d+", @@ -732,20 +708,6 @@ public static partial class Parser return HasSpecialMarker(filePath); } - private static bool IsMangaSpecial(string? filePath) - { - if (string.IsNullOrEmpty(filePath)) return false; - return HasSpecialMarker(filePath); - } - - private static bool IsComicSpecial(string? filePath) - { - if (string.IsNullOrEmpty(filePath)) return false; - return HasSpecialMarker(filePath); - } - - - public static string ParseMangaSeries(string filename) { foreach (var regex in MangaSeriesRegex) @@ -932,22 +894,6 @@ public static partial class Parser return title; } - private static string RemoveMangaSpecialTags(string title) - { - return MangaSpecialRegex.Replace(title, string.Empty); - } - - private static string RemoveEuropeanTags(string title) - { - return EuropeanComicRegex.Replace(title, string.Empty); - } - - private static string RemoveComicSpecialTags(string title) - { - return ComicSpecialRegex.Replace(title, string.Empty); - } - - /// /// Translates _ -> spaces, trims front and back of string, removes release groups @@ -966,20 +912,6 @@ public static partial class Parser title = RemoveEditionTagHolders(title); - // if (replaceSpecials) - // { - // if (isComic) - // { - // title = RemoveComicSpecialTags(title); - // title = RemoveEuropeanTags(title); - // } - // else - // { - // title = RemoveMangaSpecialTags(title); - // } - // } - - title = title.Trim(SpacesAndSeparators); title = EmptySpaceRegex.Replace(title, " "); @@ -1110,11 +1042,6 @@ public static partial class Parser { if (string.IsNullOrEmpty(name)) return name; var cleaned = SpecialTokenRegex.Replace(name.Replace('_', ' '), string.Empty).Trim(); - var lastIndex = cleaned.LastIndexOf('.'); - if (lastIndex > 0) - { - cleaned = cleaned.Substring(0, cleaned.LastIndexOf('.')).Trim(); - } return string.IsNullOrEmpty(cleaned) ? name : cleaned; } @@ -1132,7 +1059,7 @@ public static partial class Parser } /// - /// Validates that a Path doesn't start with certain blacklisted folders, like __MACOSX, @Recently-Snapshot, etc and that if a full path, the filename + /// Validates that a Path doesn't start with certain blacklisted folders, like __MACOSX, @Recently-Snapshot, etc. and that if a full path, the filename /// doesn't start with ._, which is a metadata file on MACOSX. /// /// diff --git a/API/Services/Tasks/VersionUpdaterService.cs b/API/Services/Tasks/VersionUpdaterService.cs index c1f0cf268..43b3d9c6a 100644 --- a/API/Services/Tasks/VersionUpdaterService.cs +++ b/API/Services/Tasks/VersionUpdaterService.cs @@ -51,7 +51,7 @@ public interface IVersionUpdaterService Task CheckForUpdate(); Task PushUpdate(UpdateNotificationDto update); Task> GetAllReleases(int count = 0); - Task GetNumberOfReleasesBehind(); + Task GetNumberOfReleasesBehind(bool stableOnly = false); } @@ -112,6 +112,10 @@ public partial class VersionUpdaterService : IVersionUpdaterService return dto; } + /// + /// Will add any extra (nightly) updates from the latest stable. Does not back-fill anything prior to the latest stable. + /// + /// private async Task EnrichWithNightlyInfo(List dtos) { var dto = dtos[0]; // Latest version @@ -301,7 +305,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService } // If we're on a nightly build, enrich the information - if (updateDtos.Count != 0 && BuildInfo.Version > new Version(updateDtos[0].UpdateVersion)) + if (updateDtos.Count != 0) // && BuildInfo.Version > new Version(updateDtos[0].UpdateVersion) { await EnrichWithNightlyInfo(updateDtos); } @@ -397,22 +401,25 @@ public partial class VersionUpdaterService : IVersionUpdaterService } - public async Task GetNumberOfReleasesBehind() + /// + /// Returns the number of releases ahead of this install version. If this install version is on a nightly, + /// then include nightly releases, otherwise only count Stable releases. + /// + /// Only count Stable releases + /// + public async Task GetNumberOfReleasesBehind(bool stableOnly = false) { var updates = await GetAllReleases(); // If the user is on nightly, then we need to handle releases behind differently - if (updates[0].IsPrerelease) + if (!stableOnly && (updates[0].IsPrerelease || updates[0].IsOnNightlyInRelease)) { - return Math.Min(0, updates - .TakeWhile(update => update.UpdateVersion != update.CurrentVersion) - .Count() - 1); + return updates.Count(u => u.IsReleaseNewer); } - return Math.Min(0, updates + return updates .Where(update => !update.IsPrerelease) - .TakeWhile(update => update.UpdateVersion != update.CurrentVersion) - .Count()); + .Count(u => u.IsReleaseNewer); } private UpdateNotificationDto? CreateDto(GithubReleaseMetadata? update) diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index d23887774..292deb7bb 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -12,8 +12,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Kavita.sln.DotSettings b/Kavita.sln.DotSettings index 92adaa72f..b46c328cd 100644 --- a/Kavita.sln.DotSettings +++ b/Kavita.sln.DotSettings @@ -2,9 +2,13 @@ ExplicitlyExcluded True True + True + True + True True True True + True True True True diff --git a/UI/Web/src/app/_services/server.service.ts b/UI/Web/src/app/_services/server.service.ts index 0eb179779..6ee0cd8b7 100644 --- a/UI/Web/src/app/_services/server.service.ts +++ b/UI/Web/src/app/_services/server.service.ts @@ -53,8 +53,8 @@ export class ServerService { return this.http.get(this.baseUrl + 'server/check-update'); } - checkHowOutOfDate() { - return this.http.get(this.baseUrl + 'server/check-out-of-date', TextResonse) + checkHowOutOfDate(stableOnly: boolean = true) { + return this.http.get(this.baseUrl + `server/check-out-of-date?stableOnly=${stableOnly}`, TextResonse) .pipe(map(r => parseInt(r, 10))); } diff --git a/UI/Web/src/app/_services/version.service.ts b/UI/Web/src/app/_services/version.service.ts index e16a18d1f..169fc11c5 100644 --- a/UI/Web/src/app/_services/version.service.ts +++ b/UI/Web/src/app/_services/version.service.ts @@ -2,7 +2,7 @@ import {inject, Injectable, OnDestroy} from '@angular/core'; import {interval, Subscription, switchMap} from 'rxjs'; import {ServerService} from "./server.service"; import {AccountService} from "./account.service"; -import {filter, tap} from "rxjs/operators"; +import {filter, take} from "rxjs/operators"; import {NgbModal} from "@ng-bootstrap/ng-bootstrap"; import {NewUpdateModalComponent} from "../announcements/_components/new-update-modal/new-update-modal.component"; import {OutOfDateModalComponent} from "../announcements/_components/out-of-date-modal/out-of-date-modal.component"; @@ -16,82 +16,191 @@ export class VersionService implements OnDestroy{ private readonly accountService = inject(AccountService); private readonly modalService = inject(NgbModal); - public static readonly versionKey = 'kavita--version'; - private readonly checkInterval = 600000; // 10 minutes (600000) - private periodicCheckSubscription?: Subscription; + public static readonly SERVER_VERSION_KEY = 'kavita--version'; + public static readonly CLIENT_REFRESH_KEY = 'kavita--client-refresh-last-shown'; + public static readonly NEW_UPDATE_KEY = 'kavita--new-update-last-shown'; + public static readonly OUT_OF_BAND_KEY = 'kavita--out-of-band-last-shown'; + + // Notification intervals + private readonly CLIENT_REFRESH_INTERVAL = 0; // Show immediately (once) + private readonly NEW_UPDATE_INTERVAL = 7 * 24 * 60 * 60 * 1000; // 1 week in milliseconds + private readonly OUT_OF_BAND_INTERVAL = 30 * 24 * 60 * 60 * 1000; // 1 month in milliseconds + + // Check intervals + private readonly VERSION_CHECK_INTERVAL = 30 * 60 * 1000; // 30 minutes + private readonly OUT_OF_DATE_CHECK_INTERVAL = this.VERSION_CHECK_INTERVAL; // 2 * 60 * 60 * 1000; // 2 hours + + private readonly OUT_Of_BAND_AMOUNT = 2; // How many releases before we show "You're X releases out of date" + + + private versionCheckSubscription?: Subscription; private outOfDateCheckSubscription?: Subscription; private modalOpen = false; constructor() { - this.startPeriodicUpdateCheck(); + this.startVersionCheck(); this.startOutOfDateCheck(); } ngOnDestroy() { - this.periodicCheckSubscription?.unsubscribe(); + this.versionCheckSubscription?.unsubscribe(); this.outOfDateCheckSubscription?.unsubscribe(); } - private startOutOfDateCheck() { - // Every hour, have the UI check for an update. People seriously stay out of date - this.outOfDateCheckSubscription = interval(2* 60 * 60 * 1000) // 2 hours in milliseconds + /** + * Periodic check for server version to detect client refreshes and new updates + */ + private startVersionCheck(): void { + console.log('Starting version checker'); + this.versionCheckSubscription = interval(this.VERSION_CHECK_INTERVAL) .pipe( switchMap(() => this.accountService.currentUser$), - filter(u => u !== undefined && this.accountService.hasAdminRole(u)), - switchMap(_ => this.serverService.checkHowOutOfDate()), - filter(versionOutOfDate => { - return !isNaN(versionOutOfDate) && versionOutOfDate > 2; - }), - tap(versionOutOfDate => { - if (!this.modalService.hasOpenModals()) { - const ref = this.modalService.open(OutOfDateModalComponent, {size: 'xl', fullscreen: 'md'}); - ref.componentInstance.versionsOutOfDate = versionOutOfDate; - } - }) - ) - .subscribe(); - } - - private startPeriodicUpdateCheck(): void { - console.log('Starting periodic version update checker'); - this.periodicCheckSubscription = interval(this.checkInterval) - .pipe( - switchMap(_ => this.accountService.currentUser$), - filter(user => user !== undefined && !this.modalOpen), + filter(user => !!user && !this.modalOpen), switchMap(user => this.serverService.getVersion(user!.apiKey)), + filter(update => !!update), ).subscribe(version => this.handleVersionUpdate(version)); } - private handleVersionUpdate(version: string) { + /** + * Checks if the server is out of date compared to the latest release + */ + private startOutOfDateCheck() { + console.log('Starting out-of-date checker'); + this.outOfDateCheckSubscription = interval(this.OUT_OF_DATE_CHECK_INTERVAL) + .pipe( + switchMap(() => this.accountService.currentUser$), + filter(u => u !== undefined && this.accountService.hasAdminRole(u) && !this.modalOpen), + switchMap(_ => this.serverService.checkHowOutOfDate(true)), + filter(versionsOutOfDate => !isNaN(versionsOutOfDate) && versionsOutOfDate > this.OUT_Of_BAND_AMOUNT), + ) + .subscribe(versionsOutOfDate => this.handleOutOfDateNotification(versionsOutOfDate)); + } + + /** + * Handles the version check response to determine if client refresh or new update notification is needed + */ + private handleVersionUpdate(serverVersion: string) { if (this.modalOpen) return; - // Pause periodic checks while the modal is open - this.periodicCheckSubscription?.unsubscribe(); + const cachedVersion = localStorage.getItem(VersionService.SERVER_VERSION_KEY); + console.log('Server version:', serverVersion, 'Cached version:', cachedVersion); - const cachedVersion = localStorage.getItem(VersionService.versionKey); - console.log('Kavita version: ', version, ' Running version: ', cachedVersion); - - const hasChanged = cachedVersion == null || cachedVersion != version; - if (hasChanged) { - this.modalOpen = true; - - this.serverService.getChangelog(1).subscribe(changelog => { - const ref = this.modalService.open(NewUpdateModalComponent, {size: 'lg', keyboard: false}); - ref.componentInstance.version = version; - ref.componentInstance.update = changelog[0]; - - ref.closed.subscribe(_ => this.onModalClosed()); - ref.dismissed.subscribe(_ => this.onModalClosed()); - - }); + const isNewServerVersion = cachedVersion !== null && cachedVersion !== serverVersion; + // Case 1: Client Refresh needed (server has updated since last client load) + if (isNewServerVersion) { + this.showClientRefreshNotification(serverVersion); + } + // Case 2: Check for new updates (for server admin) + else { + this.checkForNewUpdates(); } - localStorage.setItem(VersionService.versionKey, version); + // Always update the cached version + localStorage.setItem(VersionService.SERVER_VERSION_KEY, serverVersion); } - private onModalClosed() { + /** + * Shows a notification that client refresh is needed due to server update + */ + private showClientRefreshNotification(newVersion: string): void { + this.pauseChecks(); + + // Client refresh notifications should always show (once) + this.modalOpen = true; + + this.serverService.getChangelog(1).subscribe(changelog => { + const ref = this.modalService.open(NewUpdateModalComponent, { + size: 'lg', + keyboard: false, + backdrop: 'static' // Prevent closing by clicking outside + }); + + ref.componentInstance.version = newVersion; + ref.componentInstance.update = changelog[0]; + ref.componentInstance.requiresRefresh = true; + + // Update the last shown timestamp + localStorage.setItem(VersionService.CLIENT_REFRESH_KEY, Date.now().toString()); + + ref.closed.subscribe(_ => this.onModalClosed()); + ref.dismissed.subscribe(_ => this.onModalClosed()); + }); + } + + /** + * Checks for new server updates and shows notification if appropriate + */ + private checkForNewUpdates(): void { + this.accountService.currentUser$ + .pipe( + take(1), + filter(user => user !== undefined && this.accountService.hasAdminRole(user)), + switchMap(_ => this.serverService.checkHowOutOfDate()), + filter(versionsOutOfDate => !isNaN(versionsOutOfDate) && versionsOutOfDate > 0 && versionsOutOfDate <= this.OUT_Of_BAND_AMOUNT) + ) + .subscribe(versionsOutOfDate => { + const lastShown = Number(localStorage.getItem(VersionService.NEW_UPDATE_KEY) || '0'); + const currentTime = Date.now(); + + // Show notification if it hasn't been shown in the last week + if (currentTime - lastShown >= this.NEW_UPDATE_INTERVAL) { + this.pauseChecks(); + this.modalOpen = true; + + this.serverService.getChangelog(1).subscribe(changelog => { + const ref = this.modalService.open(NewUpdateModalComponent, { size: 'lg' }); + ref.componentInstance.versionsOutOfDate = versionsOutOfDate; + ref.componentInstance.update = changelog[0]; + ref.componentInstance.requiresRefresh = false; + + // Update the last shown timestamp + localStorage.setItem(VersionService.NEW_UPDATE_KEY, currentTime.toString()); + + ref.closed.subscribe(_ => this.onModalClosed()); + ref.dismissed.subscribe(_ => this.onModalClosed()); + }); + } + }); + } + + /** + * Handles the notification for servers that are significantly out of date + */ + private handleOutOfDateNotification(versionsOutOfDate: number): void { + const lastShown = Number(localStorage.getItem(VersionService.OUT_OF_BAND_KEY) || '0'); + const currentTime = Date.now(); + + // Show notification if it hasn't been shown in the last month + if (currentTime - lastShown >= this.OUT_OF_BAND_INTERVAL) { + this.pauseChecks(); + this.modalOpen = true; + + const ref = this.modalService.open(OutOfDateModalComponent, { size: 'xl', fullscreen: 'md' }); + ref.componentInstance.versionsOutOfDate = versionsOutOfDate; + + // Update the last shown timestamp + localStorage.setItem(VersionService.OUT_OF_BAND_KEY, currentTime.toString()); + + ref.closed.subscribe(_ => this.onModalClosed()); + ref.dismissed.subscribe(_ => this.onModalClosed()); + } + } + + /** + * Pauses all version checks while modals are open + */ + private pauseChecks(): void { + this.versionCheckSubscription?.unsubscribe(); + this.outOfDateCheckSubscription?.unsubscribe(); + } + + /** + * Resumes all checks when modals are closed + */ + private onModalClosed(): void { this.modalOpen = false; - this.startPeriodicUpdateCheck(); + this.startVersionCheck(); + this.startOutOfDateCheck(); } } diff --git a/UI/Web/src/app/_single-module/related-tab/related-tab.component.ts b/UI/Web/src/app/_single-module/related-tab/related-tab.component.ts index c02c4ac53..cbc6d2e4b 100644 --- a/UI/Web/src/app/_single-module/related-tab/related-tab.component.ts +++ b/UI/Web/src/app/_single-module/related-tab/related-tab.component.ts @@ -29,7 +29,7 @@ export interface RelatedSeriesPair { styleUrl: './related-tab.component.scss', changeDetection: ChangeDetectionStrategy.OnPush }) -export class RelatedTabComponent implements OnInit { +export class RelatedTabComponent { protected readonly imageService = inject(ImageService); protected readonly router = inject(Router); @@ -40,10 +40,6 @@ export class RelatedTabComponent implements OnInit { @Input() bookmarks: Array = []; @Input() libraryId!: number; - ngOnInit() { - console.log('bookmarks: ', this.bookmarks); - } - openReadingList(readingList: ReadingList) { this.router.navigate(['lists', readingList.id]); } diff --git a/UI/Web/src/app/admin/license/license.component.ts b/UI/Web/src/app/admin/license/license.component.ts index 0ab987c32..4e12293f2 100644 --- a/UI/Web/src/app/admin/license/license.component.ts +++ b/UI/Web/src/app/admin/license/license.component.ts @@ -224,7 +224,6 @@ export class LicenseComponent implements OnInit { toggleViewMode() { this.isViewMode = !this.isViewMode; - console.log('edit mode: ', !this.isViewMode) this.cdRef.markForCheck(); this.resetForm(); } diff --git a/UI/Web/src/app/admin/manage-logs/manage-logs.component.ts b/UI/Web/src/app/admin/manage-logs/manage-logs.component.ts index 4e69801c3..c3c42abb3 100644 --- a/UI/Web/src/app/admin/manage-logs/manage-logs.component.ts +++ b/UI/Web/src/app/admin/manage-logs/manage-logs.component.ts @@ -64,7 +64,7 @@ export class ManageLogsComponent implements OnInit, OnDestroy { // unsubscribe from signalr connection if (this.hubConnection) { this.hubConnection.stop().catch(err => console.error(err)); - console.log('Stoping log connection'); + console.log('Stopping log connection'); } } diff --git a/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts b/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts index f8c60660b..2a2fbf0f5 100644 --- a/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts +++ b/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts @@ -7,7 +7,7 @@ import {shareReplay} from 'rxjs/operators'; import {debounceTime, defer, distinctUntilChanged, filter, forkJoin, Observable, of, switchMap, tap} from 'rxjs'; import {ServerService} from 'src/app/_services/server.service'; import {Job} from 'src/app/_models/job/job'; -import {UpdateNotificationModalComponent} from 'src/app/shared/update-notification/update-notification-modal.component'; +import {UpdateNotificationModalComponent} from 'src/app/announcements/_components/update-notification/update-notification-modal.component'; import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; import {DownloadService} from 'src/app/shared/_services/download.service'; import {DefaultValuePipe} from '../../_pipes/default-value.pipe'; @@ -134,6 +134,7 @@ export class ManageTasksSettingsComponent implements OnInit { } }, ]; + customOption = 'custom'; @@ -305,7 +306,6 @@ export class ManageTasksSettingsComponent implements OnInit { modelSettings.taskCleanup = this.settingsForm.get('taskCleanupCustom')?.value; } - console.log('modelSettings: ', modelSettings); return modelSettings; } diff --git a/UI/Web/src/app/all-series/_components/all-series/all-series.component.ts b/UI/Web/src/app/all-series/_components/all-series/all-series.component.ts index 44b651875..fdd6638b1 100644 --- a/UI/Web/src/app/all-series/_components/all-series/all-series.component.ts +++ b/UI/Web/src/app/all-series/_components/all-series/all-series.component.ts @@ -112,7 +112,6 @@ export class AllSeriesComponent implements OnInit { private readonly cdRef: ChangeDetectorRef) { this.router.routeReuseStrategy.shouldReuseRoute = () => false; - console.log('url: ', this.route.snapshot); this.filterUtilityService.filterPresetsFromUrl(this.route.snapshot).subscribe(filter => { this.filter = filter; diff --git a/UI/Web/src/app/announcements/_components/new-update-modal/new-update-modal.component.ts b/UI/Web/src/app/announcements/_components/new-update-modal/new-update-modal.component.ts index d1eb43285..b66c73da3 100644 --- a/UI/Web/src/app/announcements/_components/new-update-modal/new-update-modal.component.ts +++ b/UI/Web/src/app/announcements/_components/new-update-modal/new-update-modal.component.ts @@ -7,13 +7,14 @@ import {SafeHtmlPipe} from "../../../_pipes/safe-html.pipe"; import {VersionService} from "../../../_services/version.service"; import {ChangelogUpdateItemComponent} from "../changelog-update-item/changelog-update-item.component"; +/** + * This modal is used when an update occurred and the UI needs to be refreshed to get the latest JS libraries + */ @Component({ selector: 'app-new-update-modal', standalone: true, imports: [ TranslocoDirective, - UpdateSectionComponent, - SafeHtmlPipe, ChangelogUpdateItemComponent ], templateUrl: './new-update-modal.component.html', @@ -41,8 +42,6 @@ export class NewUpdateModalComponent { private applyUpdate(version: string): void { this.bustLocaleCache(); - console.log('Setting version key: ', version); - localStorage.setItem(VersionService.versionKey, version); location.reload(); } @@ -54,8 +53,10 @@ export class NewUpdateModalComponent { (this.translocoService as any).cache.delete(locale); (this.translocoService as any).cache.clear(); - // TODO: Retrigger transloco - this.translocoService.setActiveLang(locale); + // Retrigger transloco + setTimeout(() => { + this.translocoService.setActiveLang(locale); + }, 10); } } diff --git a/UI/Web/src/app/shared/update-notification/update-notification-modal.component.html b/UI/Web/src/app/announcements/_components/update-notification/update-notification-modal.component.html similarity index 85% rename from UI/Web/src/app/shared/update-notification/update-notification-modal.component.html rename to UI/Web/src/app/announcements/_components/update-notification/update-notification-modal.component.html index b8d3c2b38..497c1a35d 100644 --- a/UI/Web/src/app/shared/update-notification/update-notification-modal.component.html +++ b/UI/Web/src/app/announcements/_components/update-notification/update-notification-modal.component.html @@ -4,8 +4,9 @@
    -
  • {{t('comics-label', {value: item.comicsTime})}}
  • -
  • {{t('manga-label', {value: item.mangaTime})}}
  • -
  • {{t('books-label', {value: item.booksTime})}}
  • +
  • {{t('comics-label', {value: item.comicsTime | number:'1.0-1'})}}
  • +
  • {{t('manga-label', {value: item.mangaTime | number:'1.0-1'})}}
  • +
  • {{t('books-label', {value: item.booksTime | number:'1.0-1'})}}
diff --git a/UI/Web/src/app/statistics/_components/top-readers/top-readers.component.ts b/UI/Web/src/app/statistics/_components/top-readers/top-readers.component.ts index d8bb1e998..3b6f76049 100644 --- a/UI/Web/src/app/statistics/_components/top-readers/top-readers.component.ts +++ b/UI/Web/src/app/statistics/_components/top-readers/top-readers.component.ts @@ -11,7 +11,7 @@ import { Observable, switchMap, shareReplay } from 'rxjs'; import { StatisticsService } from 'src/app/_services/statistics.service'; import { TopUserRead } from '../../_models/top-reads'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; -import { AsyncPipe } from '@angular/common'; +import {AsyncPipe, DecimalPipe} from '@angular/common'; import {TranslocoDirective} from "@jsverse/transloco"; import {CarouselReelComponent} from "../../../carousel/_components/carousel-reel/carousel-reel.component"; @@ -29,18 +29,20 @@ export const TimePeriods: Array<{title: string, value: number}> = styleUrls: ['./top-readers.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [ReactiveFormsModule, AsyncPipe, TranslocoDirective, CarouselReelComponent] + imports: [ReactiveFormsModule, AsyncPipe, TranslocoDirective, CarouselReelComponent, DecimalPipe] }) export class TopReadersComponent implements OnInit { private readonly destroyRef = inject(DestroyRef); + private readonly statsService = inject(StatisticsService); + private readonly cdRef = inject(ChangeDetectorRef); formGroup: FormGroup; timePeriods = TimePeriods; users$: Observable; - constructor(private statsService: StatisticsService, private readonly cdRef: ChangeDetectorRef) { + constructor() { this.formGroup = new FormGroup({ 'days': new FormControl(this.timePeriods[0].value, []), });