Release Polish 2 (#3354)

This commit is contained in:
Joe Milazzo 2024-11-09 14:05:17 -06:00 committed by GitHub
parent e1aba57783
commit 0e0d8dca5b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 333 additions and 287 deletions

View File

@ -12,7 +12,7 @@
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.14.0" />
<PackageReference Include="BenchmarkDotNet.Annotations" Version="0.14.0" />
<PackageReference Include="NSubstitute" Version="5.1.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
</ItemGroup>
<ItemGroup>

View File

@ -8,9 +8,9 @@
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="NSubstitute" Version="5.1.0" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="21.0.29" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="21.0.29" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="21.1.3" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="21.1.3" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View File

@ -0,0 +1,207 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Abstractions;
using System.IO.Compression;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using System.Xml;
using System.Xml.Serialization;
using API.Data;
using API.Data.Metadata;
using API.Entities;
using API.Entities.Enums;
using API.Helpers;
using API.Helpers.Builders;
using API.Services;
using API.Services.Plus;
using API.Services.Tasks;
using API.Services.Tasks.Metadata;
using API.Services.Tasks.Scanner;
using API.SignalR;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit.Abstractions;
namespace API.Tests.Helpers;
public class ScannerHelper
{
private readonly IUnitOfWork _unitOfWork;
private readonly ITestOutputHelper _testOutputHelper;
private readonly string _testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/ScanTests");
private readonly string _testcasesDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/TestCases");
private readonly string _imagePath = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/1x1.png");
private static readonly string[] ComicInfoExtensions = new[] { ".cbz", ".cbr", ".zip", ".rar" };
public ScannerHelper(IUnitOfWork unitOfWork, ITestOutputHelper testOutputHelper)
{
_unitOfWork = unitOfWork;
_testOutputHelper = testOutputHelper;
}
public async Task<Library> GenerateScannerData(string testcase, Dictionary<string, ComicInfo> comicInfos = null)
{
var testDirectoryPath = await GenerateTestDirectory(Path.Join(_testcasesDirectory, testcase), comicInfos);
var (publisher, type) = SplitPublisherAndLibraryType(Path.GetFileNameWithoutExtension(testcase));
var library = new LibraryBuilder(publisher, type)
.WithFolders([new FolderPath() {Path = testDirectoryPath}])
.Build();
var admin = new AppUserBuilder("admin", "admin@kavita.com", Seed.DefaultThemes[0])
.WithLibrary(library)
.Build();
_unitOfWork.UserRepository.Add(admin); // Admin is needed for generating collections/reading lists
_unitOfWork.LibraryRepository.Add(library);
await _unitOfWork.CommitAsync();
return library;
}
public ScannerService CreateServices()
{
var fs = new FileSystem();
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fs);
var archiveService = new ArchiveService(Substitute.For<ILogger<ArchiveService>>(), ds,
Substitute.For<IImageService>(), Substitute.For<IMediaErrorService>());
var readingItemService = new ReadingItemService(archiveService, Substitute.For<IBookService>(),
Substitute.For<IImageService>(), ds, Substitute.For<ILogger<ReadingItemService>>());
var processSeries = new ProcessSeries(_unitOfWork, Substitute.For<ILogger<ProcessSeries>>(),
Substitute.For<IEventHub>(),
ds, Substitute.For<ICacheHelper>(), readingItemService, new FileService(fs),
Substitute.For<IMetadataService>(),
Substitute.For<IWordCountAnalyzerService>(),
Substitute.For<IReadingListService>(),
Substitute.For<IExternalMetadataService>());
var scanner = new ScannerService(_unitOfWork, Substitute.For<ILogger<ScannerService>>(),
Substitute.For<IMetadataService>(),
Substitute.For<ICacheService>(), Substitute.For<IEventHub>(), ds,
readingItemService, processSeries, Substitute.For<IWordCountAnalyzerService>());
return scanner;
}
private static (string Publisher, LibraryType Type) SplitPublisherAndLibraryType(string input)
{
// Split the input string based on " - "
var parts = input.Split(" - ", StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 2)
{
throw new ArgumentException("Input must be in the format 'Publisher - LibraryType'");
}
var publisher = parts[0].Trim();
var libraryTypeString = parts[1].Trim();
// Try to parse the right-hand side as a LibraryType enum
if (!Enum.TryParse<LibraryType>(libraryTypeString, out var libraryType))
{
throw new ArgumentException($"'{libraryTypeString}' is not a valid LibraryType");
}
return (publisher, libraryType);
}
private async Task<string> GenerateTestDirectory(string mapPath, Dictionary<string, ComicInfo> comicInfos = null)
{
// Read the map file
var mapContent = await File.ReadAllTextAsync(mapPath);
// Deserialize the JSON content into a list of strings using System.Text.Json
var filePaths = JsonSerializer.Deserialize<List<string>>(mapContent);
// Create a test directory
var testDirectory = Path.Combine(_testDirectory, Path.GetFileNameWithoutExtension(mapPath));
if (Directory.Exists(testDirectory))
{
Directory.Delete(testDirectory, true);
}
Directory.CreateDirectory(testDirectory);
// Generate the files and folders
await Scaffold(testDirectory, filePaths, comicInfos);
_testOutputHelper.WriteLine($"Test Directory Path: {testDirectory}");
return testDirectory;
}
public async Task Scaffold(string testDirectory, List<string> filePaths, Dictionary<string, ComicInfo> comicInfos = null)
{
foreach (var relativePath in filePaths)
{
var fullPath = Path.Combine(testDirectory, relativePath);
var fileDir = Path.GetDirectoryName(fullPath);
// Create the directory if it doesn't exist
if (!Directory.Exists(fileDir))
{
Directory.CreateDirectory(fileDir);
Console.WriteLine($"Created directory: {fileDir}");
}
var ext = Path.GetExtension(fullPath).ToLower();
if (ComicInfoExtensions.Contains(ext) && comicInfos != null && comicInfos.TryGetValue(Path.GetFileName(relativePath), out var info))
{
CreateMinimalCbz(fullPath, info);
}
else
{
// Create an empty file
await File.Create(fullPath).DisposeAsync();
Console.WriteLine($"Created empty file: {fullPath}");
}
}
}
private void CreateMinimalCbz(string filePath, ComicInfo? comicInfo = null)
{
using (var archive = ZipFile.Open(filePath, ZipArchiveMode.Create))
{
// Add the 1x1 image to the archive
archive.CreateEntryFromFile(_imagePath, "1x1.png");
if (comicInfo != null)
{
// Serialize ComicInfo object to XML
var comicInfoXml = SerializeComicInfoToXml(comicInfo);
// Create an entry for ComicInfo.xml in the archive
var entry = archive.CreateEntry("ComicInfo.xml");
using var entryStream = entry.Open();
using var writer = new StreamWriter(entryStream, Encoding.UTF8);
// Write the XML to the archive
writer.Write(comicInfoXml);
}
}
Console.WriteLine($"Created minimal CBZ archive: {filePath} with{(comicInfo != null ? "" : "out")} metadata.");
}
private static string SerializeComicInfoToXml(ComicInfo comicInfo)
{
var xmlSerializer = new XmlSerializer(typeof(ComicInfo));
using var stringWriter = new StringWriter();
using (var xmlWriter = XmlWriter.Create(stringWriter, new XmlWriterSettings { Indent = true, Encoding = new UTF8Encoding(false), OmitXmlDeclaration = false}))
{
xmlSerializer.Serialize(xmlWriter, comicInfo);
}
// For the love of god, I spent 2 hours trying to get utf-8 with no BOM
return stringWriter.ToString().Replace("""<?xml version="1.0" encoding="utf-16"?>""",
@"<?xml version='1.0' encoding='utf-8'?>");
}
}

