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> <ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.14.0" /> <PackageReference Include="BenchmarkDotNet" Version="0.14.0" />
<PackageReference Include="BenchmarkDotNet.Annotations" 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>
<ItemGroup> <ItemGroup>

View File

@ -8,9 +8,9 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.10" /> <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="NSubstitute" Version="5.1.0" /> <PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="21.0.29" /> <PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="21.1.3" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="21.0.29" /> <PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="21.1.3" />
<PackageReference Include="xunit" Version="2.9.2" /> <PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"> <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <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 public class ScannerServiceTests : AbstractDbTest
{ {
private readonly ITestOutputHelper _testOutputHelper; private readonly ITestOutputHelper _testOutputHelper;
private readonly ScannerHelper _scannerHelper;
private readonly string _testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/ScanTests"); 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) public ScannerServiceTests(ITestOutputHelper testOutputHelper)
{ {
@ -47,6 +45,7 @@ public class ScannerServiceTests : AbstractDbTest
// Set up Hangfire to use in-memory storage for testing // Set up Hangfire to use in-memory storage for testing
GlobalConfiguration.Configuration.UseInMemoryStorage(); GlobalConfiguration.Configuration.UseInMemoryStorage();
_scannerHelper = new ScannerHelper(_unitOfWork, testOutputHelper);
} }
protected override async Task ResetDb() protected override async Task ResetDb()
@ -59,8 +58,8 @@ public class ScannerServiceTests : AbstractDbTest
public async Task ScanLibrary_ComicVine_PublisherFolder() public async Task ScanLibrary_ComicVine_PublisherFolder()
{ {
var testcase = "Publisher - ComicVine.json"; var testcase = "Publisher - ComicVine.json";
var library = await GenerateScannerData(testcase); var library = await _scannerHelper.GenerateScannerData(testcase);
var scanner = CreateServices(); var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id); await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
@ -72,8 +71,8 @@ public class ScannerServiceTests : AbstractDbTest
public async Task ScanLibrary_ShouldCombineNestedFolder() public async Task ScanLibrary_ShouldCombineNestedFolder()
{ {
var testcase = "Series and Series-Series Combined - Manga.json"; var testcase = "Series and Series-Series Combined - 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); await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
@ -87,8 +86,8 @@ public class ScannerServiceTests : AbstractDbTest
public async Task ScanLibrary_FlatSeries() public async Task ScanLibrary_FlatSeries()
{ {
var testcase = "Flat Series - Manga.json"; var testcase = "Flat Series - 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); await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
@ -103,8 +102,8 @@ public class ScannerServiceTests : AbstractDbTest
public async Task ScanLibrary_FlatSeriesWithSpecialFolder() public async Task ScanLibrary_FlatSeriesWithSpecialFolder()
{ {
var testcase = "Flat Series with Specials Folder - Manga.json"; var testcase = "Flat Series with Specials 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); await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); 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"; const string testcase = "Flat Special - 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); await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); 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"; const string testcase = "Scan Library Parses as ( - 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); await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); 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" 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); await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); 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 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); await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
@ -221,10 +220,10 @@ public class ScannerServiceTests : AbstractDbTest
Series = "The Novel's Extra", 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); await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); 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"; 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); await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); 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"; 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); await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
@ -303,10 +302,10 @@ public class ScannerServiceTests : AbstractDbTest
Publisher = "Chapter Publisher" 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); await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); 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"; 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); await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); 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"; 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); await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); 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" 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); await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); 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 // 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)); 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 // Now that a new file exists in the subdirectory, scan again
await scanner.ScanSeries(series.Id); await scanner.ScanSeries(series.Id);
@ -399,170 +398,4 @@ public class ScannerServiceTests : AbstractDbTest
Assert.Equal(3, series.Volumes.Count); Assert.Equal(3, series.Volumes.Count);
Assert.Equal(2, series.Volumes.First(v => v.MinNumber.Is(Parser.LooseLeafVolumeNumber)).Chapters.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.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.69" /> <PackageReference Include="HtmlAgilityPack" Version="1.11.70" />
<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.15" /> <PackageReference Include="Hangfire.AspNetCore" Version="1.8.15" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" /> <PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" />
@ -82,8 +82,8 @@
<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="2.4.2" /> <PackageReference Include="NetVips" Version="3.0.0" />
<PackageReference Include="NetVips.Native" Version="8.15.3" /> <PackageReference Include="NetVips.Native" Version="8.16.0" />
<PackageReference Include="NReco.Logging.File" Version="1.2.1" /> <PackageReference Include="NReco.Logging.File" Version="1.2.1" />
<PackageReference Include="Serilog" Version="4.1.0" /> <PackageReference Include="Serilog" Version="4.1.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" /> <PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
@ -102,8 +102,8 @@
</PackageReference> </PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.9.0" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.9.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.1.2" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.2.0" />
<PackageReference Include="System.IO.Abstractions" Version="21.0.29" /> <PackageReference Include="System.IO.Abstractions" Version="21.1.3" />
<PackageReference Include="System.Drawing.Common" Version="8.0.10" /> <PackageReference Include="System.Drawing.Common" Version="8.0.10" />
<PackageReference Include="VersOne.Epub" Version="3.3.2" /> <PackageReference Include="VersOne.Epub" Version="3.3.2" />
<PackageReference Include="YamlDotNet" Version="16.1.3" /> <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 {AgeRating} from "../../_models/metadata/age-rating";
import {ImageComponent} from "../../shared/image/image.component"; import {ImageComponent} from "../../shared/image/image.component";
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
@ -23,7 +32,7 @@ const basePath = './assets/images/ratings/';
styleUrl: './age-rating-image.component.scss', styleUrl: './age-rating-image.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class AgeRatingImageComponent implements OnInit { export class AgeRatingImageComponent implements OnInit, OnChanges {
private readonly cdRef = inject(ChangeDetectorRef); private readonly cdRef = inject(ChangeDetectorRef);
private readonly filterUtilityService = inject(FilterUtilitiesService); private readonly filterUtilityService = inject(FilterUtilitiesService);
@ -34,6 +43,14 @@ export class AgeRatingImageComponent implements OnInit {
imageUrl: string = 'unknown-rating.png'; imageUrl: string = 'unknown-rating.png';
ngOnInit() { ngOnInit() {
this.setImage();
}
ngOnChanges() {
this.setImage();
}
setImage() {
switch (this.rating) { switch (this.rating) {
case AgeRating.Unknown: case AgeRating.Unknown:
this.imageUrl = basePath + 'unknown-rating.png'; this.imageUrl = basePath + 'unknown-rating.png';

View File

@ -1,6 +1,12 @@
<ng-container *transloco="let t; read: 'manage-email-settings'"> <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> <p>{{t('description')}}</p>
<form [formGroup]="settingsForm"> <form [formGroup]="settingsForm">
<p class="alert alert-warning">{{t('setting-description')}} {{t('test-warning')}}</p> <p class="alert alert-warning">{{t('setting-description')}} {{t('test-warning')}}</p>
@ -86,7 +92,7 @@
@if(settingsForm.get('enableSsl'); as formControl) { @if(settingsForm.get('enableSsl'); as formControl) {
<app-setting-switch [title]="t('enable-ssl-label')"> <app-setting-switch [title]="t('enable-ssl-label')">
<ng-template #switch> <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"> <input id="setting-enable-ssl" type="checkbox" class="form-check-input" formControlName="enableSsl">
</div> </div>
</ng-template> </ng-template>
@ -137,18 +143,13 @@
@if(settingsForm.get('customizedTemplates'); as formControl) { @if(settingsForm.get('customizedTemplates'); as formControl) {
<app-setting-switch [title]="t('customized-templates-label')" [subtitle]="t('customized-templates-tooltip')"> <app-setting-switch [title]="t('customized-templates-label')" [subtitle]="t('customized-templates-tooltip')">
<ng-template #switch> <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"> <input id="settings-customized-templates" type="checkbox" class="form-check-input" formControlName="customizedTemplates">
</div> </div>
</ng-template> </ng-template>
</app-setting-switch> </app-setting-switch>
} }
</div> </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> </form>
</ng-container> </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 {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core';
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms'; import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
import {ToastrService} from 'ngx-toastr'; 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 {SettingsService} from '../settings.service';
import {ServerSettings} from '../_models/server-settings'; import {ServerSettings} from '../_models/server-settings';
import { import {
NgbAlert, NgbAlert,
NgbTooltip NgbTooltip
} from '@ng-bootstrap/ng-bootstrap'; } 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 {translate, TranslocoModule} from "@jsverse/transloco";
import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe"; import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe";
import {ManageMediaIssuesComponent} from "../manage-media-issues/manage-media-issues.component"; 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 {DefaultValuePipe} from "../../_pipes/default-value.pipe";
import {BytesPipe} from "../../_pipes/bytes.pipe"; import {BytesPipe} from "../../_pipes/bytes.pipe";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
@Component({ @Component({
selector: 'app-manage-email-settings', selector: 'app-manage-email-settings',
@ -24,8 +25,8 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
styleUrls: ['./manage-email-settings.component.scss'], styleUrls: ['./manage-email-settings.component.scss'],
standalone: true, standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgIf, ReactiveFormsModule, NgbTooltip, NgTemplateOutlet, TranslocoModule, SafeHtmlPipe, imports: [ReactiveFormsModule, NgbTooltip, NgTemplateOutlet, TranslocoModule, SafeHtmlPipe,
ManageMediaIssuesComponent, TitleCasePipe, NgbAlert, SettingItemComponent, SettingSwitchComponent, DefaultValuePipe, BytesPipe, AsyncPipe] ManageMediaIssuesComponent, TitleCasePipe, NgbAlert, SettingItemComponent, SettingSwitchComponent, DefaultValuePipe, BytesPipe, AsyncPipe, CardActionablesComponent]
}) })
export class ManageEmailSettingsComponent implements OnInit { export class ManageEmailSettingsComponent implements OnInit {
@ -125,27 +126,6 @@ export class ManageEmailSettingsComponent implements OnInit {
return modelSettings; 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() { test() {
this.settingsService.testEmailServerSettings().subscribe(res => { this.settingsService.testEmailServerSettings().subscribe(res => {

View File

@ -1,11 +1,4 @@
<ng-container *transloco="let t; read: 'manage-media-settings'"> <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"> <form [formGroup]="settingsForm">
<div class="mb-4"> <div class="mb-4">
<p> <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'"> <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"> <form [formGroup]="settingsForm">
<div class="alert alert-warning" role="alert"> <div class="alert alert-warning" role="alert">
<strong>{{t('notice')}}</strong> {{t('restart-required')}} <strong>{{t('notice')}}</strong> {{t('restart-required')}}
@ -217,10 +223,8 @@
@if(settingsForm.get('enableOpds'); as formControl) { @if(settingsForm.get('enableOpds'); as formControl) {
<app-setting-switch [title]="t('opds-label')" [subtitle]="t('opds-tooltip')"> <app-setting-switch [title]="t('opds-label')" [subtitle]="t('opds-tooltip')">
<ng-template #switch> <ng-template #switch>
<div class="form-check form-switch"> <div class="form-check form-switch float-end">
<div class="form-check form-switch"> <input id="opds" type="checkbox" [attr.aria-label]="t('opds-label')" class="form-check-input" formControlName="enableOpds">
<input id="opds" type="checkbox" [attr.aria-label]="t('opds-label')" class="form-check-input" formControlName="enableOpds">
</div>
</div> </div>
</ng-template> </ng-template>
</app-setting-switch> </app-setting-switch>
@ -231,10 +235,8 @@
@if(settingsForm.get('enableFolderWatching'); as formControl) { @if(settingsForm.get('enableFolderWatching'); as formControl) {
<app-setting-switch [title]="t('folder-watching-label')" [subtitle]="t('folder-watching-tooltip')"> <app-setting-switch [title]="t('folder-watching-label')" [subtitle]="t('folder-watching-tooltip')">
<ng-template #switch> <ng-template #switch>
<div class="form-check form-switch"> <div class="form-check form-switch float-end">
<div class="form-check form-switch"> <input id="folder-watching" type="checkbox" class="form-check-input" formControlName="enableFolderWatching" role="switch">
<input id="folder-watching" type="checkbox" class="form-check-input" formControlName="enableFolderWatching" role="switch">
</div>
</div> </div>
</ng-template> </ng-template>
</app-setting-switch> </app-setting-switch>
@ -245,10 +247,8 @@
@if(settingsForm.get('allowStatCollection'); as formControl) { @if(settingsForm.get('allowStatCollection'); as formControl) {
<app-setting-switch [title]="t('allow-stats-label')" [subtitle]="allowStatsTooltip"> <app-setting-switch [title]="t('allow-stats-label')" [subtitle]="allowStatsTooltip">
<ng-template #switch> <ng-template #switch>
<div class="form-check form-switch"> <div class="form-check form-switch float-end">
<div class="form-check form-switch"> <input id="stat-collection" type="checkbox" class="form-check-input" formControlName="allowStatCollection" role="switch">
<input id="stat-collection" type="checkbox" class="form-check-input" formControlName="allowStatCollection" role="switch">
</div>
</div> </div>
</ng-template> </ng-template>
</app-setting-switch> </app-setting-switch>
@ -315,10 +315,6 @@
} }
</div> </div>
</ng-container> </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> </form>
</ng-container> </ng-container>

View File

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

View File

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

View File

@ -2,7 +2,7 @@
<app-setting-item [title]="title" [showEdit]="false" [canEdit]="false" [subtitle]="tooltipText" [toggleOnViewClick]="false"> <app-setting-item [title]="title" [showEdit]="false" [canEdit]="false" [subtitle]="tooltipText" [toggleOnViewClick]="false">
<ng-template #view> <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"> <div class="d-inline-flex">
@if (hideData) { @if (hideData) {
@ -18,7 +18,7 @@
</ng-template> </ng-template>
@if (showRefresh) { @if (showRefresh) {
<ng-template #titleActions> <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> </ng-template>
} }
</app-setting-item> </app-setting-item>

View File

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

View File

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

View File

@ -139,6 +139,7 @@ export class ManageUserPreferencesComponent implements OnInit {
this.localizationService.getLocales().subscribe(res => { this.localizationService.getLocales().subscribe(res => {
this.locales = res; this.locales = res;
this.cdRef.markForCheck(); 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('shareReviews', new FormControl(this.user.preferences.shareReviews, []));
this.settingsForm.addControl('locale', new FormControl(this.user.preferences.locale || 'en', [])); 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 // Automatically save settings as we edit them
this.settingsForm.valueChanges.pipe( this.settingsForm.valueChanges.pipe(

View File

@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<title>Kavita</title> <title>Kavita</title>
<base href="/"> <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="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="32x32" href="assets/icons/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="assets/icons/favicon-16x16.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 { .btn-actions {
color: var(--btn-fa-icon-color); color: var(--btn-fa-icon-color);
border-radius: var(--btn-actions-border-radius); border-radius: var(--btn-actions-border-radius);

View File

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