mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-05-31 20:24:27 -04:00
Version Update Modal Rework + A few bugfixes (#3664)
This commit is contained in:
parent
9fb3bdd548
commit
43d0d1277f
@ -6,11 +6,11 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.2" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.3" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
|
||||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||||
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="22.0.11" />
|
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="22.0.12" />
|
||||||
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="22.0.11" />
|
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="22.0.12" />
|
||||||
<PackageReference Include="xunit" Version="2.9.3" />
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
@ -108,4 +108,19 @@ public abstract class AbstractDbTest : AbstractFsTest , IDisposable
|
|||||||
_context.Dispose();
|
_context.Dispose();
|
||||||
_connection.Dispose();
|
_connection.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Add a role to an existing User. Commits.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userId"></param>
|
||||||
|
/// <param name="roleName"></param>
|
||||||
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
31
API.Tests/Extensions/EncodeFormatExtensionsTests.cs
Normal file
31
API.Tests/Extensions/EncodeFormatExtensionsTests.cs
Normal file
@ -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, string>
|
||||||
|
{
|
||||||
|
{ EncodeFormat.PNG, ".png" },
|
||||||
|
{ EncodeFormat.WEBP, ".webp" },
|
||||||
|
{ EncodeFormat.AVIF, ".avif" }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
foreach (var format in Enum.GetValues(typeof(EncodeFormat)).Cast<EncodeFormat>())
|
||||||
|
{
|
||||||
|
var extension = format.GetExtension();
|
||||||
|
Assert.Equal(expectedExtensions[format], extension);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
81
API.Tests/Extensions/VersionExtensionTests.cs
Normal file
81
API.Tests/Extensions/VersionExtensionTests.cs
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
258
API.Tests/Helpers/ReviewHelperTests.cs
Normal file
258
API.Tests/Helpers/ReviewHelperTests.cs
Normal file
@ -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<UserReviewDto>();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = ReviewHelper.SelectSpectrumOfReviews(reviews).ToList();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Empty(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SelectSpectrumOfReviews_ResultsOrderedByScoreDescending()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var reviews = new List<UserReviewDto>
|
||||||
|
{
|
||||||
|
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 = "<div></div>";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = ReviewHelper.GetCharacters(body);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(string.Empty, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetCharacters_WithLessCharactersThanLimit_ReturnsFullText()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var body = "<p>This is a short review.</p>";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = ReviewHelper.GetCharacters(body);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal("This is a short review.…", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetCharacters_WithMoreCharactersThanLimit_TruncatesText()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var body = "<p>" + new string('a', 200) + "</p>";
|
||||||
|
|
||||||
|
// 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 = "<p>Visible text</p><script>console.log('hidden');</script>";
|
||||||
|
|
||||||
|
// 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 = "<p>This is **bold** and _italic_ text with [link](url).</p>";
|
||||||
|
|
||||||
|
// 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 = """
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1># Header</h1>
|
||||||
|
<p>This is ~~strikethrough~~ and __underlined__ text</p>
|
||||||
|
<p>~~~code block~~~</p>
|
||||||
|
<p>+++highlighted+++</p>
|
||||||
|
<p>img123(image.jpg)</p>
|
||||||
|
</div>
|
||||||
|
""";
|
||||||
|
|
||||||
|
// 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<UserReviewDto> CreateReviewList(int count)
|
||||||
|
{
|
||||||
|
var reviews = new List<UserReviewDto>();
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
|||||||
using System.IO.Abstractions.TestingHelpers;
|
using System.IO;
|
||||||
|
using System.IO.Abstractions.TestingHelpers;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
using API.Services;
|
using API.Services;
|
||||||
using API.Services.Tasks.Scanner.Parser;
|
using API.Services.Tasks.Scanner.Parser;
|
||||||
@ -8,59 +9,54 @@ using Xunit;
|
|||||||
|
|
||||||
namespace API.Tests.Parsers;
|
namespace API.Tests.Parsers;
|
||||||
|
|
||||||
public class BasicParserTests
|
public class BasicParserTests : AbstractFsTest
|
||||||
{
|
{
|
||||||
private readonly BasicParser _parser;
|
private readonly BasicParser _parser;
|
||||||
private readonly ILogger<DirectoryService> _dsLogger = Substitute.For<ILogger<DirectoryService>>();
|
private readonly ILogger<DirectoryService> _dsLogger = Substitute.For<ILogger<DirectoryService>>();
|
||||||
private const string RootDirectory = "C:/Books/";
|
private readonly string _rootDirectory;
|
||||||
|
|
||||||
public BasicParserTests()
|
public BasicParserTests()
|
||||||
{
|
{
|
||||||
var fileSystem = new MockFileSystem();
|
var fileSystem = CreateFileSystem();
|
||||||
fileSystem.AddDirectory("C:/Books/");
|
_rootDirectory = Path.Join(DataDirectory, "Books/");
|
||||||
fileSystem.AddFile("C:/Books/Harry Potter/Harry Potter - Vol 1.epub", new MockFileData(""));
|
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($"{_rootDirectory}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($"{_rootDirectory}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($"{_rootDirectory}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 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);
|
var ds = new DirectoryService(_dsLogger, fileSystem);
|
||||||
_parser = new BasicParser(ds, new ImageParser(ds));
|
_parser = new BasicParser(ds, new ImageParser(ds));
|
||||||
}
|
}
|
||||||
|
|
||||||
#region Parse_Books
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Parse_Manga
|
#region Parse_Manga
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Parse_MangaLibrary_JustCover_ShouldReturnNull()
|
public void Parse_MangaLibrary_JustCover_ShouldReturnNull()
|
||||||
{
|
{
|
||||||
var actual = _parser.Parse(@"C:/Books/Accel World/cover.png", "C:/Books/Accel World/",
|
var actual = _parser.Parse($"{_rootDirectory}Accel World/cover.png", $"{_rootDirectory}Accel World/",
|
||||||
RootDirectory, LibraryType.Manga, null);
|
_rootDirectory, LibraryType.Manga);
|
||||||
Assert.Null(actual);
|
Assert.Null(actual);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Parse_MangaLibrary_OtherImage_ShouldReturnNull()
|
public void Parse_MangaLibrary_OtherImage_ShouldReturnNull()
|
||||||
{
|
{
|
||||||
var actual = _parser.Parse(@"C:/Books/Accel World/page 01.png", "C:/Books/Accel World/",
|
var actual = _parser.Parse($"{_rootDirectory}Accel World/page 01.png", $"{_rootDirectory}Accel World/",
|
||||||
RootDirectory, LibraryType.Manga, null);
|
_rootDirectory, LibraryType.Manga);
|
||||||
Assert.NotNull(actual);
|
Assert.NotNull(actual);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,8 +66,8 @@ public class BasicParserTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Parse_MangaLibrary_VolumeAndChapterInFilename()
|
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/",
|
var actual = _parser.Parse($"{_rootDirectory}Mujaki no Rakuen/Mujaki no Rakuen Vol12 ch76.cbz", $"{_rootDirectory}Mujaki no Rakuen/",
|
||||||
RootDirectory, LibraryType.Manga, null);
|
_rootDirectory, LibraryType.Manga);
|
||||||
Assert.NotNull(actual);
|
Assert.NotNull(actual);
|
||||||
|
|
||||||
Assert.Equal("Mujaki no Rakuen", actual.Series);
|
Assert.Equal("Mujaki no Rakuen", actual.Series);
|
||||||
@ -86,9 +82,9 @@ public class BasicParserTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Parse_MangaLibrary_JustVolumeInFilename()
|
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",
|
var actual = _parser.Parse($"{_rootDirectory}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}Shimoneta to Iu Gainen ga Sonzai Shinai Taikutsu na Sekai Man-hen/",
|
||||||
RootDirectory, LibraryType.Manga, null);
|
_rootDirectory, LibraryType.Manga);
|
||||||
Assert.NotNull(actual);
|
Assert.NotNull(actual);
|
||||||
|
|
||||||
Assert.Equal("Shimoneta to Iu Gainen ga Sonzai Shinai Taikutsu na Sekai Man-hen", actual.Series);
|
Assert.Equal("Shimoneta to Iu Gainen ga Sonzai Shinai Taikutsu na Sekai Man-hen", actual.Series);
|
||||||
@ -103,9 +99,9 @@ public class BasicParserTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Parse_MangaLibrary_JustChapterInFilename()
|
public void Parse_MangaLibrary_JustChapterInFilename()
|
||||||
{
|
{
|
||||||
var actual = _parser.Parse("C:/Books/Beelzebub/Beelzebub_01_[Noodles].zip",
|
var actual = _parser.Parse($"{_rootDirectory}Beelzebub/Beelzebub_01_[Noodles].zip",
|
||||||
"C:/Books/Beelzebub/",
|
$"{_rootDirectory}Beelzebub/",
|
||||||
RootDirectory, LibraryType.Manga, null);
|
_rootDirectory, LibraryType.Manga);
|
||||||
Assert.NotNull(actual);
|
Assert.NotNull(actual);
|
||||||
|
|
||||||
Assert.Equal("Beelzebub", actual.Series);
|
Assert.Equal("Beelzebub", actual.Series);
|
||||||
@ -120,9 +116,9 @@ public class BasicParserTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Parse_MangaLibrary_SpecialMarkerInFilename()
|
public void Parse_MangaLibrary_SpecialMarkerInFilename()
|
||||||
{
|
{
|
||||||
var actual = _parser.Parse("C:/Books/Summer Time Rendering/Specials/Record 014 (between chapter 083 and ch084) SP11.cbr",
|
var actual = _parser.Parse($"{_rootDirectory}Summer Time Rendering/Specials/Record 014 (between chapter 083 and ch084) SP11.cbr",
|
||||||
"C:/Books/Summer Time Rendering/",
|
$"{_rootDirectory}Summer Time Rendering/",
|
||||||
RootDirectory, LibraryType.Manga, null);
|
_rootDirectory, LibraryType.Manga);
|
||||||
Assert.NotNull(actual);
|
Assert.NotNull(actual);
|
||||||
|
|
||||||
Assert.Equal("Summer Time Rendering", actual.Series);
|
Assert.Equal("Summer Time Rendering", actual.Series);
|
||||||
@ -133,36 +129,54 @@ public class BasicParserTests
|
|||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tests that when the filename parses as a speical, it appropriately parses
|
/// Tests that when the filename parses as a special, it appropriately parses
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Parse_MangaLibrary_SpecialInFilename()
|
public void Parse_MangaLibrary_SpecialInFilename()
|
||||||
{
|
{
|
||||||
var actual = _parser.Parse("C:/Books/Summer Time Rendering/Volume SP01.cbr",
|
var actual = _parser.Parse($"{_rootDirectory}Summer Time Rendering/Volume SP01.cbr",
|
||||||
"C:/Books/Summer Time Rendering/",
|
$"{_rootDirectory}Summer Time Rendering/",
|
||||||
RootDirectory, LibraryType.Manga, null);
|
_rootDirectory, LibraryType.Manga);
|
||||||
Assert.NotNull(actual);
|
Assert.NotNull(actual);
|
||||||
|
|
||||||
Assert.Equal("Summer Time Rendering", actual.Series);
|
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.SpecialVolume, actual.Volumes);
|
||||||
Assert.Equal(Parser.DefaultChapter, actual.Chapters);
|
Assert.Equal(Parser.DefaultChapter, actual.Chapters);
|
||||||
Assert.True(actual.IsSpecial);
|
Assert.True(actual.IsSpecial);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tests that when the filename parses as a speical, it appropriately parses
|
/// Tests that when the filename parses as a special, it appropriately parses
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Parse_MangaLibrary_SpecialInFilename2()
|
public void Parse_MangaLibrary_SpecialInFilename2()
|
||||||
{
|
{
|
||||||
var actual = _parser.Parse("M:/Kimi wa Midara na Boku no Joou/Specials/[Renzokusei] Special 1 SP02.zip",
|
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/",
|
"M:/Kimi wa Midara na Boku no Joou/",
|
||||||
RootDirectory, LibraryType.Manga, null);
|
_rootDirectory, LibraryType.Manga);
|
||||||
Assert.NotNull(actual);
|
Assert.NotNull(actual);
|
||||||
|
|
||||||
Assert.Equal("Kimi wa Midara na Boku no Joou", actual.Series);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests that when the filename parses as a special, it appropriately parses
|
||||||
|
/// </summary>
|
||||||
|
[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.SpecialVolume, actual.Volumes);
|
||||||
Assert.Equal(Parser.DefaultChapter, actual.Chapters);
|
Assert.Equal(Parser.DefaultChapter, actual.Chapters);
|
||||||
Assert.True(actual.IsSpecial);
|
Assert.True(actual.IsSpecial);
|
||||||
@ -174,9 +188,9 @@ public class BasicParserTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Parse_MangaLibrary_EditionInFilename()
|
public void Parse_MangaLibrary_EditionInFilename()
|
||||||
{
|
{
|
||||||
var actual = _parser.Parse("C:/Books/Air Gear/Air Gear Omnibus v01 (2016) (Digital) (Shadowcat-Empire).cbz",
|
var actual = _parser.Parse($"{_rootDirectory}Air Gear/Air Gear Omnibus v01 (2016) (Digital) (Shadowcat-Empire).cbz",
|
||||||
"C:/Books/Air Gear/",
|
$"{_rootDirectory}Air Gear/",
|
||||||
RootDirectory, LibraryType.Manga, null);
|
_rootDirectory, LibraryType.Manga);
|
||||||
Assert.NotNull(actual);
|
Assert.NotNull(actual);
|
||||||
|
|
||||||
Assert.Equal("Air Gear", actual.Series);
|
Assert.Equal("Air Gear", actual.Series);
|
||||||
@ -195,9 +209,9 @@ public class BasicParserTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Parse_MangaBooks_JustVolumeInFilename()
|
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",
|
var actual = _parser.Parse($"{_rootDirectory}Epubs/Harrison, Kim - The Good, The Bad, and the Undead - Hollows Vol 2.5.epub",
|
||||||
"C:/Books/Epubs/",
|
$"{_rootDirectory}Epubs/",
|
||||||
RootDirectory, LibraryType.Manga, null);
|
_rootDirectory, LibraryType.Manga);
|
||||||
Assert.NotNull(actual);
|
Assert.NotNull(actual);
|
||||||
|
|
||||||
Assert.Equal("Harrison, Kim - The Good, The Bad, and the Undead - Hollows", actual.Series);
|
Assert.Equal("Harrison, Kim - The Good, The Bad, and the Undead - Hollows", actual.Series);
|
||||||
|
@ -11,14 +11,14 @@ public class ParserInfoTests
|
|||||||
{
|
{
|
||||||
var p1 = new ParserInfo()
|
var p1 = new ParserInfo()
|
||||||
{
|
{
|
||||||
Chapters = API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter,
|
Chapters = Parser.DefaultChapter,
|
||||||
Edition = "",
|
Edition = "",
|
||||||
Format = MangaFormat.Archive,
|
Format = MangaFormat.Archive,
|
||||||
FullFilePath = "/manga/darker than black.cbz",
|
FullFilePath = "/manga/darker than black.cbz",
|
||||||
IsSpecial = false,
|
IsSpecial = false,
|
||||||
Series = "darker than black",
|
Series = "darker than black",
|
||||||
Title = "darker than black",
|
Title = "darker than black",
|
||||||
Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume
|
Volumes = Parser.LooseLeafVolume
|
||||||
};
|
};
|
||||||
|
|
||||||
var p2 = new ParserInfo()
|
var p2 = new ParserInfo()
|
||||||
@ -30,7 +30,7 @@ public class ParserInfoTests
|
|||||||
IsSpecial = false,
|
IsSpecial = false,
|
||||||
Series = "darker than black",
|
Series = "darker than black",
|
||||||
Title = "Darker Than Black",
|
Title = "Darker Than Black",
|
||||||
Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume
|
Volumes = Parser.LooseLeafVolume
|
||||||
};
|
};
|
||||||
|
|
||||||
var expected = new ParserInfo()
|
var expected = new ParserInfo()
|
||||||
@ -42,7 +42,7 @@ public class ParserInfoTests
|
|||||||
IsSpecial = false,
|
IsSpecial = false,
|
||||||
Series = "darker than black",
|
Series = "darker than black",
|
||||||
Title = "darker than black",
|
Title = "darker than black",
|
||||||
Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume
|
Volumes = Parser.LooseLeafVolume
|
||||||
};
|
};
|
||||||
p1.Merge(p2);
|
p1.Merge(p2);
|
||||||
|
|
||||||
@ -62,12 +62,12 @@ public class ParserInfoTests
|
|||||||
IsSpecial = true,
|
IsSpecial = true,
|
||||||
Series = "darker than black",
|
Series = "darker than black",
|
||||||
Title = "darker than black",
|
Title = "darker than black",
|
||||||
Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume
|
Volumes = Parser.LooseLeafVolume
|
||||||
};
|
};
|
||||||
|
|
||||||
var p2 = new ParserInfo()
|
var p2 = new ParserInfo()
|
||||||
{
|
{
|
||||||
Chapters = API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter,
|
Chapters = Parser.DefaultChapter,
|
||||||
Edition = "",
|
Edition = "",
|
||||||
Format = MangaFormat.Archive,
|
Format = MangaFormat.Archive,
|
||||||
FullFilePath = "/manga/darker than black.cbz",
|
FullFilePath = "/manga/darker than black.cbz",
|
||||||
|
@ -44,6 +44,7 @@ public class ParsingTests
|
|||||||
[InlineData("DEAD Tube Prologue", "DEAD Tube Prologue")]
|
[InlineData("DEAD Tube Prologue", "DEAD Tube Prologue")]
|
||||||
[InlineData("DEAD Tube Prologue SP01", "DEAD Tube Prologue")]
|
[InlineData("DEAD Tube Prologue SP01", "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)
|
public void CleanSpecialTitleTest(string input, string expected)
|
||||||
{
|
{
|
||||||
Assert.Equal(expected, CleanSpecialTitle(input));
|
Assert.Equal(expected, CleanSpecialTitle(input));
|
||||||
@ -251,6 +252,7 @@ public class ParsingTests
|
|||||||
[InlineData("ch1/backcover.png", false)]
|
[InlineData("ch1/backcover.png", false)]
|
||||||
[InlineData("backcover.png", false)]
|
[InlineData("backcover.png", false)]
|
||||||
[InlineData("back_cover.png", false)]
|
[InlineData("back_cover.png", false)]
|
||||||
|
[InlineData("LD Blacklands #1 35 (back cover).png", false)]
|
||||||
public void IsCoverImageTest(string inputPath, bool expected)
|
public void IsCoverImageTest(string inputPath, bool expected)
|
||||||
{
|
{
|
||||||
Assert.Equal(expected, IsCoverImage(inputPath));
|
Assert.Equal(expected, IsCoverImage(inputPath));
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
using System.Collections.Generic;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using API.Constants;
|
||||||
using API.Data;
|
using API.Data;
|
||||||
using API.Data.Repositories;
|
using API.Data.Repositories;
|
||||||
using API.DTOs.Collection;
|
using API.DTOs.Collection;
|
||||||
@ -10,6 +12,8 @@ using API.Helpers.Builders;
|
|||||||
using API.Services;
|
using API.Services;
|
||||||
using API.Services.Plus;
|
using API.Services.Plus;
|
||||||
using API.SignalR;
|
using API.SignalR;
|
||||||
|
using Kavita.Common;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
@ -53,6 +57,64 @@ public class CollectionTagServiceTests : AbstractDbTest
|
|||||||
await _unitOfWork.CommitAsync();
|
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
|
#region UpdateTag
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@ -111,6 +173,189 @@ public class CollectionTagServiceTests : AbstractDbTest
|
|||||||
Assert.Equal("UpdateTag_ShouldNotChangeTitle_WhenNotKavitaSource", tag.Title);
|
Assert.Equal("UpdateTag_ShouldNotChangeTitle_WhenNotKavitaSource", tag.Title);
|
||||||
Assert.False(string.IsNullOrEmpty(tag.Summary));
|
Assert.False(string.IsNullOrEmpty(tag.Summary));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateTag_ShouldThrowException_WhenTagDoesNotExist()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await SeedSeries();
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
var exception = await Assert.ThrowsAsync<KavitaException>(() => _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<KavitaException>(() => _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<KavitaException>(() => _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<KavitaException>(() => _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
|
#endregion
|
||||||
|
|
||||||
|
|
||||||
@ -131,7 +376,7 @@ public class CollectionTagServiceTests : AbstractDbTest
|
|||||||
await _service.RemoveTagFromSeries(tag, new[] {1});
|
await _service.RemoveTagFromSeries(tag, new[] {1});
|
||||||
var userCollections = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections);
|
var userCollections = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections);
|
||||||
Assert.Equal(2, userCollections!.Collections.Count);
|
Assert.Equal(2, userCollections!.Collections.Count);
|
||||||
Assert.Equal(1, tag.Items.Count);
|
Assert.Single(tag.Items);
|
||||||
Assert.Equal(2, tag.Items.First().Id);
|
Assert.Equal(2, tag.Items.First().Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -175,6 +420,111 @@ public class CollectionTagServiceTests : AbstractDbTest
|
|||||||
Assert.Null(tag2);
|
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<int>());
|
||||||
|
|
||||||
|
// 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
|
#endregion
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -11,6 +11,7 @@ using API.DTOs.Scrobbling;
|
|||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
using API.Entities.Metadata;
|
using API.Entities.Metadata;
|
||||||
|
using API.Entities.MetadataMatching;
|
||||||
using API.Helpers.Builders;
|
using API.Helpers.Builders;
|
||||||
using API.Services.Plus;
|
using API.Services.Plus;
|
||||||
using API.Services.Tasks.Metadata;
|
using API.Services.Tasks.Metadata;
|
||||||
|
@ -167,7 +167,6 @@ public class ScannerServiceTests : AbstractDbTest
|
|||||||
Assert.NotNull(postLib.Series.First().Volumes.FirstOrDefault(v => v.Chapters.FirstOrDefault(c => c.IsSpecial) != null));
|
Assert.NotNull(postLib.Series.First().Volumes.FirstOrDefault(v => v.Chapters.FirstOrDefault(c => c.IsSpecial) != null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ScanLibrary_SeriesWithUnbalancedParenthesis()
|
public async Task ScanLibrary_SeriesWithUnbalancedParenthesis()
|
||||||
{
|
{
|
||||||
|
@ -59,6 +59,7 @@ public class SeriesServiceTests : AbstractDbTest
|
|||||||
Substitute.For<ITaskScheduler>(), Substitute.For<ILogger<SeriesService>>(),
|
Substitute.For<ITaskScheduler>(), Substitute.For<ILogger<SeriesService>>(),
|
||||||
Substitute.For<IScrobblingService>(), locService, Substitute.For<IReadingListService>());
|
Substitute.For<IScrobblingService>(), locService, Substitute.For<IReadingListService>());
|
||||||
}
|
}
|
||||||
|
|
||||||
#region Setup
|
#region Setup
|
||||||
|
|
||||||
protected override async Task ResetDb()
|
protected override async Task ResetDb()
|
||||||
|
292
API.Tests/Services/SettingsServiceTests.cs
Normal file
292
API.Tests/Services/SettingsServiceTests.cs
Normal file
@ -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<ILogger<DirectoryService>>(), new FileSystem());
|
||||||
|
|
||||||
|
_mockUnitOfWork = Substitute.For<IUnitOfWork>();
|
||||||
|
_settingsService = new SettingsService(_mockUnitOfWork, ds,
|
||||||
|
Substitute.For<ILibraryWatcher>(), Substitute.For<ITaskScheduler>(),
|
||||||
|
Substitute.For<ILogger<SettingsService>>());
|
||||||
|
}
|
||||||
|
|
||||||
|
#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<string, AgeRating>(),
|
||||||
|
Blacklist = [],
|
||||||
|
Whitelist = [],
|
||||||
|
Overrides = [],
|
||||||
|
PersonRoles = [],
|
||||||
|
FieldMappings = []
|
||||||
|
};
|
||||||
|
|
||||||
|
var settingsRepo = Substitute.For<ISettingsRepository>();
|
||||||
|
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<string, AgeRating> { { "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<ISettingsRepository>();
|
||||||
|
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<List<MetadataFieldMapping>>());
|
||||||
|
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<ISettingsRepository>();
|
||||||
|
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<List<MetadataFieldMapping>>());
|
||||||
|
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<ISettingsRepository>();
|
||||||
|
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
|
||||||
|
}
|
@ -65,13 +65,13 @@ public class VersionUpdaterServiceTests : IDisposable
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task CheckForUpdate_ShouldReturnNull_WhenGithubApiReturnsNull()
|
public async Task CheckForUpdate_ShouldReturnNull_WhenGithubApiReturnsNull()
|
||||||
{
|
{
|
||||||
// Arrange
|
|
||||||
_httpTest.RespondWith("null");
|
_httpTest.RespondWith("null");
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.CheckForUpdate();
|
var result = await _service.CheckForUpdate();
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.Null(result);
|
Assert.Null(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,7 +79,7 @@ public class VersionUpdaterServiceTests : IDisposable
|
|||||||
//[Fact]
|
//[Fact]
|
||||||
public async Task CheckForUpdate_ShouldReturnUpdateNotification_WhenNewVersionIsAvailable()
|
public async Task CheckForUpdate_ShouldReturnUpdateNotification_WhenNewVersionIsAvailable()
|
||||||
{
|
{
|
||||||
// Arrange
|
|
||||||
var githubResponse = new
|
var githubResponse = new
|
||||||
{
|
{
|
||||||
tag_name = "v0.6.0",
|
tag_name = "v0.6.0",
|
||||||
@ -91,10 +91,10 @@ public class VersionUpdaterServiceTests : IDisposable
|
|||||||
|
|
||||||
_httpTest.RespondWithJson(githubResponse);
|
_httpTest.RespondWithJson(githubResponse);
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.CheckForUpdate();
|
var result = await _service.CheckForUpdate();
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.NotNull(result);
|
Assert.NotNull(result);
|
||||||
Assert.Equal("0.6.0", result.UpdateVersion);
|
Assert.Equal("0.6.0", result.UpdateVersion);
|
||||||
Assert.Equal("0.5.0.0", result.CurrentVersion);
|
Assert.Equal("0.5.0.0", result.CurrentVersion);
|
||||||
@ -121,10 +121,10 @@ public class VersionUpdaterServiceTests : IDisposable
|
|||||||
|
|
||||||
_httpTest.RespondWithJson(githubResponse);
|
_httpTest.RespondWithJson(githubResponse);
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.CheckForUpdate();
|
var result = await _service.CheckForUpdate();
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.NotNull(result);
|
Assert.NotNull(result);
|
||||||
Assert.True(result.IsReleaseEqual);
|
Assert.True(result.IsReleaseEqual);
|
||||||
Assert.False(result.IsReleaseNewer);
|
Assert.False(result.IsReleaseNewer);
|
||||||
@ -134,7 +134,7 @@ public class VersionUpdaterServiceTests : IDisposable
|
|||||||
//[Fact]
|
//[Fact]
|
||||||
public async Task PushUpdate_ShouldSendUpdateEvent_WhenNewerVersionAvailable()
|
public async Task PushUpdate_ShouldSendUpdateEvent_WhenNewerVersionAvailable()
|
||||||
{
|
{
|
||||||
// Arrange
|
|
||||||
var update = new UpdateNotificationDto
|
var update = new UpdateNotificationDto
|
||||||
{
|
{
|
||||||
UpdateVersion = "0.6.0",
|
UpdateVersion = "0.6.0",
|
||||||
@ -145,10 +145,10 @@ public class VersionUpdaterServiceTests : IDisposable
|
|||||||
PublishDate = null
|
PublishDate = null
|
||||||
};
|
};
|
||||||
|
|
||||||
// Act
|
|
||||||
await _service.PushUpdate(update);
|
await _service.PushUpdate(update);
|
||||||
|
|
||||||
// Assert
|
|
||||||
await _eventHub.Received(1).SendMessageAsync(
|
await _eventHub.Received(1).SendMessageAsync(
|
||||||
Arg.Is(MessageFactory.UpdateAvailable),
|
Arg.Is(MessageFactory.UpdateAvailable),
|
||||||
Arg.Any<SignalRMessage>(),
|
Arg.Any<SignalRMessage>(),
|
||||||
@ -159,7 +159,7 @@ public class VersionUpdaterServiceTests : IDisposable
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task PushUpdate_ShouldNotSendUpdateEvent_WhenVersionIsEqual()
|
public async Task PushUpdate_ShouldNotSendUpdateEvent_WhenVersionIsEqual()
|
||||||
{
|
{
|
||||||
// Arrange
|
|
||||||
var update = new UpdateNotificationDto
|
var update = new UpdateNotificationDto
|
||||||
{
|
{
|
||||||
UpdateVersion = "0.5.0.0",
|
UpdateVersion = "0.5.0.0",
|
||||||
@ -170,10 +170,10 @@ public class VersionUpdaterServiceTests : IDisposable
|
|||||||
PublishDate = null
|
PublishDate = null
|
||||||
};
|
};
|
||||||
|
|
||||||
// Act
|
|
||||||
await _service.PushUpdate(update);
|
await _service.PushUpdate(update);
|
||||||
|
|
||||||
// Assert
|
|
||||||
await _eventHub.DidNotReceive().SendMessageAsync(
|
await _eventHub.DidNotReceive().SendMessageAsync(
|
||||||
Arg.Any<string>(),
|
Arg.Any<string>(),
|
||||||
Arg.Any<SignalRMessage>(),
|
Arg.Any<SignalRMessage>(),
|
||||||
@ -184,7 +184,7 @@ public class VersionUpdaterServiceTests : IDisposable
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetAllReleases_ShouldReturnReleases_LimitedByCount()
|
public async Task GetAllReleases_ShouldReturnReleases_LimitedByCount()
|
||||||
{
|
{
|
||||||
// Arrange
|
|
||||||
var releases = new List<object>
|
var releases = new List<object>
|
||||||
{
|
{
|
||||||
new
|
new
|
||||||
@ -215,10 +215,10 @@ public class VersionUpdaterServiceTests : IDisposable
|
|||||||
|
|
||||||
_httpTest.RespondWithJson(releases);
|
_httpTest.RespondWithJson(releases);
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.GetAllReleases(2);
|
var result = await _service.GetAllReleases(2);
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.Equal(2, result.Count);
|
Assert.Equal(2, result.Count);
|
||||||
Assert.Equal("0.7.0.0", result[0].UpdateVersion);
|
Assert.Equal("0.7.0.0", result[0].UpdateVersion);
|
||||||
Assert.Equal("0.6.0", result[1].UpdateVersion);
|
Assert.Equal("0.6.0", result[1].UpdateVersion);
|
||||||
@ -227,7 +227,7 @@ public class VersionUpdaterServiceTests : IDisposable
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetAllReleases_ShouldUseCachedData_WhenCacheIsValid()
|
public async Task GetAllReleases_ShouldUseCachedData_WhenCacheIsValid()
|
||||||
{
|
{
|
||||||
// Arrange
|
|
||||||
var releases = new List<UpdateNotificationDto>
|
var releases = new List<UpdateNotificationDto>
|
||||||
{
|
{
|
||||||
new()
|
new()
|
||||||
@ -257,10 +257,10 @@ public class VersionUpdaterServiceTests : IDisposable
|
|||||||
await File.WriteAllTextAsync(cacheFilePath, System.Text.Json.JsonSerializer.Serialize(releases));
|
await File.WriteAllTextAsync(cacheFilePath, System.Text.Json.JsonSerializer.Serialize(releases));
|
||||||
File.SetLastWriteTimeUtc(cacheFilePath, DateTime.UtcNow); // Ensure it's fresh
|
File.SetLastWriteTimeUtc(cacheFilePath, DateTime.UtcNow); // Ensure it's fresh
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.GetAllReleases();
|
var result = await _service.GetAllReleases();
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.Equal(2, result.Count);
|
Assert.Equal(2, result.Count);
|
||||||
Assert.Empty(_httpTest.CallLog); // No HTTP calls made
|
Assert.Empty(_httpTest.CallLog); // No HTTP calls made
|
||||||
}
|
}
|
||||||
@ -268,7 +268,7 @@ public class VersionUpdaterServiceTests : IDisposable
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetAllReleases_ShouldFetchNewData_WhenCacheIsExpired()
|
public async Task GetAllReleases_ShouldFetchNewData_WhenCacheIsExpired()
|
||||||
{
|
{
|
||||||
// Arrange
|
|
||||||
var releases = new List<UpdateNotificationDto>
|
var releases = new List<UpdateNotificationDto>
|
||||||
{
|
{
|
||||||
new()
|
new()
|
||||||
@ -303,10 +303,10 @@ public class VersionUpdaterServiceTests : IDisposable
|
|||||||
|
|
||||||
_httpTest.RespondWithJson(newReleases);
|
_httpTest.RespondWithJson(newReleases);
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.GetAllReleases();
|
var result = await _service.GetAllReleases();
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.Equal(1, result.Count);
|
Assert.Equal(1, result.Count);
|
||||||
Assert.Equal("0.7.0.0", result[0].UpdateVersion);
|
Assert.Equal("0.7.0.0", result[0].UpdateVersion);
|
||||||
Assert.NotEmpty(_httpTest.CallLog); // HTTP call was made
|
Assert.NotEmpty(_httpTest.CallLog); // HTTP call was made
|
||||||
@ -314,7 +314,7 @@ public class VersionUpdaterServiceTests : IDisposable
|
|||||||
|
|
||||||
public async Task GetNumberOfReleasesBehind_ShouldReturnCorrectCount()
|
public async Task GetNumberOfReleasesBehind_ShouldReturnCorrectCount()
|
||||||
{
|
{
|
||||||
// Arrange
|
|
||||||
var releases = new List<object>
|
var releases = new List<object>
|
||||||
{
|
{
|
||||||
new
|
new
|
||||||
@ -345,16 +345,16 @@ public class VersionUpdaterServiceTests : IDisposable
|
|||||||
|
|
||||||
_httpTest.RespondWithJson(releases);
|
_httpTest.RespondWithJson(releases);
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.GetNumberOfReleasesBehind();
|
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
|
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()
|
public async Task GetNumberOfReleasesBehind_ShouldReturnCorrectCount_WithNightlies()
|
||||||
{
|
{
|
||||||
// Arrange
|
|
||||||
var releases = new List<object>
|
var releases = new List<object>
|
||||||
{
|
{
|
||||||
new
|
new
|
||||||
@ -377,17 +377,17 @@ public class VersionUpdaterServiceTests : IDisposable
|
|||||||
|
|
||||||
_httpTest.RespondWithJson(releases);
|
_httpTest.RespondWithJson(releases);
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.GetNumberOfReleasesBehind();
|
var result = await _service.GetNumberOfReleasesBehind();
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.Equal(2, result); // We have to add 1 because the current release is > 0.7.0
|
Assert.Equal(2, result); // We have to add 1 because the current release is > 0.7.0
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ParseReleaseBody_ShouldExtractSections()
|
public async Task ParseReleaseBody_ShouldExtractSections()
|
||||||
{
|
{
|
||||||
// Arrange
|
|
||||||
var githubResponse = new
|
var githubResponse = new
|
||||||
{
|
{
|
||||||
tag_name = "v0.6.0",
|
tag_name = "v0.6.0",
|
||||||
@ -399,10 +399,10 @@ public class VersionUpdaterServiceTests : IDisposable
|
|||||||
|
|
||||||
_httpTest.RespondWithJson(githubResponse);
|
_httpTest.RespondWithJson(githubResponse);
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.CheckForUpdate();
|
var result = await _service.CheckForUpdate();
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.NotNull(result);
|
Assert.NotNull(result);
|
||||||
Assert.Equal(2, result.Added.Count);
|
Assert.Equal(2, result.Added.Count);
|
||||||
Assert.Equal(2, result.Fixed.Count);
|
Assert.Equal(2, result.Fixed.Count);
|
||||||
@ -414,7 +414,7 @@ public class VersionUpdaterServiceTests : IDisposable
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetAllReleases_ShouldHandleNightlyBuilds()
|
public async Task GetAllReleases_ShouldHandleNightlyBuilds()
|
||||||
{
|
{
|
||||||
// Arrange
|
|
||||||
// Set BuildInfo.Version to a nightly build version
|
// Set BuildInfo.Version to a nightly build version
|
||||||
typeof(BuildInfo).GetProperty(nameof(BuildInfo.Version))?.SetValue(null, new Version("0.7.1.0"));
|
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
|
// Mock commit info for develop branch
|
||||||
_httpTest.RespondWithJson(new List<object>());
|
_httpTest.RespondWithJson(new List<object>());
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _service.GetAllReleases();
|
var result = await _service.GetAllReleases();
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.NotNull(result);
|
Assert.NotNull(result);
|
||||||
Assert.True(result[0].IsOnNightlyInRelease);
|
Assert.True(result[0].IsOnNightlyInRelease);
|
||||||
}
|
}
|
||||||
|
@ -51,8 +51,8 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CsvHelper" Version="33.0.1" />
|
<PackageReference Include="CsvHelper" Version="33.0.1" />
|
||||||
<PackageReference Include="MailKit" Version="4.10.0" />
|
<PackageReference Include="MailKit" Version="4.11.0" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.2">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.3">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
@ -66,20 +66,20 @@
|
|||||||
<PackageReference Include="Hangfire.InMemory" Version="1.0.0" />
|
<PackageReference Include="Hangfire.InMemory" Version="1.0.0" />
|
||||||
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
|
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
|
||||||
<PackageReference Include="Hangfire.Storage.SQLite" Version="0.4.2" />
|
<PackageReference Include="Hangfire.Storage.SQLite" Version="0.4.2" />
|
||||||
<PackageReference Include="HtmlAgilityPack" Version="1.11.74" />
|
<PackageReference Include="HtmlAgilityPack" Version="1.12.0" />
|
||||||
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
|
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
|
||||||
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.18" />
|
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.18" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
|
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.2" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.3" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="9.0.2" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="9.0.3" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.2" />
|
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.3" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.2" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.3" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.2" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.3" />
|
||||||
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
|
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
|
||||||
<PackageReference Include="MimeTypeMapOfficial" Version="1.0.17" />
|
<PackageReference Include="MimeTypeMapOfficial" Version="1.0.17" />
|
||||||
<PackageReference Include="Nager.ArticleNumber" Version="1.0.7" />
|
<PackageReference Include="Nager.ArticleNumber" Version="1.0.7" />
|
||||||
<PackageReference Include="NetVips" Version="3.0.0" />
|
<PackageReference Include="NetVips" Version="3.0.0" />
|
||||||
<PackageReference Include="NetVips.Native" Version="8.16.0" />
|
<PackageReference Include="NetVips.Native" Version="8.16.1" />
|
||||||
<PackageReference Include="NReco.Logging.File" Version="1.2.2" />
|
<PackageReference Include="NReco.Logging.File" Version="1.2.2" />
|
||||||
<PackageReference Include="Serilog" Version="4.2.0" />
|
<PackageReference Include="Serilog" Version="4.2.0" />
|
||||||
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
|
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
|
||||||
@ -96,11 +96,11 @@
|
|||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.3.1" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.0.0" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.2" />
|
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.2" />
|
||||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.6.0" />
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.6.1" />
|
||||||
<PackageReference Include="System.IO.Abstractions" Version="22.0.11" />
|
<PackageReference Include="System.IO.Abstractions" Version="22.0.12" />
|
||||||
<PackageReference Include="System.Drawing.Common" Version="9.0.2" />
|
<PackageReference Include="System.Drawing.Common" Version="9.0.3" />
|
||||||
<PackageReference Include="VersOne.Epub" Version="3.3.2" />
|
<PackageReference Include="VersOne.Epub" Version="3.3.2" />
|
||||||
<PackageReference Include="YamlDotNet" Version="16.3.0" />
|
<PackageReference Include="YamlDotNet" Version="16.3.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
@ -4,6 +4,7 @@ using System.Globalization;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using API.Constants;
|
using API.Constants;
|
||||||
|
using API.DTOs;
|
||||||
using API.DTOs.Filtering;
|
using API.DTOs.Filtering;
|
||||||
using API.Services;
|
using API.Services;
|
||||||
using EasyCaching.Core;
|
using EasyCaching.Core;
|
||||||
|
@ -13,6 +13,7 @@ using API.DTOs.Recommendation;
|
|||||||
using API.DTOs.SeriesDetail;
|
using API.DTOs.SeriesDetail;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
using API.Extensions;
|
using API.Extensions;
|
||||||
|
using API.Helpers;
|
||||||
using API.Services;
|
using API.Services;
|
||||||
using API.Services.Plus;
|
using API.Services.Plus;
|
||||||
using Kavita.Common.Extensions;
|
using Kavita.Common.Extensions;
|
||||||
@ -225,7 +226,7 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
|
|||||||
var isAdmin = User.IsInRole(PolicyConstants.AdminRole);
|
var isAdmin = User.IsInRole(PolicyConstants.AdminRole);
|
||||||
var user = await unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId())!;
|
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;
|
ret.Reviews = userReviews;
|
||||||
|
|
||||||
if (!isAdmin && ret.Recommendations != null && user != null)
|
if (!isAdmin && ret.Recommendations != null && user != null)
|
||||||
|
@ -55,7 +55,7 @@ public class PersonController : BaseApiController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns a list of authors & artists for browsing
|
/// Returns a list of authors and artists for browsing
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="userParams"></param>
|
/// <param name="userParams"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
|
@ -203,10 +203,11 @@ public class ServerController : BaseApiController
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns how many versions out of date this install is
|
/// Returns how many versions out of date this install is
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="stableOnly">Only count Stable releases</param>
|
||||||
[HttpGet("check-out-of-date")]
|
[HttpGet("check-out-of-date")]
|
||||||
public async Task<ActionResult<int>> CheckHowOutOfDate()
|
public async Task<ActionResult<int>> CheckHowOutOfDate(bool stableOnly = true)
|
||||||
{
|
{
|
||||||
return Ok(await _versionUpdaterService.GetNumberOfReleasesBehind());
|
return Ok(await _versionUpdaterService.GetNumberOfReleasesBehind(stableOnly));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -40,10 +40,11 @@ public class SettingsController : BaseApiController
|
|||||||
private readonly IEmailService _emailService;
|
private readonly IEmailService _emailService;
|
||||||
private readonly ILibraryWatcher _libraryWatcher;
|
private readonly ILibraryWatcher _libraryWatcher;
|
||||||
private readonly ILocalizationService _localizationService;
|
private readonly ILocalizationService _localizationService;
|
||||||
|
private readonly ISettingsService _settingsService;
|
||||||
|
|
||||||
public SettingsController(ILogger<SettingsController> logger, IUnitOfWork unitOfWork, ITaskScheduler taskScheduler,
|
public SettingsController(ILogger<SettingsController> logger, IUnitOfWork unitOfWork, ITaskScheduler taskScheduler,
|
||||||
IDirectoryService directoryService, IMapper mapper, IEmailService emailService, ILibraryWatcher libraryWatcher,
|
IDirectoryService directoryService, IMapper mapper, IEmailService emailService, ILibraryWatcher libraryWatcher,
|
||||||
ILocalizationService localizationService)
|
ILocalizationService localizationService, ISettingsService settingsService)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
@ -53,6 +54,7 @@ public class SettingsController : BaseApiController
|
|||||||
_emailService = emailService;
|
_emailService = emailService;
|
||||||
_libraryWatcher = libraryWatcher;
|
_libraryWatcher = libraryWatcher;
|
||||||
_localizationService = localizationService;
|
_localizationService = localizationService;
|
||||||
|
_settingsService = settingsService;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("base-url")]
|
[HttpGet("base-url")]
|
||||||
@ -139,346 +141,32 @@ public class SettingsController : BaseApiController
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Update Server settings
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="updateSettingsDto"></param>
|
||||||
|
/// <returns></returns>
|
||||||
[Authorize(Policy = "RequireAdminRole")]
|
[Authorize(Policy = "RequireAdminRole")]
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<ActionResult<ServerSettingDto>> UpdateSettings(ServerSettingDto updateSettingsDto)
|
public async Task<ActionResult<ServerSettingDto>> UpdateSettings(ServerSettingDto updateSettingsDto)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("{UserName} is updating Server Settings", User.GetUsername());
|
_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
|
try
|
||||||
{
|
{
|
||||||
await _unitOfWork.CommitAsync();
|
return Ok(await _settingsService.UpdateSettings(updateSettingsDto));
|
||||||
|
}
|
||||||
if (!updateSettingsDto.AllowStatCollection)
|
catch (KavitaException ex)
|
||||||
{
|
{
|
||||||
_taskScheduler.CancelStatsTasks();
|
return BadRequest(await _localizationService.Translate(User.GetUserId(), ex.Message));
|
||||||
}
|
|
||||||
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "There was an exception when updating server settings");
|
_logger.LogError(ex, "There was an exception when updating server settings");
|
||||||
await _unitOfWork.RollbackAsync();
|
|
||||||
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error"));
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// All values allowed for Task Scheduling APIs. A custom cron job is not included. Disabled is not applicable for Cleanup.
|
/// All values allowed for Task Scheduling APIs. A custom cron job is not included. Disabled is not applicable for Cleanup.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -549,7 +237,7 @@ public class SettingsController : BaseApiController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Update the metadata settings for Kavita+ users
|
/// Update the metadata settings for Kavita+ Metadata feature
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="dto"></param>
|
/// <param name="dto"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
@ -557,54 +245,14 @@ public class SettingsController : BaseApiController
|
|||||||
[HttpPost("metadata-settings")]
|
[HttpPost("metadata-settings")]
|
||||||
public async Task<ActionResult<MetadataSettingsDto>> UpdateMetadataSettings(MetadataSettingsDto dto)
|
public async Task<ActionResult<MetadataSettingsDto>> UpdateMetadataSettings(MetadataSettingsDto dto)
|
||||||
{
|
{
|
||||||
var existingMetadataSetting = await _unitOfWork.SettingsRepository.GetMetadataSettings();
|
try
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
// Clear existing mappings
|
return Ok(await _settingsService.UpdateMetadataSettings(dto));
|
||||||
existingMetadataSetting.FieldMappings ??= [];
|
}
|
||||||
_unitOfWork.SettingsRepository.RemoveRange(existingMetadataSetting.FieldMappings);
|
catch (Exception ex)
|
||||||
|
{
|
||||||
existingMetadataSetting.FieldMappings.Clear();
|
_logger.LogError(ex, "There was an issue when updating metadata settings");
|
||||||
|
return BadRequest(ex.Message);
|
||||||
|
|
||||||
// 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 Ok(await _unitOfWork.SettingsRepository.GetMetadataSettingDto());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -10,7 +10,7 @@ public class AppUserCollectionDto : IHasCoverImage
|
|||||||
{
|
{
|
||||||
public int Id { get; init; }
|
public int Id { get; init; }
|
||||||
public string Title { get; set; } = default!;
|
public string Title { get; set; } = default!;
|
||||||
public string Summary { get; set; } = default!;
|
public string? Summary { get; set; } = default!;
|
||||||
public bool Promoted { get; set; }
|
public bool Promoted { get; set; }
|
||||||
public AgeRating AgeRating { get; set; }
|
public AgeRating AgeRating { get; set; }
|
||||||
|
|
||||||
|
10
API/DTOs/KavitaLocale.cs
Normal file
10
API/DTOs/KavitaLocale.cs
Normal file
@ -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
|
||||||
|
}
|
@ -1,6 +1,7 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
|
using API.Entities.MetadataMatching;
|
||||||
using NotImplementedException = System.NotImplementedException;
|
using NotImplementedException = System.NotImplementedException;
|
||||||
|
|
||||||
namespace API.DTOs.KavitaPlus.Metadata;
|
namespace API.DTOs.KavitaPlus.Metadata;
|
||||||
|
@ -1,17 +0,0 @@
|
|||||||
namespace API.DTOs.Statistics;
|
|
||||||
|
|
||||||
public class KavitaPlusMetadataBreakdownDto
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Total amount of Series
|
|
||||||
/// </summary>
|
|
||||||
public int TotalSeries { get; set; }
|
|
||||||
/// <summary>
|
|
||||||
/// Series on the Blacklist (errored or bad match)
|
|
||||||
/// </summary>
|
|
||||||
public int ErroredSeries { get; set; }
|
|
||||||
/// <summary>
|
|
||||||
/// Completed so far
|
|
||||||
/// </summary>
|
|
||||||
public int SeriesCompleted { get; set; }
|
|
||||||
}
|
|
@ -11,6 +11,7 @@ using API.Entities.Enums.UserPreferences;
|
|||||||
using API.Entities.History;
|
using API.Entities.History;
|
||||||
using API.Entities.Interfaces;
|
using API.Entities.Interfaces;
|
||||||
using API.Entities.Metadata;
|
using API.Entities.Metadata;
|
||||||
|
using API.Entities.MetadataMatching;
|
||||||
using API.Entities.Scrobble;
|
using API.Entities.Scrobble;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||||
|
@ -7,6 +7,7 @@ using API.DTOs.Settings;
|
|||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
using API.Entities.Metadata;
|
using API.Entities.Metadata;
|
||||||
|
using API.Entities.MetadataMatching;
|
||||||
using AutoMapper;
|
using AutoMapper;
|
||||||
using AutoMapper.QueryableExtensions;
|
using AutoMapper.QueryableExtensions;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
@ -546,7 +546,16 @@ public class UserRepository : IUserRepository
|
|||||||
public async Task<IList<string>> GetRoles(int userId)
|
public async Task<IList<string>> GetRoles(int userId)
|
||||||
{
|
{
|
||||||
var user = await _context.Users.FirstOrDefaultAsync(u => u.Id == userId);
|
var user = await _context.Users.FirstOrDefaultAsync(u => u.Id == userId);
|
||||||
if (user == null || _userManager == null) return ArraySegment<string>.Empty; // userManager is null on Unit Tests only
|
if (user == null) return ArraySegment<string>.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);
|
return await _userManager.GetRolesAsync(user);
|
||||||
}
|
}
|
||||||
|
@ -11,6 +11,7 @@ using API.Data.Repositories;
|
|||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
using API.Entities.Enums.Theme;
|
using API.Entities.Enums.Theme;
|
||||||
|
using API.Entities.MetadataMatching;
|
||||||
using API.Extensions;
|
using API.Extensions;
|
||||||
using API.Services;
|
using API.Services;
|
||||||
using Kavita.Common;
|
using Kavita.Common;
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
|
using API.Entities.MetadataMatching;
|
||||||
|
|
||||||
namespace API.Entities;
|
namespace API.Entities;
|
||||||
|
|
||||||
|
17
API/Entities/MetadataMatching/MetadataSettingField.cs
Normal file
17
API/Entities/MetadataMatching/MetadataSettingField.cs
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
namespace API.Entities.MetadataMatching;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents which field that can be written to as an override when already locked
|
||||||
|
/// </summary>
|
||||||
|
public enum MetadataSettingField
|
||||||
|
{
|
||||||
|
Summary = 1,
|
||||||
|
PublicationStatus = 2,
|
||||||
|
StartDate = 3,
|
||||||
|
Genres = 4,
|
||||||
|
Tags = 5,
|
||||||
|
LocalizedName = 6,
|
||||||
|
Covers = 7,
|
||||||
|
AgeRating = 8,
|
||||||
|
People = 9
|
||||||
|
}
|
@ -1,24 +1,7 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
|
|
||||||
namespace API.Entities;
|
namespace API.Entities.MetadataMatching;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents which field that can be written to as an override when already locked
|
|
||||||
/// </summary>
|
|
||||||
public enum MetadataSettingField
|
|
||||||
{
|
|
||||||
Summary = 1,
|
|
||||||
PublicationStatus = 2,
|
|
||||||
StartDate = 3,
|
|
||||||
Genres = 4,
|
|
||||||
Tags = 5,
|
|
||||||
LocalizedName = 6,
|
|
||||||
Covers = 7,
|
|
||||||
AgeRating = 8,
|
|
||||||
People = 9
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handles the metadata settings for Kavita+
|
/// Handles the metadata settings for Kavita+
|
||||||
|
@ -69,6 +69,7 @@ public static class ApplicationServiceExtensions
|
|||||||
services.AddScoped<ICoverDbService, CoverDbService>();
|
services.AddScoped<ICoverDbService, CoverDbService>();
|
||||||
|
|
||||||
services.AddScoped<ILocalizationService, LocalizationService>();
|
services.AddScoped<ILocalizationService, LocalizationService>();
|
||||||
|
services.AddScoped<ISettingsService, SettingsService>();
|
||||||
|
|
||||||
|
|
||||||
services.AddScoped<IScrobblingService, ScrobblingService>();
|
services.AddScoped<IScrobblingService, ScrobblingService>();
|
||||||
|
@ -14,18 +14,4 @@ public static class VersionExtensions
|
|||||||
return v1.Build == v2.Build;
|
return v1.Build == v2.Build;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// v0.8.2.3 is within v0.8.2 (v1). Essentially checks if this is a Nightly of a stable release
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="v1"></param>
|
|
||||||
/// <param name="v2"></param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public static bool IsWithinStableRelease(this Version v1, Version v2)
|
|
||||||
{
|
|
||||||
return v1.Major == v2.Major && v1.Minor != v2.Minor && v1.Build != v2.Build;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -29,6 +29,7 @@ using API.DTOs.Theme;
|
|||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
using API.Entities.Metadata;
|
using API.Entities.Metadata;
|
||||||
|
using API.Entities.MetadataMatching;
|
||||||
using API.Entities.Scrobble;
|
using API.Entities.Scrobble;
|
||||||
using API.Extensions.QueryExtensions.Filtering;
|
using API.Extensions.QueryExtensions.Filtering;
|
||||||
using API.Helpers.Converters;
|
using API.Helpers.Converters;
|
||||||
@ -336,7 +337,7 @@ public class AutoMapperProfiles : Profile
|
|||||||
CreateMap<UserReviewDto, ExternalReview>()
|
CreateMap<UserReviewDto, ExternalReview>()
|
||||||
.ForMember(dest => dest.BodyJustText,
|
.ForMember(dest => dest.BodyJustText,
|
||||||
opt =>
|
opt =>
|
||||||
opt.MapFrom(src => ReviewService.GetCharacters(src.Body)));
|
opt.MapFrom(src => ReviewHelper.GetCharacters(src.Body)));
|
||||||
|
|
||||||
CreateMap<ExternalRecommendation, ExternalSeriesDto>();
|
CreateMap<ExternalRecommendation, ExternalSeriesDto>();
|
||||||
CreateMap<Series, ManageMatchSeriesDto>()
|
CreateMap<Series, ManageMatchSeriesDto>()
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
|
|
||||||
namespace API.Extensions;
|
namespace API.Helpers;
|
||||||
|
|
||||||
public static class DayOfWeekHelper
|
public static class DayOfWeekHelper
|
||||||
{
|
{
|
||||||
|
@ -5,10 +5,9 @@ using System.Text.RegularExpressions;
|
|||||||
using API.DTOs.SeriesDetail;
|
using API.DTOs.SeriesDetail;
|
||||||
using HtmlAgilityPack;
|
using HtmlAgilityPack;
|
||||||
|
|
||||||
|
namespace API.Helpers;
|
||||||
|
|
||||||
namespace API.Services;
|
public static class ReviewHelper
|
||||||
|
|
||||||
public static class ReviewService
|
|
||||||
{
|
{
|
||||||
private const int BodyTextLimit = 175;
|
private const int BodyTextLimit = 175;
|
||||||
public static IEnumerable<UserReviewDto> SelectSpectrumOfReviews(IList<UserReviewDto> reviews)
|
public static IEnumerable<UserReviewDto> SelectSpectrumOfReviews(IList<UserReviewDto> reviews)
|
||||||
@ -60,6 +59,9 @@ public static class ReviewService
|
|||||||
.Where(s => !s.Equals("\n")));
|
.Where(s => !s.Equals("\n")));
|
||||||
|
|
||||||
// Clean any leftover markdown out
|
// 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, @"[_*\[\]~]", string.Empty);
|
||||||
plainText = Regex.Replace(plainText, @"img\d*\((.*?)\)", string.Empty);
|
plainText = Regex.Replace(plainText, @"img\d*\((.*?)\)", string.Empty);
|
||||||
plainText = Regex.Replace(plainText, @"~~~(.*?)~~~", "$1");
|
plainText = Regex.Replace(plainText, @"~~~(.*?)~~~", "$1");
|
||||||
@ -68,6 +70,7 @@ public static class ReviewService
|
|||||||
plainText = Regex.Replace(plainText, @"__(.*?)__", "$1");
|
plainText = Regex.Replace(plainText, @"__(.*?)__", "$1");
|
||||||
plainText = Regex.Replace(plainText, @"#\s(.*?)", "$1");
|
plainText = Regex.Replace(plainText, @"#\s(.*?)", "$1");
|
||||||
|
|
||||||
|
|
||||||
// Just strip symbols
|
// Just strip symbols
|
||||||
plainText = Regex.Replace(plainText, @"[_*\[\]~]", string.Empty);
|
plainText = Regex.Replace(plainText, @"[_*\[\]~]", string.Empty);
|
||||||
plainText = Regex.Replace(plainText, @"img\d*\((.*?)\)", string.Empty);
|
plainText = Regex.Replace(plainText, @"img\d*\((.*?)\)", string.Empty);
|
@ -5,6 +5,7 @@ using System.Threading.Tasks;
|
|||||||
using System.Web;
|
using System.Web;
|
||||||
using API.Constants;
|
using API.Constants;
|
||||||
using API.Data;
|
using API.Data;
|
||||||
|
using API.DTOs.Account;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Errors;
|
using API.Errors;
|
||||||
using Kavita.Common;
|
using Kavita.Common;
|
||||||
@ -46,7 +47,7 @@ public class AccountService : IAccountService
|
|||||||
public async Task<IEnumerable<ApiException>> ChangeUserPassword(AppUser user, string newPassword)
|
public async Task<IEnumerable<ApiException>> ChangeUserPassword(AppUser user, string newPassword)
|
||||||
{
|
{
|
||||||
var passwordValidationIssues = (await ValidatePassword(user, newPassword)).ToList();
|
var passwordValidationIssues = (await ValidatePassword(user, newPassword)).ToList();
|
||||||
if (passwordValidationIssues.Any()) return passwordValidationIssues;
|
if (passwordValidationIssues.Count != 0) return passwordValidationIssues;
|
||||||
|
|
||||||
var result = await _userManager.RemovePasswordAsync(user);
|
var result = await _userManager.RemovePasswordAsync(user);
|
||||||
if (!result.Succeeded)
|
if (!result.Succeeded)
|
||||||
@ -55,15 +56,11 @@ public class AccountService : IAccountService
|
|||||||
return result.Errors.Select(e => new ApiException(400, e.Code, e.Description));
|
return result.Errors.Select(e => new ApiException(400, e.Code, e.Description));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
result = await _userManager.AddPasswordAsync(user, newPassword);
|
result = await _userManager.AddPasswordAsync(user, newPassword);
|
||||||
if (!result.Succeeded)
|
if (result.Succeeded) return [];
|
||||||
{
|
|
||||||
_logger.LogError("Could not update password");
|
|
||||||
return result.Errors.Select(e => new ApiException(400, e.Code, e.Description));
|
|
||||||
}
|
|
||||||
|
|
||||||
return new List<ApiException>();
|
_logger.LogError("Could not update password");
|
||||||
|
return result.Errors.Select(e => new ApiException(400, e.Code, e.Description));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IEnumerable<ApiException>> ValidatePassword(AppUser user, string password)
|
public async Task<IEnumerable<ApiException>> ValidatePassword(AppUser user, string password)
|
||||||
@ -81,15 +78,16 @@ public class AccountService : IAccountService
|
|||||||
}
|
}
|
||||||
public async Task<IEnumerable<ApiException>> ValidateUsername(string username)
|
public async Task<IEnumerable<ApiException>> 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<ApiException>()
|
return
|
||||||
{
|
[
|
||||||
new ApiException(400, "Username is already taken")
|
new(400, "Username is already taken")
|
||||||
};
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.Empty<ApiException>();
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IEnumerable<ApiException>> ValidateEmail(string email)
|
public async Task<IEnumerable<ApiException>> ValidateEmail(string email)
|
||||||
@ -112,6 +110,7 @@ public class AccountService : IAccountService
|
|||||||
{
|
{
|
||||||
if (user == null) return false;
|
if (user == null) return false;
|
||||||
var roles = await _userManager.GetRolesAsync(user);
|
var roles = await _userManager.GetRolesAsync(user);
|
||||||
|
|
||||||
return roles.Contains(PolicyConstants.BookmarkRole) || roles.Contains(PolicyConstants.AdminRole);
|
return roles.Contains(PolicyConstants.BookmarkRole) || roles.Contains(PolicyConstants.AdminRole);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -124,6 +123,7 @@ public class AccountService : IAccountService
|
|||||||
{
|
{
|
||||||
if (user == null) return false;
|
if (user == null) return false;
|
||||||
var roles = await _userManager.GetRolesAsync(user);
|
var roles = await _userManager.GetRolesAsync(user);
|
||||||
|
|
||||||
return roles.Contains(PolicyConstants.DownloadRole) || roles.Contains(PolicyConstants.AdminRole);
|
return roles.Contains(PolicyConstants.DownloadRole) || roles.Contains(PolicyConstants.AdminRole);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -135,9 +135,10 @@ public class AccountService : IAccountService
|
|||||||
public async Task<bool> CanChangeAgeRestriction(AppUser? user)
|
public async Task<bool> CanChangeAgeRestriction(AppUser? user)
|
||||||
{
|
{
|
||||||
if (user == null) return false;
|
if (user == null) return false;
|
||||||
|
|
||||||
var roles = await _userManager.GetRolesAsync(user);
|
var roles = await _userManager.GetRolesAsync(user);
|
||||||
if (roles.Contains(PolicyConstants.ReadOnlyRole)) return false;
|
if (roles.Contains(PolicyConstants.ReadOnlyRole)) return false;
|
||||||
|
|
||||||
return roles.Contains(PolicyConstants.ChangePasswordRole) || roles.Contains(PolicyConstants.AdminRole);
|
return roles.Contains(PolicyConstants.ChangePasswordRole) || roles.Contains(PolicyConstants.AdminRole);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -58,7 +58,7 @@ public class CollectionTagService : ICollectionTagService
|
|||||||
if (!title.Equals(existingTag.Title) && await _unitOfWork.CollectionTagRepository.CollectionExists(dto.Title, userId))
|
if (!title.Equals(existingTag.Title) && await _unitOfWork.CollectionTagRepository.CollectionExists(dto.Title, userId))
|
||||||
throw new KavitaException("collection-tag-duplicate");
|
throw new KavitaException("collection-tag-duplicate");
|
||||||
|
|
||||||
existingTag.Items ??= new List<Series>();
|
existingTag.Items ??= [];
|
||||||
if (existingTag.Source == ScrobbleProvider.Kavita)
|
if (existingTag.Source == ScrobbleProvider.Kavita)
|
||||||
{
|
{
|
||||||
existingTag.Title = title;
|
existingTag.Title = title;
|
||||||
@ -74,7 +74,7 @@ public class CollectionTagService : ICollectionTagService
|
|||||||
_unitOfWork.CollectionTagRepository.Update(existingTag);
|
_unitOfWork.CollectionTagRepository.Update(existingTag);
|
||||||
|
|
||||||
// Check if Tag has updated (Summary)
|
// 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))
|
if (existingTag.Summary == null || !existingTag.Summary.Equals(summary))
|
||||||
{
|
{
|
||||||
existingTag.Summary = summary;
|
existingTag.Summary = summary;
|
||||||
@ -105,7 +105,7 @@ public class CollectionTagService : ICollectionTagService
|
|||||||
{
|
{
|
||||||
if (tag == null) return false;
|
if (tag == null) return false;
|
||||||
|
|
||||||
tag.Items ??= new List<Series>();
|
tag.Items ??= [];
|
||||||
tag.Items = tag.Items.Where(s => !seriesIds.Contains(s.Id)).ToList();
|
tag.Items = tag.Items.Where(s => !seriesIds.Contains(s.Id)).ToList();
|
||||||
|
|
||||||
if (tag.Items.Count == 0)
|
if (tag.Items.Count == 0)
|
||||||
|
@ -4,20 +4,14 @@ using System.Linq;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using API.Data;
|
using API.Data;
|
||||||
|
using API.DTOs;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
|
|
||||||
namespace API.Services;
|
namespace API.Services;
|
||||||
#nullable enable
|
#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
|
public interface ILocalizationService
|
||||||
|
@ -16,6 +16,7 @@ using API.DTOs.SeriesDetail;
|
|||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
using API.Entities.Metadata;
|
using API.Entities.Metadata;
|
||||||
|
using API.Entities.MetadataMatching;
|
||||||
using API.Extensions;
|
using API.Extensions;
|
||||||
using API.Helpers;
|
using API.Helpers;
|
||||||
using API.Services.Tasks.Metadata;
|
using API.Services.Tasks.Metadata;
|
||||||
|
@ -278,7 +278,7 @@ public class LicenseService(
|
|||||||
var releases = await versionUpdaterService.GetAllReleases();
|
var releases = await versionUpdaterService.GetAllReleases();
|
||||||
response.IsValidVersion = releases
|
response.IsValidVersion = releases
|
||||||
.Where(r => !r.UpdateTitle.Contains("Hotfix")) // We don't care about Hotfix 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)
|
.Take(3)
|
||||||
.All(r => new Version(r.UpdateVersion) <= BuildInfo.Version);
|
.All(r => new Version(r.UpdateVersion) <= BuildInfo.Version);
|
||||||
|
|
||||||
|
441
API/Services/SettingsService.cs
Normal file
441
API/Services/SettingsService.cs
Normal file
@ -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<ActionResult<MetadataSettingsDto>> UpdateMetadataSettings(MetadataSettingsDto dto);
|
||||||
|
Task<ActionResult<ServerSettingDto>> 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<SettingsService> _logger;
|
||||||
|
|
||||||
|
public SettingsService(IUnitOfWork unitOfWork, IDirectoryService directoryService,
|
||||||
|
ILibraryWatcher libraryWatcher, ITaskScheduler taskScheduler,
|
||||||
|
ILogger<SettingsService> logger)
|
||||||
|
{
|
||||||
|
_unitOfWork = unitOfWork;
|
||||||
|
_directoryService = directoryService;
|
||||||
|
_libraryWatcher = libraryWatcher;
|
||||||
|
_taskScheduler = taskScheduler;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Update the metadata settings for Kavita+ Metadata feature
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dto"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
public async Task<ActionResult<MetadataSettingsDto>> 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Update Server Settings
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="updateSettingsDto"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
/// <exception cref="KavitaException"></exception>
|
||||||
|
public async Task<ActionResult<ServerSettingDto>> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -36,7 +36,6 @@ public interface IStatisticService
|
|||||||
IEnumerable<StatCount<int>> GetWordsReadCountByYear(int userId = 0);
|
IEnumerable<StatCount<int>> GetWordsReadCountByYear(int userId = 0);
|
||||||
Task UpdateServerStatistics();
|
Task UpdateServerStatistics();
|
||||||
Task<long> TimeSpentReadingForUsersAsync(IList<int> userIds, IList<int> libraryIds);
|
Task<long> TimeSpentReadingForUsersAsync(IList<int> userIds, IList<int> libraryIds);
|
||||||
Task<KavitaPlusMetadataBreakdownDto> GetKavitaPlusMetadataBreakdown();
|
|
||||||
Task<IEnumerable<FileExtensionExportDto>> GetFilesByExtension(string fileExtension);
|
Task<IEnumerable<FileExtensionExportDto>> GetFilesByExtension(string fileExtension);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -139,7 +138,9 @@ public class StatisticService : IStatisticService
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
#pragma warning disable S6561
|
||||||
var timeDifference = DateTime.Now - earliestReadDate;
|
var timeDifference = DateTime.Now - earliestReadDate;
|
||||||
|
#pragma warning restore S6561
|
||||||
var deltaWeeks = (int)Math.Ceiling(timeDifference.TotalDays / 7);
|
var deltaWeeks = (int)Math.Ceiling(timeDifference.TotalDays / 7);
|
||||||
|
|
||||||
averageReadingTimePerWeek /= deltaWeeks;
|
averageReadingTimePerWeek /= deltaWeeks;
|
||||||
@ -554,29 +555,6 @@ public class StatisticService : IStatisticService
|
|||||||
p.chapter.AvgHoursToRead * (p.progress.PagesRead / (1.0f * p.chapter.Pages))));
|
p.chapter.AvgHoursToRead * (p.progress.PagesRead / (1.0f * p.chapter.Pages))));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<KavitaPlusMetadataBreakdownDto> 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<IEnumerable<FileExtensionExportDto>> GetFilesByExtension(string fileExtension)
|
public async Task<IEnumerable<FileExtensionExportDto>> GetFilesByExtension(string fileExtension)
|
||||||
{
|
{
|
||||||
var query = _context.MangaFile
|
var query = _context.MangaFile
|
||||||
|
@ -7,6 +7,7 @@ using API.Data;
|
|||||||
using API.Data.Repositories;
|
using API.Data.Repositories;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
using API.Extensions;
|
using API.Extensions;
|
||||||
|
using API.Helpers;
|
||||||
using API.Helpers.Converters;
|
using API.Helpers.Converters;
|
||||||
using API.Services.Plus;
|
using API.Services.Plus;
|
||||||
using API.Services.Tasks;
|
using API.Services.Tasks;
|
||||||
|
@ -86,7 +86,7 @@ public class BasicParser(IDirectoryService directoryService, IDefaultParser imag
|
|||||||
{
|
{
|
||||||
ParseFromFallbackFolders(filePath, tempRootPath, type, ref ret);
|
ParseFromFallbackFolders(filePath, tempRootPath, type, ref ret);
|
||||||
}
|
}
|
||||||
|
ret.Title = Parser.CleanSpecialTitle(fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(ret.Series))
|
if (string.IsNullOrEmpty(ret.Series))
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Immutable;
|
using System.Collections.Immutable;
|
||||||
using System.Globalization;
|
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.RegularExpressions;
|
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",
|
"One Shot", "One-Shot", "Prologue", "TPB", "Trade Paper Back", "Omnibus", "Compendium", "Absolute", "Graphic Novel",
|
||||||
"GN", "FCBD", "Giant Size");
|
"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 Number = @"\d+(\.\d)?";
|
||||||
private const string NumberRange = Number + @"(-" + Number + @")?";
|
private const string NumberRange = Number + @"(-" + Number + @")?";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// non greedy matching of a string where parenthesis are balanced
|
/// non-greedy matching of a string where parenthesis are balanced
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string BalancedParen = @"(?:[^()]|(?<open>\()|(?<-open>\)))*?(?(open)(?!))";
|
public const string BalancedParen = @"(?:[^()]|(?<open>\()|(?<-open>\)))*?(?(open)(?!))";
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// non greedy matching of a string where square brackets are balanced
|
/// non-greedy matching of a string where square brackets are balanced
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string BalancedBracket = @"(?:[^\[\]]|(?<open>\[)|(?<-open>\]))*?(?(open)(?!))";
|
public const string BalancedBracket = @"(?:[^\[\]]|(?<open>\[)|(?<-open>\]))*?(?(open)(?!))";
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Matches [Complete], release tags like [kmts] but not [ Complete ] or [kmts ]
|
/// Matches [Complete], release tags like [kmts] but not [ Complete ] or [kmts ]
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private const string TagsInBrackets = $@"\[(?!\s){BalancedBracket}(?<!\s)\]";
|
private const string TagsInBrackets = $@"\[(?!\s){BalancedBracket}(?<!\s)\]";
|
||||||
/// <summary>
|
|
||||||
/// Common regex patterns present in both Comics and Mangas
|
|
||||||
/// </summary>
|
|
||||||
private const string CommonSpecial = @"Specials?|One[- ]?Shot|Extra(?:\sChapter)?(?=\s)|Art Collection|Side Stories|Bonus";
|
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Matches against font-family css syntax. Does not match if url import has data: starting, as that is binary data
|
/// Matches against font-family css syntax. Does not match if url import has data: starting, as that is binary data
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>See here for some examples https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face</remarks>
|
/// <remarks>See here for some examples https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face</remarks>
|
||||||
public static readonly Regex FontSrcUrlRegex = new Regex(@"(?<Start>(?:src:\s?)?(?:url|local)\((?!data:)" + "(?:[\"']?)" + @"(?!data:))"
|
public static readonly Regex FontSrcUrlRegex = new(@"(?<Start>(?:src:\s?)?(?:url|local)\((?!data:)" + "(?:[\"']?)" + @"(?!data:))"
|
||||||
+ "(?<Filename>(?!data:)[^\"']+?)" + "(?<End>[\"']?" + @"\);?)",
|
+ "(?<Filename>(?!data:)[^\"']+?)" + "(?<End>[\"']?" + @"\);?)",
|
||||||
MatchOptions, RegexTimeout);
|
MatchOptions, RegexTimeout);
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// https://developer.mozilla.org/en-US/docs/Web/CSS/@import
|
/// https://developer.mozilla.org/en-US/docs/Web/CSS/@import
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static readonly Regex CssImportUrlRegex = new Regex("(@import\\s([\"|']|url\\([\"|']))(?<Filename>[^'\"]+)([\"|']\\)?);",
|
public static readonly Regex CssImportUrlRegex = new("(@import\\s([\"|']|url\\([\"|']))(?<Filename>[^'\"]+)([\"|']\\)?);",
|
||||||
MatchOptions | RegexOptions.Multiline, RegexTimeout);
|
MatchOptions | RegexOptions.Multiline, RegexTimeout);
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Misc css image references, like background-image: url(), border-image, or list-style-image
|
/// Misc css image references, like background-image: url(), border-image, or list-style-image
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// Original prepend: (background|border|list-style)-image:\s?)?
|
/// Original prepend: (background|border|list-style)-image:\s?)?
|
||||||
public static readonly Regex CssImageUrlRegex = new Regex(@"(url\((?!data:).(?!data:))" + "(?<Filename>(?!data:)[^\"']*)" + @"(.\))",
|
public static readonly Regex CssImageUrlRegex = new(@"(url\((?!data:).(?!data:))" + "(?<Filename>(?!data:)[^\"']*)" + @"(.\))",
|
||||||
MatchOptions, RegexTimeout);
|
MatchOptions, RegexTimeout);
|
||||||
|
|
||||||
|
|
||||||
private static readonly Regex ImageRegex = new Regex(ImageFileExtensions,
|
private static readonly Regex ImageRegex = new(ImageFileExtensions,
|
||||||
MatchOptions, RegexTimeout);
|
MatchOptions, RegexTimeout);
|
||||||
private static readonly Regex ArchiveFileRegex = new Regex(ArchiveFileExtensions,
|
private static readonly Regex ArchiveFileRegex = new(ArchiveFileExtensions,
|
||||||
MatchOptions, RegexTimeout);
|
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);
|
MatchOptions, RegexTimeout);
|
||||||
private static readonly Regex XmlRegex = new Regex(XmlRegexExtensions,
|
private static readonly Regex XmlRegex = new(XmlRegexExtensions,
|
||||||
MatchOptions, RegexTimeout);
|
MatchOptions, RegexTimeout);
|
||||||
private static readonly Regex BookFileRegex = new Regex(BookFileExtensions,
|
private static readonly Regex BookFileRegex = new(BookFileExtensions,
|
||||||
MatchOptions, RegexTimeout);
|
MatchOptions, RegexTimeout);
|
||||||
private static readonly Regex CoverImageRegex = new Regex(@"(?<![[a-z]\d])(?:!?)(?<!back)(?<!back_)(?<!back-)(cover|folder)(?![\w\d])",
|
private static readonly Regex CoverImageRegex = new(@"(?<!back[\s_-])(?<!\(back )(?<!back)(?:^|[^a-zA-Z0-9])(!?cover|folder)(?![a-zA-Z0-9]|s\b)",
|
||||||
MatchOptions, RegexTimeout);
|
MatchOptions, RegexTimeout);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Normalize everything within Kavita. Some characters don't fall under Unicode, like full-width characters and need to be
|
/// 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.
|
/// added on a case-by-case basis.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static readonly Regex NormalizeRegex = new Regex(@"[^\p{L}0-9\+!*!+]",
|
private static readonly Regex NormalizeRegex = new(@"[^\p{L}0-9\+!*!+]",
|
||||||
MatchOptions, RegexTimeout);
|
MatchOptions, RegexTimeout);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Supports Batman (2020) or Batman (2)
|
/// Supports Batman (2020) or Batman (2)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static readonly Regex SeriesAndYearRegex = new Regex(@"^\D+\s\((?<Year>\d+)\)$",
|
private static readonly Regex SeriesAndYearRegex = new(@"^\D+\s\((?<Year>\d+)\)$",
|
||||||
MatchOptions, RegexTimeout);
|
MatchOptions, RegexTimeout);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Recognizes the Special token only
|
/// Recognizes the Special token only
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static readonly Regex SpecialTokenRegex = new Regex(@"SP\d+",
|
private static readonly Regex SpecialTokenRegex = new(@"SP\d+",
|
||||||
MatchOptions, RegexTimeout);
|
MatchOptions, RegexTimeout);
|
||||||
|
|
||||||
|
|
||||||
private static readonly Regex[] MangaVolumeRegex = new[]
|
private static readonly Regex[] MangaVolumeRegex =
|
||||||
{
|
[
|
||||||
// Thai Volume: เล่ม n -> Volume n
|
// Thai Volume: เล่ม n -> Volume n
|
||||||
new Regex(
|
new Regex(
|
||||||
@"(เล่ม|เล่มที่)(\s)?(\.?)(\s|_)?(?<Volume>\d+(\-\d+)?(\.\d+)?)",
|
@"(เล่ม|เล่มที่)(\s)?(\.?)(\s|_)?(?<Volume>\d+(\-\d+)?(\.\d+)?)",
|
||||||
@ -197,11 +192,11 @@ public static partial class Parser
|
|||||||
// Russian Volume: n Том -> Volume n
|
// Russian Volume: n Том -> Volume n
|
||||||
new Regex(
|
new Regex(
|
||||||
@"(\s|_)?(?<Volume>\d+(?:(\-)\d+)?)(\s|_)Том(а?)",
|
@"(\s|_)?(?<Volume>\d+(?:(\-)\d+)?)(\s|_)Том(а?)",
|
||||||
MatchOptions, RegexTimeout),
|
MatchOptions, RegexTimeout)
|
||||||
};
|
];
|
||||||
|
|
||||||
private static readonly Regex[] MangaSeriesRegex = new[]
|
private static readonly Regex[] MangaSeriesRegex =
|
||||||
{
|
[
|
||||||
// Thai Volume: เล่ม n -> Volume n
|
// Thai Volume: เล่ม n -> Volume n
|
||||||
new Regex(
|
new Regex(
|
||||||
@"(?<Series>.+?)(เล่ม|เล่มที่)(\s)?(\.?)(\s|_)?(?<Volume>\d+(\-\d+)?(\.\d+)?)",
|
@"(?<Series>.+?)(เล่ม|เล่มที่)(\s)?(\.?)(\s|_)?(?<Volume>\d+(\-\d+)?(\.\d+)?)",
|
||||||
@ -374,12 +369,12 @@ public static partial class Parser
|
|||||||
// Japanese Volume: n巻 -> Volume n
|
// Japanese Volume: n巻 -> Volume n
|
||||||
new Regex(
|
new Regex(
|
||||||
@"(?<Series>.+?)第(?<Volume>\d+(?:(\-)\d+)?)巻",
|
@"(?<Series>.+?)第(?<Volume>\d+(?:(\-)\d+)?)巻",
|
||||||
MatchOptions, RegexTimeout),
|
MatchOptions, RegexTimeout)
|
||||||
|
|
||||||
};
|
];
|
||||||
|
|
||||||
private static readonly Regex[] ComicSeriesRegex = new[]
|
private static readonly Regex[] ComicSeriesRegex =
|
||||||
{
|
[
|
||||||
// Thai Volume: เล่ม n -> Volume n
|
// Thai Volume: เล่ม n -> Volume n
|
||||||
new Regex(
|
new Regex(
|
||||||
@"(?<Series>.+?)(เล่ม|เล่มที่)(\s)?(\.?)(\s|_)?(?<Volume>\d+(\-\d+)?(\.\d+)?)",
|
@"(?<Series>.+?)(เล่ม|เล่มที่)(\s)?(\.?)(\s|_)?(?<Volume>\d+(\-\d+)?(\.\d+)?)",
|
||||||
@ -467,11 +462,11 @@ public static partial class Parser
|
|||||||
// MUST BE LAST: Batman & Daredevil - King of New York
|
// MUST BE LAST: Batman & Daredevil - King of New York
|
||||||
new Regex(
|
new Regex(
|
||||||
@"^(?<Series>.*)",
|
@"^(?<Series>.*)",
|
||||||
MatchOptions, RegexTimeout),
|
MatchOptions, RegexTimeout)
|
||||||
};
|
];
|
||||||
|
|
||||||
private static readonly Regex[] ComicVolumeRegex = new[]
|
private static readonly Regex[] ComicVolumeRegex =
|
||||||
{
|
[
|
||||||
// Thai Volume: เล่ม n -> Volume n
|
// Thai Volume: เล่ม n -> Volume n
|
||||||
new Regex(
|
new Regex(
|
||||||
@"(เล่ม|เล่มที่)(\s)?(\.?)(\s|_)?(?<Volume>\d+(\-\d+)?(\.\d+)?)",
|
@"(เล่ม|เล่มที่)(\s)?(\.?)(\s|_)?(?<Volume>\d+(\-\d+)?(\.\d+)?)",
|
||||||
@ -507,11 +502,11 @@ public static partial class Parser
|
|||||||
// Russian Volume: n Том -> Volume n
|
// Russian Volume: n Том -> Volume n
|
||||||
new Regex(
|
new Regex(
|
||||||
@"(\s|_)?(?<Volume>\d+(?:(\-)\d+)?)(\s|_)Том(а?)",
|
@"(\s|_)?(?<Volume>\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
|
// Thai Volume: บทที่ n -> Chapter n, ตอนที่ n -> Chapter n
|
||||||
new Regex(
|
new Regex(
|
||||||
@"(บทที่|ตอนที่)(\s)?(\.?)(\s|_)?(?<Chapter>\d+(\-\d+)?(\.\d+)?)",
|
@"(บทที่|ตอนที่)(\s)?(\.?)(\s|_)?(?<Chapter>\d+(\-\d+)?(\.\d+)?)",
|
||||||
@ -576,11 +571,11 @@ public static partial class Parser
|
|||||||
// spawn-123, spawn-chapter-123 (from https://github.com/Girbons/comics-downloader)
|
// spawn-123, spawn-chapter-123 (from https://github.com/Girbons/comics-downloader)
|
||||||
new Regex(
|
new Regex(
|
||||||
@"^(?<Series>.+?)-(chapter-)?(?<Chapter>\d+)",
|
@"^(?<Series>.+?)-(chapter-)?(?<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
|
// Thai Chapter: บทที่ n -> Chapter n, ตอนที่ n -> Chapter n, เล่ม n -> Volume n, เล่มที่ n -> Volume n
|
||||||
new Regex(
|
new Regex(
|
||||||
@"(?<Volume>((เล่ม|เล่มที่))?(\s|_)?\.?\d+)(\s|_)(บทที่|ตอนที่)\.?(\s|_)?(?<Chapter>\d+)",
|
@"(?<Volume>((เล่ม|เล่มที่))?(\s|_)?\.?\d+)(\s|_)(บทที่|ตอนที่)\.?(\s|_)?(?<Chapter>\d+)",
|
||||||
@ -645,8 +640,8 @@ public static partial class Parser
|
|||||||
// Russian Chapter: n Главa -> Chapter n
|
// Russian Chapter: n Главa -> Chapter n
|
||||||
new Regex(
|
new Regex(
|
||||||
@"(?!Том)(?<!Том\.)\s\d+(\s|_)?(?<Chapter>\d+(?:\.\d+|-\d+)?)(\s|_)(Глава|глава|Главы|Глава)",
|
@"(?!Том)(?<!Том\.)\s\d+(\s|_)?(?<Chapter>\d+(?:\.\d+|-\d+)?)(\s|_)(Глава|глава|Главы|Глава)",
|
||||||
MatchOptions, RegexTimeout),
|
MatchOptions, RegexTimeout)
|
||||||
};
|
];
|
||||||
|
|
||||||
private static readonly Regex MangaEditionRegex = new Regex(
|
private static readonly Regex MangaEditionRegex = new Regex(
|
||||||
// Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz
|
// Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz
|
||||||
@ -661,25 +656,6 @@ public static partial class Parser
|
|||||||
MatchOptions, RegexTimeout
|
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.
|
// 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(
|
private static readonly Regex SpecialMarkerRegex = new Regex(
|
||||||
@"SP\d+",
|
@"SP\d+",
|
||||||
@ -732,20 +708,6 @@ public static partial class Parser
|
|||||||
return HasSpecialMarker(filePath);
|
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)
|
public static string ParseMangaSeries(string filename)
|
||||||
{
|
{
|
||||||
foreach (var regex in MangaSeriesRegex)
|
foreach (var regex in MangaSeriesRegex)
|
||||||
@ -932,22 +894,6 @@ public static partial class Parser
|
|||||||
return title;
|
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Translates _ -> spaces, trims front and back of string, removes release groups
|
/// Translates _ -> spaces, trims front and back of string, removes release groups
|
||||||
@ -966,20 +912,6 @@ public static partial class Parser
|
|||||||
|
|
||||||
title = RemoveEditionTagHolders(title);
|
title = RemoveEditionTagHolders(title);
|
||||||
|
|
||||||
// if (replaceSpecials)
|
|
||||||
// {
|
|
||||||
// if (isComic)
|
|
||||||
// {
|
|
||||||
// title = RemoveComicSpecialTags(title);
|
|
||||||
// title = RemoveEuropeanTags(title);
|
|
||||||
// }
|
|
||||||
// else
|
|
||||||
// {
|
|
||||||
// title = RemoveMangaSpecialTags(title);
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
|
|
||||||
title = title.Trim(SpacesAndSeparators);
|
title = title.Trim(SpacesAndSeparators);
|
||||||
|
|
||||||
title = EmptySpaceRegex.Replace(title, " ");
|
title = EmptySpaceRegex.Replace(title, " ");
|
||||||
@ -1110,11 +1042,6 @@ public static partial class Parser
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(name)) return name;
|
if (string.IsNullOrEmpty(name)) return name;
|
||||||
var cleaned = SpecialTokenRegex.Replace(name.Replace('_', ' '), string.Empty).Trim();
|
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;
|
return string.IsNullOrEmpty(cleaned) ? name : cleaned;
|
||||||
}
|
}
|
||||||
@ -1132,7 +1059,7 @@ public static partial class Parser
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
/// doesn't start with ._, which is a metadata file on MACOSX.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="path"></param>
|
/// <param name="path"></param>
|
||||||
|
@ -51,7 +51,7 @@ public interface IVersionUpdaterService
|
|||||||
Task<UpdateNotificationDto?> CheckForUpdate();
|
Task<UpdateNotificationDto?> CheckForUpdate();
|
||||||
Task PushUpdate(UpdateNotificationDto update);
|
Task PushUpdate(UpdateNotificationDto update);
|
||||||
Task<IList<UpdateNotificationDto>> GetAllReleases(int count = 0);
|
Task<IList<UpdateNotificationDto>> GetAllReleases(int count = 0);
|
||||||
Task<int> GetNumberOfReleasesBehind();
|
Task<int> GetNumberOfReleasesBehind(bool stableOnly = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -112,6 +112,10 @@ public partial class VersionUpdaterService : IVersionUpdaterService
|
|||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Will add any extra (nightly) updates from the latest stable. Does not back-fill anything prior to the latest stable.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dtos"></param>
|
||||||
private async Task EnrichWithNightlyInfo(List<UpdateNotificationDto> dtos)
|
private async Task EnrichWithNightlyInfo(List<UpdateNotificationDto> dtos)
|
||||||
{
|
{
|
||||||
var dto = dtos[0]; // Latest version
|
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 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);
|
await EnrichWithNightlyInfo(updateDtos);
|
||||||
}
|
}
|
||||||
@ -397,22 +401,25 @@ public partial class VersionUpdaterService : IVersionUpdaterService
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public async Task<int> GetNumberOfReleasesBehind()
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="stableOnly">Only count Stable releases </param>
|
||||||
|
/// <returns></returns>
|
||||||
|
public async Task<int> GetNumberOfReleasesBehind(bool stableOnly = false)
|
||||||
{
|
{
|
||||||
var updates = await GetAllReleases();
|
var updates = await GetAllReleases();
|
||||||
|
|
||||||
// If the user is on nightly, then we need to handle releases behind differently
|
// 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
|
return updates.Count(u => u.IsReleaseNewer);
|
||||||
.TakeWhile(update => update.UpdateVersion != update.CurrentVersion)
|
|
||||||
.Count() - 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Math.Min(0, updates
|
return updates
|
||||||
.Where(update => !update.IsPrerelease)
|
.Where(update => !update.IsPrerelease)
|
||||||
.TakeWhile(update => update.UpdateVersion != update.CurrentVersion)
|
.Count(u => u.IsReleaseNewer);
|
||||||
.Count());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private UpdateNotificationDto? CreateDto(GithubReleaseMetadata? update)
|
private UpdateNotificationDto? CreateDto(GithubReleaseMetadata? update)
|
||||||
|
@ -12,8 +12,8 @@
|
|||||||
<PackageReference Include="Cronos" Version="0.9.0" />
|
<PackageReference Include="Cronos" Version="0.9.0" />
|
||||||
<PackageReference Include="DotNet.Glob" Version="3.1.3" />
|
<PackageReference Include="DotNet.Glob" Version="3.1.3" />
|
||||||
<PackageReference Include="Flurl.Http" Version="4.0.2" />
|
<PackageReference Include="Flurl.Http" Version="4.0.2" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.2" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.3" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.2" />
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.3" />
|
||||||
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.7.0.110445">
|
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.7.0.110445">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
@ -2,9 +2,13 @@
|
|||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=1BC0273F_002DFEBE_002D4DA1_002DBC04_002D3A3167E4C86C_002Fd_003AData_002Fd_003AMigrations/@EntryIndexedValue">ExplicitlyExcluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=1BC0273F_002DFEBE_002D4DA1_002DBC04_002D3A3167E4C86C_002Fd_003AData_002Fd_003AMigrations/@EntryIndexedValue">ExplicitlyExcluded</s:String>
|
||||||
<s:Boolean x:Key="/Default/CodeInspection/Highlighting/RunLongAnalysisInSwa/@EntryValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/CodeInspection/Highlighting/RunLongAnalysisInSwa/@EntryValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/CodeInspection/Highlighting/RunValueAnalysisInNullableWarningsEnabledContext2/@EntryValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/CodeInspection/Highlighting/RunValueAnalysisInNullableWarningsEnabledContext2/@EntryValue">True</s:Boolean>
|
||||||
|
<s:Boolean x:Key="/Default/Environment/Filtering/ExcludeCoverageFilters/=API_003B_002A_003BAPI_002EExtensions_002EFlurlExtensions_003B_002A/@EntryIndexedValue">True</s:Boolean>
|
||||||
|
<s:Boolean x:Key="/Default/Environment/Filtering/ExcludeCoverageFilters/=API_003B_002A_003BAPI_002EExtensions_002EHttpExtensions_003B_002A/@EntryIndexedValue">True</s:Boolean>
|
||||||
|
<s:Boolean x:Key="/Default/Environment/Filtering/ExcludeCoverageFilters/=API_003B_002A_003BAPI_002EExtensions_002EIdentityServiceExtensions_003B_002A/@EntryIndexedValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=appsettings/@EntryIndexedValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/UserDictionary/Words/=appsettings/@EntryIndexedValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=Docnet/@EntryIndexedValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/UserDictionary/Words/=Docnet/@EntryIndexedValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=epubs/@EntryIndexedValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/UserDictionary/Words/=epubs/@EntryIndexedValue">True</s:Boolean>
|
||||||
|
<s:Boolean x:Key="/Default/UserDictionary/Words/=fcbd/@EntryIndexedValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=kavitaignore/@EntryIndexedValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/UserDictionary/Words/=kavitaignore/@EntryIndexedValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=kavitaignores/@EntryIndexedValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/UserDictionary/Words/=kavitaignores/@EntryIndexedValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=langs/@EntryIndexedValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/UserDictionary/Words/=langs/@EntryIndexedValue">True</s:Boolean>
|
||||||
|
@ -53,8 +53,8 @@ export class ServerService {
|
|||||||
return this.http.get<UpdateVersionEvent | null>(this.baseUrl + 'server/check-update');
|
return this.http.get<UpdateVersionEvent | null>(this.baseUrl + 'server/check-update');
|
||||||
}
|
}
|
||||||
|
|
||||||
checkHowOutOfDate() {
|
checkHowOutOfDate(stableOnly: boolean = true) {
|
||||||
return this.http.get<string>(this.baseUrl + 'server/check-out-of-date', TextResonse)
|
return this.http.get<string>(this.baseUrl + `server/check-out-of-date?stableOnly=${stableOnly}`, TextResonse)
|
||||||
.pipe(map(r => parseInt(r, 10)));
|
.pipe(map(r => parseInt(r, 10)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@ import {inject, Injectable, OnDestroy} from '@angular/core';
|
|||||||
import {interval, Subscription, switchMap} from 'rxjs';
|
import {interval, Subscription, switchMap} from 'rxjs';
|
||||||
import {ServerService} from "./server.service";
|
import {ServerService} from "./server.service";
|
||||||
import {AccountService} from "./account.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 {NgbModal} from "@ng-bootstrap/ng-bootstrap";
|
||||||
import {NewUpdateModalComponent} from "../announcements/_components/new-update-modal/new-update-modal.component";
|
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";
|
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 accountService = inject(AccountService);
|
||||||
private readonly modalService = inject(NgbModal);
|
private readonly modalService = inject(NgbModal);
|
||||||
|
|
||||||
public static readonly versionKey = 'kavita--version';
|
public static readonly SERVER_VERSION_KEY = 'kavita--version';
|
||||||
private readonly checkInterval = 600000; // 10 minutes (600000)
|
public static readonly CLIENT_REFRESH_KEY = 'kavita--client-refresh-last-shown';
|
||||||
private periodicCheckSubscription?: Subscription;
|
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 outOfDateCheckSubscription?: Subscription;
|
||||||
private modalOpen = false;
|
private modalOpen = false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.startPeriodicUpdateCheck();
|
this.startVersionCheck();
|
||||||
this.startOutOfDateCheck();
|
this.startOutOfDateCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
this.periodicCheckSubscription?.unsubscribe();
|
this.versionCheckSubscription?.unsubscribe();
|
||||||
this.outOfDateCheckSubscription?.unsubscribe();
|
this.outOfDateCheckSubscription?.unsubscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
private startOutOfDateCheck() {
|
/**
|
||||||
// Every hour, have the UI check for an update. People seriously stay out of date
|
* Periodic check for server version to detect client refreshes and new updates
|
||||||
this.outOfDateCheckSubscription = interval(2* 60 * 60 * 1000) // 2 hours in milliseconds
|
*/
|
||||||
|
private startVersionCheck(): void {
|
||||||
|
console.log('Starting version checker');
|
||||||
|
this.versionCheckSubscription = interval(this.VERSION_CHECK_INTERVAL)
|
||||||
.pipe(
|
.pipe(
|
||||||
switchMap(() => this.accountService.currentUser$),
|
switchMap(() => this.accountService.currentUser$),
|
||||||
filter(u => u !== undefined && this.accountService.hasAdminRole(u)),
|
filter(user => !!user && !this.modalOpen),
|
||||||
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),
|
|
||||||
switchMap(user => this.serverService.getVersion(user!.apiKey)),
|
switchMap(user => this.serverService.getVersion(user!.apiKey)),
|
||||||
|
filter(update => !!update),
|
||||||
).subscribe(version => this.handleVersionUpdate(version));
|
).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;
|
if (this.modalOpen) return;
|
||||||
|
|
||||||
// Pause periodic checks while the modal is open
|
const cachedVersion = localStorage.getItem(VersionService.SERVER_VERSION_KEY);
|
||||||
this.periodicCheckSubscription?.unsubscribe();
|
console.log('Server version:', serverVersion, 'Cached version:', cachedVersion);
|
||||||
|
|
||||||
const cachedVersion = localStorage.getItem(VersionService.versionKey);
|
const isNewServerVersion = cachedVersion !== null && cachedVersion !== serverVersion;
|
||||||
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());
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
|
// 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.modalOpen = false;
|
||||||
this.startPeriodicUpdateCheck();
|
this.startVersionCheck();
|
||||||
|
this.startOutOfDateCheck();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -29,7 +29,7 @@ export interface RelatedSeriesPair {
|
|||||||
styleUrl: './related-tab.component.scss',
|
styleUrl: './related-tab.component.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class RelatedTabComponent implements OnInit {
|
export class RelatedTabComponent {
|
||||||
|
|
||||||
protected readonly imageService = inject(ImageService);
|
protected readonly imageService = inject(ImageService);
|
||||||
protected readonly router = inject(Router);
|
protected readonly router = inject(Router);
|
||||||
@ -40,10 +40,6 @@ export class RelatedTabComponent implements OnInit {
|
|||||||
@Input() bookmarks: Array<PageBookmark> = [];
|
@Input() bookmarks: Array<PageBookmark> = [];
|
||||||
@Input() libraryId!: number;
|
@Input() libraryId!: number;
|
||||||
|
|
||||||
ngOnInit() {
|
|
||||||
console.log('bookmarks: ', this.bookmarks);
|
|
||||||
}
|
|
||||||
|
|
||||||
openReadingList(readingList: ReadingList) {
|
openReadingList(readingList: ReadingList) {
|
||||||
this.router.navigate(['lists', readingList.id]);
|
this.router.navigate(['lists', readingList.id]);
|
||||||
}
|
}
|
||||||
|
@ -224,7 +224,6 @@ export class LicenseComponent implements OnInit {
|
|||||||
|
|
||||||
toggleViewMode() {
|
toggleViewMode() {
|
||||||
this.isViewMode = !this.isViewMode;
|
this.isViewMode = !this.isViewMode;
|
||||||
console.log('edit mode: ', !this.isViewMode)
|
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
this.resetForm();
|
this.resetForm();
|
||||||
}
|
}
|
||||||
|
@ -64,7 +64,7 @@ export class ManageLogsComponent implements OnInit, OnDestroy {
|
|||||||
// unsubscribe from signalr connection
|
// unsubscribe from signalr connection
|
||||||
if (this.hubConnection) {
|
if (this.hubConnection) {
|
||||||
this.hubConnection.stop().catch(err => console.error(err));
|
this.hubConnection.stop().catch(err => console.error(err));
|
||||||
console.log('Stoping log connection');
|
console.log('Stopping log connection');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -7,7 +7,7 @@ import {shareReplay} from 'rxjs/operators';
|
|||||||
import {debounceTime, defer, distinctUntilChanged, filter, forkJoin, Observable, of, switchMap, tap} from 'rxjs';
|
import {debounceTime, defer, distinctUntilChanged, filter, forkJoin, Observable, of, switchMap, tap} from 'rxjs';
|
||||||
import {ServerService} from 'src/app/_services/server.service';
|
import {ServerService} from 'src/app/_services/server.service';
|
||||||
import {Job} from 'src/app/_models/job/job';
|
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 {NgbModal} from '@ng-bootstrap/ng-bootstrap';
|
||||||
import {DownloadService} from 'src/app/shared/_services/download.service';
|
import {DownloadService} from 'src/app/shared/_services/download.service';
|
||||||
import {DefaultValuePipe} from '../../_pipes/default-value.pipe';
|
import {DefaultValuePipe} from '../../_pipes/default-value.pipe';
|
||||||
@ -134,6 +134,7 @@ export class ManageTasksSettingsComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
customOption = 'custom';
|
customOption = 'custom';
|
||||||
|
|
||||||
|
|
||||||
@ -305,7 +306,6 @@ export class ManageTasksSettingsComponent implements OnInit {
|
|||||||
modelSettings.taskCleanup = this.settingsForm.get('taskCleanupCustom')?.value;
|
modelSettings.taskCleanup = this.settingsForm.get('taskCleanupCustom')?.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('modelSettings: ', modelSettings);
|
|
||||||
return modelSettings;
|
return modelSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -112,7 +112,6 @@ export class AllSeriesComponent implements OnInit {
|
|||||||
private readonly cdRef: ChangeDetectorRef) {
|
private readonly cdRef: ChangeDetectorRef) {
|
||||||
|
|
||||||
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
|
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
|
||||||
console.log('url: ', this.route.snapshot);
|
|
||||||
|
|
||||||
this.filterUtilityService.filterPresetsFromUrl(this.route.snapshot).subscribe(filter => {
|
this.filterUtilityService.filterPresetsFromUrl(this.route.snapshot).subscribe(filter => {
|
||||||
this.filter = filter;
|
this.filter = filter;
|
||||||
|
@ -7,13 +7,14 @@ import {SafeHtmlPipe} from "../../../_pipes/safe-html.pipe";
|
|||||||
import {VersionService} from "../../../_services/version.service";
|
import {VersionService} from "../../../_services/version.service";
|
||||||
import {ChangelogUpdateItemComponent} from "../changelog-update-item/changelog-update-item.component";
|
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({
|
@Component({
|
||||||
selector: 'app-new-update-modal',
|
selector: 'app-new-update-modal',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
TranslocoDirective,
|
TranslocoDirective,
|
||||||
UpdateSectionComponent,
|
|
||||||
SafeHtmlPipe,
|
|
||||||
ChangelogUpdateItemComponent
|
ChangelogUpdateItemComponent
|
||||||
],
|
],
|
||||||
templateUrl: './new-update-modal.component.html',
|
templateUrl: './new-update-modal.component.html',
|
||||||
@ -41,8 +42,6 @@ export class NewUpdateModalComponent {
|
|||||||
|
|
||||||
private applyUpdate(version: string): void {
|
private applyUpdate(version: string): void {
|
||||||
this.bustLocaleCache();
|
this.bustLocaleCache();
|
||||||
console.log('Setting version key: ', version);
|
|
||||||
localStorage.setItem(VersionService.versionKey, version);
|
|
||||||
location.reload();
|
location.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -54,8 +53,10 @@ export class NewUpdateModalComponent {
|
|||||||
(this.translocoService as any).cache.delete(locale);
|
(this.translocoService as any).cache.delete(locale);
|
||||||
(this.translocoService as any).cache.clear();
|
(this.translocoService as any).cache.clear();
|
||||||
|
|
||||||
// TODO: Retrigger transloco
|
// Retrigger transloco
|
||||||
this.translocoService.setActiveLang(locale);
|
setTimeout(() => {
|
||||||
|
this.translocoService.setActiveLang(locale);
|
||||||
|
}, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -4,8 +4,9 @@
|
|||||||
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
|
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<h5>{{updateData.updateTitle}}</h5>
|
@if (updateData) {
|
||||||
<pre class="update-body" [innerHtml]="updateData.updateBody | safeHtml"></pre>
|
<app-changelog-update-item [update]="updateData" [showExtras]="false"></app-changelog-update-item>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
@ -2,15 +2,18 @@ import {ChangeDetectionStrategy, Component, Input, OnInit} from '@angular/core';
|
|||||||
import {NgbActiveModal, NgbModalModule} from '@ng-bootstrap/ng-bootstrap';
|
import {NgbActiveModal, NgbModalModule} from '@ng-bootstrap/ng-bootstrap';
|
||||||
import { UpdateVersionEvent } from 'src/app/_models/events/update-version-event';
|
import { UpdateVersionEvent } from 'src/app/_models/events/update-version-event';
|
||||||
import {CommonModule} from "@angular/common";
|
import {CommonModule} from "@angular/common";
|
||||||
import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe";
|
import {SafeHtmlPipe} from "../../../_pipes/safe-html.pipe";
|
||||||
import {TranslocoDirective} from "@jsverse/transloco";
|
import {TranslocoDirective} from "@jsverse/transloco";
|
||||||
import {WikiLink} from "../../_models/wiki";
|
import {WikiLink} from "../../../_models/wiki";
|
||||||
|
import {
|
||||||
|
ChangelogUpdateItemComponent
|
||||||
|
} from "../changelog-update-item/changelog-update-item.component";
|
||||||
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-update-notification-modal',
|
selector: 'app-update-notification-modal',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, NgbModalModule, SafeHtmlPipe, TranslocoDirective],
|
imports: [CommonModule, NgbModalModule, SafeHtmlPipe, TranslocoDirective, ChangelogUpdateItemComponent],
|
||||||
templateUrl: './update-notification-modal.component.html',
|
templateUrl: './update-notification-modal.component.html',
|
||||||
styleUrls: ['./update-notification-modal.component.scss'],
|
styleUrls: ['./update-notification-modal.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
@ -20,6 +23,8 @@ export class UpdateNotificationModalComponent implements OnInit {
|
|||||||
@Input({required: true}) updateData!: UpdateVersionEvent;
|
@Input({required: true}) updateData!: UpdateVersionEvent;
|
||||||
updateUrl: string = WikiLink.UpdateNative;
|
updateUrl: string = WikiLink.UpdateNative;
|
||||||
|
|
||||||
|
// TODO: I think I can remove this and just use NewUpdateModalComponent instead which handles both Nightly/Stable
|
||||||
|
|
||||||
constructor(public modal: NgbActiveModal) { }
|
constructor(public modal: NgbActiveModal) { }
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
@ -97,6 +97,7 @@ export class AppComponent implements OnInit {
|
|||||||
return user.preferences.noTransitions;
|
return user.preferences.noTransitions;
|
||||||
}), takeUntilDestroyed(this.destroyRef));
|
}), takeUntilDestroyed(this.destroyRef));
|
||||||
|
|
||||||
|
this.localizationService.getLocales().subscribe(); // This will cache the localizations on startup
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -113,7 +114,6 @@ export class AppComponent implements OnInit {
|
|||||||
this.setDocHeight();
|
this.setDocHeight();
|
||||||
this.setCurrentUser();
|
this.setCurrentUser();
|
||||||
this.themeService.setColorScape('');
|
this.themeService.setColorScape('');
|
||||||
this.localizationService.getLocales().subscribe(); // This will cache the localizations on startup
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -220,7 +220,6 @@ export class VolumeCardComponent implements OnInit {
|
|||||||
read(event: any) {
|
read(event: any) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
console.log('reading volume');
|
|
||||||
this.readerService.readVolume(this.libraryId, this.seriesId, this.volume, false);
|
this.readerService.readVolume(this.libraryId, this.seriesId, this.volume, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -12,7 +12,7 @@ import { NgbModal, NgbModalRef, NgbPopover } from '@ng-bootstrap/ng-bootstrap';
|
|||||||
import {BehaviorSubject, debounceTime, startWith} from 'rxjs';
|
import {BehaviorSubject, debounceTime, startWith} from 'rxjs';
|
||||||
import { ConfirmConfig } from 'src/app/shared/confirm-dialog/_models/confirm-config';
|
import { ConfirmConfig } from 'src/app/shared/confirm-dialog/_models/confirm-config';
|
||||||
import { ConfirmService } from 'src/app/shared/confirm.service';
|
import { ConfirmService } from 'src/app/shared/confirm.service';
|
||||||
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 { DownloadService } from 'src/app/shared/_services/download.service';
|
import { DownloadService } from 'src/app/shared/_services/download.service';
|
||||||
import { ErrorEvent } from 'src/app/_models/events/error-event';
|
import { ErrorEvent } from 'src/app/_models/events/error-event';
|
||||||
import { InfoEvent } from 'src/app/_models/events/info-event';
|
import { InfoEvent } from 'src/app/_models/events/info-event';
|
||||||
|
@ -26,9 +26,9 @@
|
|||||||
{{item.username}}
|
{{item.username}}
|
||||||
</div>
|
</div>
|
||||||
<ul class="list-group list-group-flush">
|
<ul class="list-group list-group-flush">
|
||||||
<li class="list-group-item">{{t('comics-label', {value: item.comicsTime})}}</li>
|
<li class="list-group-item">{{t('comics-label', {value: item.comicsTime | number:'1.0-1'})}}</li>
|
||||||
<li class="list-group-item">{{t('manga-label', {value: item.mangaTime})}}</li>
|
<li class="list-group-item">{{t('manga-label', {value: item.mangaTime | number:'1.0-1'})}}</li>
|
||||||
<li class="list-group-item">{{t('books-label', {value: item.booksTime})}}</li>
|
<li class="list-group-item">{{t('books-label', {value: item.booksTime | number:'1.0-1'})}}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
@ -11,7 +11,7 @@ import { Observable, switchMap, shareReplay } from 'rxjs';
|
|||||||
import { StatisticsService } from 'src/app/_services/statistics.service';
|
import { StatisticsService } from 'src/app/_services/statistics.service';
|
||||||
import { TopUserRead } from '../../_models/top-reads';
|
import { TopUserRead } from '../../_models/top-reads';
|
||||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||||
import { AsyncPipe } from '@angular/common';
|
import {AsyncPipe, DecimalPipe} from '@angular/common';
|
||||||
import {TranslocoDirective} from "@jsverse/transloco";
|
import {TranslocoDirective} from "@jsverse/transloco";
|
||||||
import {CarouselReelComponent} from "../../../carousel/_components/carousel-reel/carousel-reel.component";
|
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'],
|
styleUrls: ['./top-readers.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [ReactiveFormsModule, AsyncPipe, TranslocoDirective, CarouselReelComponent]
|
imports: [ReactiveFormsModule, AsyncPipe, TranslocoDirective, CarouselReelComponent, DecimalPipe]
|
||||||
})
|
})
|
||||||
export class TopReadersComponent implements OnInit {
|
export class TopReadersComponent implements OnInit {
|
||||||
|
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
private readonly statsService = inject(StatisticsService);
|
||||||
|
private readonly cdRef = inject(ChangeDetectorRef);
|
||||||
|
|
||||||
formGroup: FormGroup;
|
formGroup: FormGroup;
|
||||||
timePeriods = TimePeriods;
|
timePeriods = TimePeriods;
|
||||||
users$: Observable<TopUserRead[]>;
|
users$: Observable<TopUserRead[]>;
|
||||||
|
|
||||||
|
|
||||||
constructor(private statsService: StatisticsService, private readonly cdRef: ChangeDetectorRef) {
|
constructor() {
|
||||||
this.formGroup = new FormGroup({
|
this.formGroup = new FormGroup({
|
||||||
'days': new FormControl(this.timePeriods[0].value, []),
|
'days': new FormControl(this.timePeriods[0].value, []),
|
||||||
});
|
});
|
||||||
|
Loading…
x
Reference in New Issue
Block a user