View File

@ -36,10 +36,8 @@ namespace API.Tests.Services;
public class ScannerServiceTests : AbstractDbTest
{
private readonly ITestOutputHelper _testOutputHelper;
private readonly ScannerHelper _scannerHelper;
private readonly string _testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/ScanTests");
private readonly string _testcasesDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/TestCases");
private readonly string _imagePath = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/1x1.png");
private static readonly string[] ComicInfoExtensions = new[] { ".cbz", ".cbr", ".zip", ".rar" };
public ScannerServiceTests(ITestOutputHelper testOutputHelper)
{
@ -47,6 +45,7 @@ public class ScannerServiceTests : AbstractDbTest
// Set up Hangfire to use in-memory storage for testing
GlobalConfiguration.Configuration.UseInMemoryStorage();
_scannerHelper = new ScannerHelper(_unitOfWork, testOutputHelper);
}
protected override async Task ResetDb()
@ -59,8 +58,8 @@ public class ScannerServiceTests : AbstractDbTest
public async Task ScanLibrary_ComicVine_PublisherFolder()
{
var testcase = "Publisher - ComicVine.json";
var library = await GenerateScannerData(testcase);
var scanner = CreateServices();
var library = await _scannerHelper.GenerateScannerData(testcase);
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
@ -72,8 +71,8 @@ public class ScannerServiceTests : AbstractDbTest
public async Task ScanLibrary_ShouldCombineNestedFolder()
{
var testcase = "Series and Series-Series Combined - Manga.json";
var library = await GenerateScannerData(testcase);
var scanner = CreateServices();
var library = await _scannerHelper.GenerateScannerData(testcase);
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
@ -87,8 +86,8 @@ public class ScannerServiceTests : AbstractDbTest
public async Task ScanLibrary_FlatSeries()
{
var testcase = "Flat Series - Manga.json";
var library = await GenerateScannerData(testcase);
var scanner = CreateServices();
var library = await _scannerHelper.GenerateScannerData(testcase);
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
@ -103,8 +102,8 @@ public class ScannerServiceTests : AbstractDbTest
public async Task ScanLibrary_FlatSeriesWithSpecialFolder()
{
var testcase = "Flat Series with Specials Folder - Manga.json";
var library = await GenerateScannerData(testcase);
var scanner = CreateServices();
var library = await _scannerHelper.GenerateScannerData(testcase);
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
@ -119,8 +118,8 @@ public class ScannerServiceTests : AbstractDbTest
{
const string testcase = "Flat Special - Manga.json";
var library = await GenerateScannerData(testcase);
var scanner = CreateServices();
var library = await _scannerHelper.GenerateScannerData(testcase);
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
@ -136,8 +135,8 @@ public class ScannerServiceTests : AbstractDbTest
{
const string testcase = "Scan Library Parses as ( - Manga.json";
var library = await GenerateScannerData(testcase);
var scanner = CreateServices();
var library = await _scannerHelper.GenerateScannerData(testcase);
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
@ -165,10 +164,10 @@ public class ScannerServiceTests : AbstractDbTest
LocalizedSeries = "Sono Bisque Doll wa Koi wo Suru"
});
var library = await GenerateScannerData(testcase, infos);
var library = await _scannerHelper.GenerateScannerData(testcase, infos);
var scanner = CreateServices();
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
@ -190,10 +189,10 @@ public class ScannerServiceTests : AbstractDbTest
LocalizedSeries = "Futoku no Guild" // Filename has a capital N and localizedSeries has lowercase
});
var library = await GenerateScannerData(testcase, infos);
var library = await _scannerHelper.GenerateScannerData(testcase, infos);
var scanner = CreateServices();
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
@ -221,10 +220,10 @@ public class ScannerServiceTests : AbstractDbTest
Series = "The Novel's Extra",
});
var library = await GenerateScannerData(testcase, infos);
var library = await _scannerHelper.GenerateScannerData(testcase, infos);
var scanner = CreateServices();
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
@ -245,10 +244,10 @@ public class ScannerServiceTests : AbstractDbTest
{
const string testcase = "Image Series with SP Folder - Manga.json";
var library = await GenerateScannerData(testcase);
var library = await _scannerHelper.GenerateScannerData(testcase);
var scanner = CreateServices();
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
@ -265,10 +264,10 @@ public class ScannerServiceTests : AbstractDbTest
{
const string testcase = "Image Series with SP Folder (Non English) - Image.json";
var library = await GenerateScannerData(testcase);
var library = await _scannerHelper.GenerateScannerData(testcase);
var scanner = CreateServices();
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
@ -303,10 +302,10 @@ public class ScannerServiceTests : AbstractDbTest
Publisher = "Chapter Publisher"
});
var library = await GenerateScannerData(testcase, infos);
var library = await _scannerHelper.GenerateScannerData(testcase, infos);
var scanner = CreateServices();
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
@ -327,10 +326,10 @@ public class ScannerServiceTests : AbstractDbTest
{
const string testcase = "PDF Comic Chapters - Comic.json";
var library = await GenerateScannerData(testcase);
var library = await _scannerHelper.GenerateScannerData(testcase);
var scanner = CreateServices();
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
@ -346,10 +345,10 @@ public class ScannerServiceTests : AbstractDbTest
{
const string testcase = "PDF Comic Chapters - LightNovel.json";
var library = await GenerateScannerData(testcase);
var library = await _scannerHelper.GenerateScannerData(testcase);
var scanner = CreateServices();
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
@ -376,10 +375,10 @@ public class ScannerServiceTests : AbstractDbTest
LocalizedSeries = "Sono Bisque Doll wa Koi wo Suru"
});
var library = await GenerateScannerData(testcase, infos);
var library = await _scannerHelper.GenerateScannerData(testcase, infos);
var scanner = CreateServices();
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
@ -391,7 +390,7 @@ public class ScannerServiceTests : AbstractDbTest
// Bootstrap a new file in the nested "Sono Bisque Doll wa Koi wo Suru" directory and perform a series scan
var testDirectory = Path.Combine(_testDirectory, Path.GetFileNameWithoutExtension(testcase));
await Scaffold(testDirectory, ["My Dress-Up Darling/Sono Bisque Doll wa Koi wo Suru ch 11.cbz"]);
await _scannerHelper.Scaffold(testDirectory, ["My Dress-Up Darling/Sono Bisque Doll wa Koi wo Suru ch 11.cbz"]);
// Now that a new file exists in the subdirectory, scan again
await scanner.ScanSeries(series.Id);
@ -399,170 +398,4 @@ public class ScannerServiceTests : AbstractDbTest
Assert.Equal(3, series.Volumes.Count);
Assert.Equal(2, series.Volumes.First(v => v.MinNumber.Is(Parser.LooseLeafVolumeNumber)).Chapters.Count);
}
#region Setup
private async Task<Library> GenerateScannerData(string testcase, Dictionary<string, ComicInfo> comicInfos = null)
{
var testDirectoryPath = await GenerateTestDirectory(Path.Join(_testcasesDirectory, testcase), comicInfos);
var (publisher, type) = SplitPublisherAndLibraryType(Path.GetFileNameWithoutExtension(testcase));
var library = new LibraryBuilder(publisher, type)
.WithFolders([new FolderPath() {Path = testDirectoryPath}])
.Build();
var admin = new AppUserBuilder("admin", "admin@kavita.com", Seed.DefaultThemes[0])
.WithLibrary(library)
.Build();
_unitOfWork.UserRepository.Add(admin); // Admin is needed for generating collections/reading lists
_unitOfWork.LibraryRepository.Add(library);
await _unitOfWork.CommitAsync();
return library;
}
private ScannerService CreateServices()
{
var fs = new FileSystem();
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fs);
var archiveService = new ArchiveService(Substitute.For<ILogger<ArchiveService>>(), ds,
Substitute.For<IImageService>(), Substitute.For<IMediaErrorService>());
var readingItemService = new ReadingItemService(archiveService, Substitute.For<IBookService>(),
Substitute.For<IImageService>(), ds, Substitute.For<ILogger<ReadingItemService>>());
var processSeries = new ProcessSeries(_unitOfWork, Substitute.For<ILogger<ProcessSeries>>(),
Substitute.For<IEventHub>(),
ds, Substitute.For<ICacheHelper>(), readingItemService, new FileService(fs),
Substitute.For<IMetadataService>(),
Substitute.For<IWordCountAnalyzerService>(),
Substitute.For<IReadingListService>(),
Substitute.For<IExternalMetadataService>());
var scanner = new ScannerService(_unitOfWork, Substitute.For<ILogger<ScannerService>>(),
Substitute.For<IMetadataService>(),
Substitute.For<ICacheService>(), Substitute.For<IEventHub>(), ds,
readingItemService, processSeries, Substitute.For<IWordCountAnalyzerService>());
return scanner;
}
private static (string Publisher, LibraryType Type) SplitPublisherAndLibraryType(string input)
{
// Split the input string based on " - "
var parts = input.Split(" - ", StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 2)
{
throw new ArgumentException("Input must be in the format 'Publisher - LibraryType'");
}
var publisher = parts[0].Trim();
var libraryTypeString = parts[1].Trim();
// Try to parse the right-hand side as a LibraryType enum
if (!Enum.TryParse<LibraryType>(libraryTypeString, out var libraryType))
{
throw new ArgumentException($"'{libraryTypeString}' is not a valid LibraryType");
}
return (publisher, libraryType);
}
private async Task<string> GenerateTestDirectory(string mapPath, Dictionary<string, ComicInfo> comicInfos = null)
{
// Read the map file
var mapContent = await File.ReadAllTextAsync(mapPath);
// Deserialize the JSON content into a list of strings using System.Text.Json
var filePaths = JsonSerializer.Deserialize<List<string>>(mapContent);
// Create a test directory
var testDirectory = Path.Combine(_testDirectory, Path.GetFileNameWithoutExtension(mapPath));
if (Directory.Exists(testDirectory))
{
Directory.Delete(testDirectory, true);
}
Directory.CreateDirectory(testDirectory);
// Generate the files and folders
await Scaffold(testDirectory, filePaths, comicInfos);
_testOutputHelper.WriteLine($"Test Directory Path: {testDirectory}");
return testDirectory;
}
private async Task Scaffold(string testDirectory, List<string> filePaths, Dictionary<string, ComicInfo> comicInfos = null)
{
foreach (var relativePath in filePaths)
{
var fullPath = Path.Combine(testDirectory, relativePath);
var fileDir = Path.GetDirectoryName(fullPath);
// Create the directory if it doesn't exist
if (!Directory.Exists(fileDir))
{
Directory.CreateDirectory(fileDir);
Console.WriteLine($"Created directory: {fileDir}");
}
var ext = Path.GetExtension(fullPath).ToLower();
if (ComicInfoExtensions.Contains(ext) && comicInfos != null && comicInfos.TryGetValue(Path.GetFileName(relativePath), out var info))
{
CreateMinimalCbz(fullPath, info);
}
else
{
// Create an empty file
await File.Create(fullPath).DisposeAsync();
Console.WriteLine($"Created empty file: {fullPath}");
}
}
}
private void CreateMinimalCbz(string filePath, ComicInfo? comicInfo = null)
{
using (var archive = ZipFile.Open(filePath, ZipArchiveMode.Create))
{
// Add the 1x1 image to the archive
archive.CreateEntryFromFile(_imagePath, "1x1.png");
if (comicInfo != null)
{
// Serialize ComicInfo object to XML
var comicInfoXml = SerializeComicInfoToXml(comicInfo);
// Create an entry for ComicInfo.xml in the archive
var entry = archive.CreateEntry("ComicInfo.xml");
using var entryStream = entry.Open();
using var writer = new StreamWriter(entryStream, Encoding.UTF8);
// Write the XML to the archive
writer.Write(comicInfoXml);
}
}
Console.WriteLine($"Created minimal CBZ archive: {filePath} with{(comicInfo != null ? "" : "out")} metadata.");
}
private static string SerializeComicInfoToXml(ComicInfo comicInfo)
{
var xmlSerializer = new XmlSerializer(typeof(ComicInfo));
using var stringWriter = new StringWriter();
using (var xmlWriter = XmlWriter.Create(stringWriter, new XmlWriterSettings { Indent = true, Encoding = new UTF8Encoding(false), OmitXmlDeclaration = false}))
{
xmlSerializer.Serialize(xmlWriter, comicInfo);
}
// For the love of god, I spent 2 hours trying to get utf-8 with no BOM
return stringWriter.ToString().Replace("""<?xml version="1.0" encoding="utf-16"?>""",
@"<?xml version='1.0' encoding='utf-8'?>");
}
#endregion
}

View File

@ -70,7 +70,7 @@
<PackageReference Include="Hangfire.InMemory" Version="1.0.0" />
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
<PackageReference Include="Hangfire.Storage.SQLite" Version="0.4.2" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.69" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.70" />
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.15" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" />
@ -82,8 +82,8 @@
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
<PackageReference Include="MimeTypeMapOfficial" Version="1.0.17" />
<PackageReference Include="Nager.ArticleNumber" Version="1.0.7" />
<PackageReference Include="NetVips" Version="2.4.2" />
<PackageReference Include="NetVips.Native" Version="8.15.3" />
<PackageReference Include="NetVips" Version="3.0.0" />
<PackageReference Include="NetVips.Native" Version="8.16.0" />
<PackageReference Include="NReco.Logging.File" Version="1.2.1" />
<PackageReference Include="Serilog" Version="4.1.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
@ -102,8 +102,8 @@
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.9.0" />
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.2" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.1.2" />
<PackageReference Include="System.IO.Abstractions" Version="21.0.29" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.2.0" />
<PackageReference Include="System.IO.Abstractions" Version="21.1.3" />
<PackageReference Include="System.Drawing.Common" Version="8.0.10" />
<PackageReference Include="VersOne.Epub" Version="3.3.2" />
<PackageReference Include="YamlDotNet" Version="16.1.3" />

View File

@ -1,4 +1,13 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
inject,
Input,
OnChanges,
OnInit,
SimpleChanges
} from '@angular/core';
import {AgeRating} from "../../_models/metadata/age-rating";
import {ImageComponent} from "../../shared/image/image.component";
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
@ -23,7 +32,7 @@ const basePath = './assets/images/ratings/';
styleUrl: './age-rating-image.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AgeRatingImageComponent implements OnInit {
export class AgeRatingImageComponent implements OnInit, OnChanges {
private readonly cdRef = inject(ChangeDetectorRef);
private readonly filterUtilityService = inject(FilterUtilitiesService);
@ -34,6 +43,14 @@ export class AgeRatingImageComponent implements OnInit {
imageUrl: string = 'unknown-rating.png';
ngOnInit() {
this.setImage();
}
ngOnChanges() {
this.setImage();
}
setImage() {
switch (this.rating) {
case AgeRating.Unknown:
this.imageUrl = basePath + 'unknown-rating.png';

View File

@ -1,6 +1,12 @@
<ng-container *transloco="let t; read: 'manage-email-settings'">
<div class="position-relative">
<button type="button" class="btn btn-primary-outline position-absolute custom-position" (click)="test()">{{t('test')}}</button>
</div>
<p>{{t('description')}}</p>
<form [formGroup]="settingsForm">
<p class="alert alert-warning">{{t('setting-description')}} {{t('test-warning')}}</p>
@ -86,7 +92,7 @@
@if(settingsForm.get('enableSsl'); as formControl) {
<app-setting-switch [title]="t('enable-ssl-label')">
<ng-template #switch>
<div class="form-check form-switch">
<div class="form-check form-switch float-end">
<input id="setting-enable-ssl" type="checkbox" class="form-check-input" formControlName="enableSsl">
</div>
</ng-template>
@ -137,18 +143,13 @@
@if(settingsForm.get('customizedTemplates'); as formControl) {
<app-setting-switch [title]="t('customized-templates-label')" [subtitle]="t('customized-templates-tooltip')">
<ng-template #switch>
<div class="form-check form-switch">
<div class="form-check form-switch float-end">
<input id="settings-customized-templates" type="checkbox" class="form-check-input" formControlName="customizedTemplates">
</div>
</ng-template>
</app-setting-switch>
}
</div>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mt-4">
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="test()">{{t('test')}}</button>
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetToDefaults()">{{t('reset-to-default')}}</button>
</div>
</form>
</ng-container>

View File

@ -0,0 +1,4 @@
.custom-position {
right: 15px;
top: -42px;
}

View File

@ -1,14 +1,14 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core';
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
import {ToastrService} from 'ngx-toastr';
import {debounceTime, distinctUntilChanged, filter, map, switchMap, take, tap} from 'rxjs';
import {debounceTime, distinctUntilChanged, filter, switchMap, take, tap} from 'rxjs';
import {SettingsService} from '../settings.service';
import {ServerSettings} from '../_models/server-settings';
import {
NgbAlert,
NgbTooltip
} from '@ng-bootstrap/ng-bootstrap';
import {AsyncPipe, NgIf, NgTemplateOutlet, TitleCasePipe} from '@angular/common';
import {AsyncPipe, NgTemplateOutlet, TitleCasePipe} from '@angular/common';
import {translate, TranslocoModule} from "@jsverse/transloco";
import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe";
import {ManageMediaIssuesComponent} from "../manage-media-issues/manage-media-issues.component";
@ -17,6 +17,7 @@ import {SettingSwitchComponent} from "../../settings/_components/setting-switch/
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
import {BytesPipe} from "../../_pipes/bytes.pipe";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
@Component({
selector: 'app-manage-email-settings',
@ -24,8 +25,8 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
styleUrls: ['./manage-email-settings.component.scss'],
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgIf, ReactiveFormsModule, NgbTooltip, NgTemplateOutlet, TranslocoModule, SafeHtmlPipe,
ManageMediaIssuesComponent, TitleCasePipe, NgbAlert, SettingItemComponent, SettingSwitchComponent, DefaultValuePipe, BytesPipe, AsyncPipe]
imports: [ReactiveFormsModule, NgbTooltip, NgTemplateOutlet, TranslocoModule, SafeHtmlPipe,
ManageMediaIssuesComponent, TitleCasePipe, NgbAlert, SettingItemComponent, SettingSwitchComponent, DefaultValuePipe, BytesPipe, AsyncPipe, CardActionablesComponent]
})
export class ManageEmailSettingsComponent implements OnInit {
@ -125,27 +126,6 @@ export class ManageEmailSettingsComponent implements OnInit {
return modelSettings;
}
async saveSettings() {
const modelSettings = this.packData();
this.settingsService.updateServerSettings(modelSettings).pipe(take(1)).subscribe((settings: ServerSettings) => {
this.serverSettings = settings;
this.resetForm();
this.toastr.success(translate('toasts.server-settings-updated'));
}, (err: any) => {
console.error('error: ', err);
});
}
resetToDefaults() {
this.settingsService.resetServerSettings().pipe(take(1)).subscribe((settings: ServerSettings) => {
this.serverSettings = settings;
this.resetForm();
this.toastr.success(translate('toasts.server-settings-updated'));
}, (err: any) => {
console.error('error: ', err);
});
}
test() {
this.settingsService.testEmailServerSettings().subscribe(res => {

View File

@ -1,11 +1,4 @@
<ng-container *transloco="let t; read: 'manage-media-settings'">
<div class="position-relative">
<button class="btn btn-secondary-outline position-absolute custom-position" (click)="resetToDefaults()" [title]="t('reset-to-default')">
<span class="phone-hidden ms-1">{{t('reset-to-default')}}</span>
</button>
</div>
<form [formGroup]="settingsForm">
<div class="mb-4">
<p>

View File

@ -1,4 +0,0 @@
.custom-position {
right: 5px;
top: -42px;
}

View File

@ -1,4 +1,10 @@
<ng-container *transloco="let t; read: 'manage-settings'">
<div class="position-relative">
<button type="button" class="btn btn-primary-outline position-absolute custom-position" (click)="resetToDefaults()">{{t('reset-to-default')}}</button>
</div>
<form [formGroup]="settingsForm">
<div class="alert alert-warning" role="alert">
<strong>{{t('notice')}}</strong> {{t('restart-required')}}
@ -217,10 +223,8 @@
@if(settingsForm.get('enableOpds'); as formControl) {
<app-setting-switch [title]="t('opds-label')" [subtitle]="t('opds-tooltip')">
<ng-template #switch>
<div class="form-check form-switch">
<div class="form-check form-switch">
<input id="opds" type="checkbox" [attr.aria-label]="t('opds-label')" class="form-check-input" formControlName="enableOpds">
</div>
<div class="form-check form-switch float-end">
<input id="opds" type="checkbox" [attr.aria-label]="t('opds-label')" class="form-check-input" formControlName="enableOpds">
</div>
</ng-template>
</app-setting-switch>
@ -231,10 +235,8 @@
@if(settingsForm.get('enableFolderWatching'); as formControl) {
<app-setting-switch [title]="t('folder-watching-label')" [subtitle]="t('folder-watching-tooltip')">
<ng-template #switch>
<div class="form-check form-switch">
<div class="form-check form-switch">
<input id="folder-watching" type="checkbox" class="form-check-input" formControlName="enableFolderWatching" role="switch">
</div>
<div class="form-check form-switch float-end">
<input id="folder-watching" type="checkbox" class="form-check-input" formControlName="enableFolderWatching" role="switch">
</div>
</ng-template>
</app-setting-switch>
@ -245,10 +247,8 @@
@if(settingsForm.get('allowStatCollection'); as formControl) {
<app-setting-switch [title]="t('allow-stats-label')" [subtitle]="allowStatsTooltip">
<ng-template #switch>
<div class="form-check form-switch">
<div class="form-check form-switch">
<input id="stat-collection" type="checkbox" class="form-check-input" formControlName="allowStatCollection" role="switch">
</div>
<div class="form-check form-switch float-end">
<input id="stat-collection" type="checkbox" class="form-check-input" formControlName="allowStatCollection" role="switch">
</div>
</ng-template>
</app-setting-switch>
@ -315,10 +315,6 @@
}
</div>
</ng-container>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mt-4">
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetToDefaults()">{{t('reset-to-default')}}</button>
</div>
</form>
</ng-container>

View File

@ -1,3 +1,7 @@
.invalid-feedback {
display: inherit;
}
.custom-position {
right: 5px;
top: -42px;
}

View File

@ -130,7 +130,7 @@
<div class="row g-0 mt-4 mb-4">
<app-setting-switch [title]="t('manage-collection-label')" [subtitle]="t('manage-collection-tooltip')">
<ng-template #switch>
<div class="form-check form-switch">
<div class="form-check form-switch float-end">
<input type="checkbox" id="manage-collections" role="switch" formControlName="manageCollections" class="form-check-input">
</div>
</ng-template>
@ -140,7 +140,7 @@
<div class="row g-0 mt-4 mb-4">
<app-setting-switch [title]="t('manage-reading-list-label')" [subtitle]="t('manage-reading-list-tooltip')">
<ng-template #switch>
<div class="form-check form-switch">
<div class="form-check form-switch float-end">
<input type="checkbox" id="manage-readinglists" role="switch" formControlName="manageReadingLists" class="form-check-input">
</div>
</ng-template>
@ -150,7 +150,7 @@
<div class="row g-0 mt-4 mb-4">
<app-setting-switch [title]="t('allow-scrobbling-label')" [subtitle]="t('allow-scrobbling-tooltip')">
<ng-template #switch>
<div class="form-check form-switch">
<div class="form-check form-switch float-end">
<input type="checkbox" id="scrobbling" role="switch" formControlName="allowScrobbling" class="form-check-input">
</div>
</ng-template>
@ -160,7 +160,7 @@
<div class="row g-0 mt-4 mb-4">
<app-setting-switch [title]="t('folder-watching-label')" [subtitle]="t('folder-watching-tooltip')">
<ng-template #switch>
<div class="form-check form-switch">
<div class="form-check form-switch float-end">
<input type="checkbox" id="lib-folder-watching" role="switch" formControlName="folderWatching" class="form-check-input">
</div>
</ng-template>
@ -170,7 +170,7 @@
<div class="row g-0 mt-4 mb-4">
<app-setting-switch [title]="t('include-in-dashboard-label')" [subtitle]="t('include-in-dashboard-tooltip')">
<ng-template #switch>
<div class="form-check form-switch">
<div class="form-check form-switch float-end">
<input type="checkbox" id="include-dashboard" role="switch" formControlName="includeInDashboard" class="form-check-input">
</div>
</ng-template>
@ -181,7 +181,7 @@
<div class="row g-0 mt-4 mb-4">
<app-setting-switch [title]="t('include-in-search-label')" [subtitle]="t('include-in-search-tooltip')">
<ng-template #switch>
<div class="form-check form-switch">
<div class="form-check form-switch float-end">
<input type="checkbox" id="include-search" role="switch" formControlName="includeInSearch" class="form-check-input">
</div>
</ng-template>

View File

@ -2,7 +2,7 @@
<app-setting-item [title]="title" [showEdit]="false" [canEdit]="false" [subtitle]="tooltipText" [toggleOnViewClick]="false">
<ng-template #view>
<input #apiKey [type]="InputType" readonly class="d-inline-flex form-control" style="width: 80%" id="api-key--{{title}}" aria-describedby="button-addon4" [value]="key" (click)="selectAll()">
<input #apiKey [type]="InputType" readonly class="d-inline-flex form-control" style="width: 80%" id="api-key--{{title}}" aria-describedby="button-addon4" [value]="key" (click)="selectAll()">
<div class="d-inline-flex">
@if (hideData) {
@ -18,7 +18,7 @@
</ng-template>
@if (showRefresh) {
<ng-template #titleActions>
<button class="btn btn-danger-text" [ngbTooltip]="tipContent" (click)="refresh()">Reset</button>
<button class="btn btn-danger-outline" [ngbTooltip]="tipContent" (click)="refresh()">Reset</button>
</ng-template>
}
</app-setting-item>

View File

@ -2,10 +2,7 @@
<div class="position-relative">
<button class="btn btn-primary-outline position-absolute custom-position" (click)="addDevice()">
<i class="fa fa-plus" aria-hidden="true"></i>
<span class="phone-hidden ms-1">
{{t('add')}}
</span>
<i class="fa fa-plus" aria-hidden="true"></i><span class="phone-hidden ms-1">{{t('add')}}</span>
</button>
</div>

View File

@ -26,7 +26,7 @@
<div class="row g-0 mt-4 mb-4">
<app-setting-switch [title]="t('blur-unread-summaries-label')" [subtitle]="t('blur-unread-summaries-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end float-end">
<div class="form-check form-switch float-end">
<input type="checkbox" role="switch"
formControlName="blurUnreadSummaries" class="form-check-input"
aria-labelledby="auto-close-label">

View File

@ -139,6 +139,7 @@ export class ManageUserPreferencesComponent implements OnInit {
this.localizationService.getLocales().subscribe(res => {
this.locales = res;
this.cdRef.markForCheck();
});
}
@ -197,9 +198,6 @@ export class ManageUserPreferencesComponent implements OnInit {
this.settingsForm.addControl('shareReviews', new FormControl(this.user.preferences.shareReviews, []));
this.settingsForm.addControl('locale', new FormControl(this.user.preferences.locale || 'en', []));
if (this.locales.length === 1) {
this.settingsForm.get('locale')?.disable();
}
// Automatically save settings as we edit them
this.settingsForm.valueChanges.pipe(

View File

@ -4,7 +4,7 @@
<meta charset="utf-8">
<title>Kavita</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="apple-touch-icon" sizes="180x180" href="assets/icons/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="assets/icons/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="assets/icons/favicon-16x16.png">

View File

@ -40,6 +40,18 @@
}
}
.btn-danger-outline {
color: var(--btn-danger-outline-text-color);
background-color: var(--btn-danger-outline-bg-color);
border-color: var(--btn-danger-outline-border-color);
&:hover {
color: var(--btn-danger-outline-hover-text-color);
background-color: var(--btn-danger-outline-hover-bg-color);
border-color: var(--btn-danger-outline-hover-border-color);
}
}
.btn-actions {
color: var(--btn-fa-icon-color);
border-radius: var(--btn-actions-border-radius);

View File

@ -145,7 +145,15 @@
--btn-secondary-outline-font-weight: bold;
--btn-primary-text-text-color: white;
--btn-secondary-text-text-color: lightgrey;
--btn-danger-text-text-color: var(--error-color);
--btn-danger-outline-text-color: white;
--btn-danger-outline-bg-color: transparent;
--btn-danger-outline-border-color: var(--error-color);
--btn-danger-outline-hover-text-color: white;
--btn-danger-outline-hover-bg-color: var(--error-color);
--btn-danger-outline-hover-border-color: var(--error-color);
--btn-alt-bg-color: #424c72;
--btn-alt-border-color: #444f75;
--btn-alt-hover-bg-color: #3b4466;