Compare commits
9 commits
develop
...
feature/ko
Author | SHA1 | Date | |
---|---|---|---|
![]() |
3b61fd2536 | ||
![]() |
231db28a5e | ||
![]() |
9893c9f473 | ||
![]() |
4073ffef66 | ||
![]() |
87b0d6a769 | ||
![]() |
87b1a807ea | ||
![]() |
180b49b8ea | ||
![]() |
1a88dd4fc0 | ||
![]() |
edbd44bbee |
81 changed files with 7625 additions and 8969 deletions
|
@ -26,5 +26,10 @@
|
|||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="Data\AesopsFables.epub">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
BIN
API.Benchmark/Data/AesopsFables.epub
Normal file
BIN
API.Benchmark/Data/AesopsFables.epub
Normal file
Binary file not shown.
44
API.Benchmark/KoreaderHashBenchmark.cs
Normal file
44
API.Benchmark/KoreaderHashBenchmark.cs
Normal file
|
@ -0,0 +1,44 @@
|
|||
using API.Helpers.Builders;
|
||||
using BenchmarkDotNet.Attributes;
|
||||
using BenchmarkDotNet.Order;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.Benchmark
|
||||
{
|
||||
[StopOnFirstError]
|
||||
[MemoryDiagnoser]
|
||||
[RankColumn]
|
||||
[Orderer(SummaryOrderPolicy.FastestToSlowest)]
|
||||
[SimpleJob(launchCount: 1, warmupCount: 5, invocationCount: 20)]
|
||||
public class KoreaderHashBenchmark
|
||||
{
|
||||
private const string sourceEpub = "./Data/AesopsFables.epub";
|
||||
|
||||
[Benchmark(Baseline = true)]
|
||||
public void TestBuildManga_baseline()
|
||||
{
|
||||
var file = new MangaFileBuilder(sourceEpub, MangaFormat.Epub)
|
||||
.Build();
|
||||
if (file == null)
|
||||
{
|
||||
throw new Exception("Failed to build manga file");
|
||||
}
|
||||
}
|
||||
|
||||
[Benchmark]
|
||||
public void TestBuildManga_withHash()
|
||||
{
|
||||
var file = new MangaFileBuilder(sourceEpub, MangaFormat.Epub)
|
||||
.WithHash()
|
||||
.Build();
|
||||
if (file == null)
|
||||
{
|
||||
throw new Exception("Failed to build manga file");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -6,12 +6,12 @@
|
|||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.8" />
|
||||
<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="xunit" Version="2.9.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
|
@ -35,4 +35,10 @@
|
|||
<None Remove="Extensions\Test Data\modified on run.txt" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="Data\AesopsFables.epub">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
BIN
API.Tests/Data/AesopsFables.epub
Normal file
BIN
API.Tests/Data/AesopsFables.epub
Normal file
Binary file not shown.
60
API.Tests/Helpers/KoreaderHelperTests.cs
Normal file
60
API.Tests/Helpers/KoreaderHelperTests.cs
Normal file
|
@ -0,0 +1,60 @@
|
|||
using API.DTOs.Koreader;
|
||||
using API.DTOs.Progress;
|
||||
using API.Helpers;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Xunit;
|
||||
|
||||
namespace API.Tests.Helpers;
|
||||
|
||||
|
||||
public class KoreaderHelperTests
|
||||
{
|
||||
|
||||
[Theory]
|
||||
[InlineData("/body/DocFragment[11]/body/div/a", 10, null)]
|
||||
[InlineData("/body/DocFragment[1]/body/div/p[40]", 0, 40)]
|
||||
[InlineData("/body/DocFragment[8]/body/div/p[28]/text().264", 7, 28)]
|
||||
public void GetEpubPositionDto(string koreaderPosition, int page, int? pNumber)
|
||||
{
|
||||
var expected = EmptyProgressDto();
|
||||
expected.BookScrollId = pNumber.HasValue ? $"//html[1]/BODY/APP-ROOT[1]/DIV[1]/DIV[1]/DIV[1]/APP-BOOK-READER[1]/DIV[1]/DIV[2]/DIV[1]/DIV[1]/DIV[1]/P[{pNumber}]" : null;
|
||||
expected.PageNum = page;
|
||||
var actual = EmptyProgressDto();
|
||||
|
||||
KoreaderHelper.UpdateProgressDto(actual, koreaderPosition);
|
||||
Assert.Equal(expected.BookScrollId, actual.BookScrollId);
|
||||
Assert.Equal(expected.PageNum, actual.PageNum);
|
||||
}
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData("//html[1]/BODY/APP-ROOT[1]/DIV[1]/DIV[1]/DIV[1]/APP-BOOK-READER[1]/DIV[1]/DIV[2]/DIV[1]/DIV[1]/DIV[1]/P[20]", 5, "/body/DocFragment[6]/body/div/p[20]")]
|
||||
[InlineData(null, 10, "/body/DocFragment[11]/body/div/a")]
|
||||
public void GetKoreaderPosition(string scrollId, int page, string koreaderPosition)
|
||||
{
|
||||
var given = EmptyProgressDto();
|
||||
given.BookScrollId = scrollId;
|
||||
given.PageNum = page;
|
||||
|
||||
Assert.Equal(koreaderPosition, KoreaderHelper.GetKoreaderPosition(given));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("./Data/AesopsFables.epub", "8795ACA4BF264B57C1EEDF06A0CEE688")]
|
||||
public void GetKoreaderHash(string filePath, string hash)
|
||||
{
|
||||
Assert.Equal(KoreaderHelper.HashContents(filePath), hash);
|
||||
}
|
||||
|
||||
private ProgressDto EmptyProgressDto()
|
||||
{
|
||||
return new ProgressDto
|
||||
{
|
||||
ChapterId = 0,
|
||||
PageNum = 0,
|
||||
VolumeId = 0,
|
||||
SeriesId = 0,
|
||||
LibraryId = 0
|
||||
};
|
||||
}
|
||||
}
|
|
@ -7,7 +7,10 @@ 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.Data.Repositories;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
|
@ -35,6 +38,7 @@ public class ScannerServiceTests : AbstractDbTest
|
|||
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)
|
||||
{
|
||||
|
@ -125,9 +129,61 @@ public class ScannerServiceTests : AbstractDbTest
|
|||
Assert.NotNull(postLib.Series.First().Volumes.FirstOrDefault(v => v.Chapters.FirstOrDefault(c => c.IsSpecial) != null));
|
||||
}
|
||||
|
||||
private async Task<Library> GenerateScannerData(string testcase)
|
||||
/// <summary>
|
||||
/// This is testing that if the first file is named A and has a localized name of B if all other files are named B, it should still group and name the series A
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ScanLibrary_LocalizedSeries()
|
||||
{
|
||||
var testDirectoryPath = await GenerateTestDirectory(Path.Join(_testcasesDirectory, testcase));
|
||||
const string testcase = "Series with Localized - Manga.json";
|
||||
|
||||
// Get the first file and generate a ComicInfo
|
||||
var infos = new Dictionary<string, ComicInfo>();
|
||||
infos.Add("My Dress-Up Darling v01.cbz", new ComicInfo()
|
||||
{
|
||||
Series = "My Dress-Up Darling",
|
||||
LocalizedSeries = "Sono Bisque Doll wa Koi wo Suru"
|
||||
});
|
||||
|
||||
var library = await GenerateScannerData(testcase, infos);
|
||||
|
||||
|
||||
var scanner = CreateServices();
|
||||
await scanner.ScanLibrary(library.Id);
|
||||
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
|
||||
|
||||
Assert.NotNull(postLib);
|
||||
Assert.Single(postLib.Series);
|
||||
Assert.Equal(3, postLib.Series.First().Volumes.Count);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Files under a folder with a SP marker should group into one issue
|
||||
/// </summary>
|
||||
/// <remarks>https://github.com/Kareadita/Kavita/issues/3299</remarks>
|
||||
[Fact]
|
||||
public async Task ScanLibrary_ImageSeries_SpecialGrouping()
|
||||
{
|
||||
const string testcase = "Image Series with SP Folder - Manga.json";
|
||||
|
||||
var library = await GenerateScannerData(testcase);
|
||||
|
||||
|
||||
var scanner = CreateServices();
|
||||
await scanner.ScanLibrary(library.Id);
|
||||
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
|
||||
|
||||
Assert.NotNull(postLib);
|
||||
Assert.Single(postLib.Series);
|
||||
Assert.Equal(3, postLib.Series.First().Volumes.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));
|
||||
|
||||
|
@ -148,11 +204,17 @@ public class ScannerServiceTests : AbstractDbTest
|
|||
|
||||
private ScannerService CreateServices()
|
||||
{
|
||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), new FileSystem());
|
||||
var mockReadingService = new MockReadingItemService(ds, Substitute.For<IBookService>());
|
||||
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>(), mockReadingService, Substitute.For<IFileService>(),
|
||||
ds, Substitute.For<ICacheHelper>(), readingItemService, new FileService(fs),
|
||||
Substitute.For<IMetadataService>(),
|
||||
Substitute.For<IWordCountAnalyzerService>(),
|
||||
Substitute.For<IReadingListService>(),
|
||||
|
@ -161,7 +223,7 @@ public class ScannerServiceTests : AbstractDbTest
|
|||
var scanner = new ScannerService(_unitOfWork, Substitute.For<ILogger<ScannerService>>(),
|
||||
Substitute.For<IMetadataService>(),
|
||||
Substitute.For<ICacheService>(), Substitute.For<IEventHub>(), ds,
|
||||
mockReadingService, processSeries, Substitute.For<IWordCountAnalyzerService>());
|
||||
readingItemService, processSeries, Substitute.For<IWordCountAnalyzerService>());
|
||||
return scanner;
|
||||
}
|
||||
|
||||
|
@ -189,7 +251,7 @@ public class ScannerServiceTests : AbstractDbTest
|
|||
|
||||
|
||||
|
||||
private async Task<string> GenerateTestDirectory(string mapPath)
|
||||
private async Task<string> GenerateTestDirectory(string mapPath, Dictionary<string, ComicInfo> comicInfos = null)
|
||||
{
|
||||
// Read the map file
|
||||
var mapContent = await File.ReadAllTextAsync(mapPath);
|
||||
|
@ -206,7 +268,7 @@ public class ScannerServiceTests : AbstractDbTest
|
|||
Directory.CreateDirectory(testDirectory);
|
||||
|
||||
// Generate the files and folders
|
||||
await Scaffold(testDirectory, filePaths);
|
||||
await Scaffold(testDirectory, filePaths, comicInfos);
|
||||
|
||||
_testOutputHelper.WriteLine($"Test Directory Path: {testDirectory}");
|
||||
|
||||
|
@ -214,7 +276,7 @@ public class ScannerServiceTests : AbstractDbTest
|
|||
}
|
||||
|
||||
|
||||
private async Task Scaffold(string testDirectory, List<string> filePaths)
|
||||
private async Task Scaffold(string testDirectory, List<string> filePaths, Dictionary<string, ComicInfo> comicInfos = null)
|
||||
{
|
||||
foreach (var relativePath in filePaths)
|
||||
{
|
||||
|
@ -229,9 +291,9 @@ public class ScannerServiceTests : AbstractDbTest
|
|||
}
|
||||
|
||||
var ext = Path.GetExtension(fullPath).ToLower();
|
||||
if (new[] { ".cbz", ".cbr", ".zip", ".rar" }.Contains(ext))
|
||||
if (ComicInfoExtensions.Contains(ext) && comicInfos != null && comicInfos.TryGetValue(Path.GetFileName(relativePath), out var info))
|
||||
{
|
||||
CreateMinimalCbz(fullPath, includeMetadata: true);
|
||||
CreateMinimalCbz(fullPath, info);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
@ -242,54 +304,44 @@ public class ScannerServiceTests : AbstractDbTest
|
|||
}
|
||||
}
|
||||
|
||||
private void CreateMinimalCbz(string filePath, bool includeMetadata)
|
||||
private void CreateMinimalCbz(string filePath, ComicInfo? comicInfo = null)
|
||||
{
|
||||
var tempImagePath = _imagePath; // Assuming _imagePath is a valid path to the 1x1 image
|
||||
|
||||
using (var archive = ZipFile.Open(filePath, ZipArchiveMode.Create))
|
||||
{
|
||||
// Add the 1x1 image to the archive
|
||||
archive.CreateEntryFromFile(tempImagePath, "1x1.png");
|
||||
archive.CreateEntryFromFile(_imagePath, "1x1.png");
|
||||
|
||||
if (includeMetadata)
|
||||
if (comicInfo != null)
|
||||
{
|
||||
var comicInfo = GenerateComicInfo();
|
||||
// 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);
|
||||
writer.Write(comicInfo);
|
||||
|
||||
// Write the XML to the archive
|
||||
writer.Write(comicInfoXml);
|
||||
}
|
||||
|
||||
}
|
||||
Console.WriteLine($"Created minimal CBZ archive: {filePath} with{(includeMetadata ? "" : "out")} metadata.");
|
||||
Console.WriteLine($"Created minimal CBZ archive: {filePath} with{(comicInfo != null ? "" : "out")} metadata.");
|
||||
}
|
||||
|
||||
private string GenerateComicInfo()
|
||||
|
||||
private static string SerializeComicInfoToXml(ComicInfo comicInfo)
|
||||
{
|
||||
var comicInfo = new StringBuilder();
|
||||
comicInfo.AppendLine("<?xml version='1.0' encoding='utf-8'?>");
|
||||
comicInfo.AppendLine("<ComicInfo>");
|
||||
|
||||
// People Tags
|
||||
string[] people = { "Joe Shmo", "Tommy Two Hands"};
|
||||
string[] genres = { /* Your list of genres here */ };
|
||||
|
||||
void AddRandomTag(string tagName, string[] choices)
|
||||
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}))
|
||||
{
|
||||
if (new Random().Next(0, 2) == 1) // 50% chance to include the tag
|
||||
{
|
||||
var selected = choices.OrderBy(x => Guid.NewGuid()).Take(new Random().Next(1, 5)).ToArray();
|
||||
comicInfo.AppendLine($" <{tagName}>{string.Join(", ", selected)}</{tagName}>");
|
||||
}
|
||||
xmlSerializer.Serialize(xmlWriter, comicInfo);
|
||||
}
|
||||
|
||||
foreach (var tag in new[] { "Writer", "Penciller", "Inker", "CoverArtist", "Publisher", "Character", "Imprint", "Colorist", "Letterer", "Editor", "Translator", "Team", "Location" })
|
||||
{
|
||||
AddRandomTag(tag, people);
|
||||
}
|
||||
|
||||
AddRandomTag("Genre", genres);
|
||||
comicInfo.AppendLine("</ComicInfo>");
|
||||
|
||||
return comicInfo.ToString();
|
||||
// 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
|
||||
}
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
[
|
||||
"My Dress-Up Darling/My Dress-Up Darling vol 1/0001.png",
|
||||
"My Dress-Up Darling/My Dress-Up Darling vol 1/0002.png",
|
||||
"My Dress-Up Darling/My Dress-Up Darling vol 2/0001.png",
|
||||
"My Dress-Up Darling/Specials/My Dress-Up Darling SP01/0001.png"
|
||||
]
|
|
@ -0,0 +1,5 @@
|
|||
[
|
||||
"My Dress-Up Darling/My Dress-Up Darling v01.cbz",
|
||||
"My Dress-Up Darling/Sono Bisque Doll wa Koi wo Suru v02.cbz",
|
||||
"My Dress-Up Darling/Sono Bisque Doll wa Koi wo Suru ch 10.cbz"
|
||||
]
|
|
@ -12,10 +12,10 @@
|
|||
<LangVersion>latestmajor</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- <Target Name="PostBuild" AfterTargets="Build" Condition=" '$(Configuration)' == 'Debug' ">-->
|
||||
<!-- <Delete Files="../openapi.json" />-->
|
||||
<!-- <Exec Command="swagger tofile --output ../openapi.json bin/$(Configuration)/$(TargetFramework)/$(AssemblyName).dll v1" />-->
|
||||
<!-- </Target>-->
|
||||
<Target Name="PostBuild" AfterTargets="Build" Condition=" '$(Configuration)' == 'Debug' ">
|
||||
<Delete Files="../openapi.json" />
|
||||
<Exec Command="swagger tofile --output ../openapi.json bin/$(Configuration)/$(TargetFramework)/$(AssemblyName).dll v1" />
|
||||
</Target>
|
||||
|
||||
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
|
||||
<DebugSymbols>false</DebugSymbols>
|
||||
|
@ -55,41 +55,41 @@
|
|||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CsvHelper" Version="33.0.1" />
|
||||
<PackageReference Include="MailKit" Version="4.7.1.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.8">
|
||||
<PackageReference Include="MailKit" Version="4.8.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.10">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
|
||||
<PackageReference Include="Docnet.Core" Version="2.6.0" />
|
||||
<PackageReference Include="EasyCaching.InMemory" Version="1.9.2" />
|
||||
<PackageReference Include="ExCSS" Version="4.2.5" />
|
||||
<PackageReference Include="ExCSS" Version="4.3.0" />
|
||||
<PackageReference Include="Flurl" Version="3.0.7" />
|
||||
<PackageReference Include="Flurl.Http" Version="3.2.4" />
|
||||
<PackageReference Include="Hangfire" Version="1.8.14" />
|
||||
<PackageReference Include="Hangfire" Version="1.8.15" />
|
||||
<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.66" />
|
||||
<PackageReference Include="HtmlAgilityPack" Version="1.11.69" />
|
||||
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
|
||||
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.14" />
|
||||
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.15" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.8" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.8" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.10" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.10" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
||||
<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.1" />
|
||||
<PackageReference Include="NetVips" Version="2.4.2" />
|
||||
<PackageReference Include="NetVips.Native" Version="8.15.3" />
|
||||
<PackageReference Include="NReco.Logging.File" Version="1.2.1" />
|
||||
<PackageReference Include="Serilog" Version="4.0.1" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.2" />
|
||||
<PackageReference Include="Serilog" Version="4.1.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
|
||||
<PackageReference Include="Serilog.Enrichers.Thread" Version="4.0.0" />
|
||||
<PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0" />
|
||||
<PackageReference Include="Serilog.Settings.Configuration" Version="8.0.2" />
|
||||
<PackageReference Include="Serilog.Settings.Configuration" Version="8.0.4" />
|
||||
<PackageReference Include="Serilog.Sinks.AspNetCore.SignalR" Version="0.4.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||
|
@ -100,11 +100,11 @@
|
|||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.7.3" />
|
||||
<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.0.2" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.1.2" />
|
||||
<PackageReference Include="System.IO.Abstractions" Version="21.0.29" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="8.0.8" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="8.0.10" />
|
||||
<PackageReference Include="VersOne.Epub" Version="3.3.2" />
|
||||
</ItemGroup>
|
||||
|
||||
|
|
99
API/Controllers/KoreaderController.cs
Normal file
99
API/Controllers/KoreaderController.cs
Normal file
|
@ -0,0 +1,99 @@
|
|||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs.Koreader;
|
||||
using API.Entities;
|
||||
using API.Services;
|
||||
using Kavita.Common;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static System.Net.WebRequestMethods;
|
||||
|
||||
namespace API.Controllers;
|
||||
#nullable enable
|
||||
|
||||
/// <summary>
|
||||
/// The endpoint to interface with Koreader's Progress Sync plugin.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Koreader uses a different form of authentication. It stores the username and password in headers.
|
||||
/// https://github.com/koreader/koreader/blob/master/plugins/kosync.koplugin/KOSyncClient.lua
|
||||
/// </remarks>
|
||||
[AllowAnonymous]
|
||||
public class KoreaderController : BaseApiController
|
||||
{
|
||||
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILocalizationService _localizationService;
|
||||
private readonly IKoreaderService _koreaderService;
|
||||
private readonly ILogger<KoreaderController> _logger;
|
||||
|
||||
public KoreaderController(IUnitOfWork unitOfWork, ILocalizationService localizationService,
|
||||
IKoreaderService koreaderService, ILogger<KoreaderController> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_localizationService = localizationService;
|
||||
_koreaderService = koreaderService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// We won't allow users to be created from Koreader. Rather, they
|
||||
// must already have an account.
|
||||
/*
|
||||
[HttpPost("/users/create")]
|
||||
public IActionResult CreateUser(CreateUserRequest request)
|
||||
{
|
||||
}
|
||||
*/
|
||||
|
||||
[HttpGet("{apiKey}/users/auth")]
|
||||
public async Task<IActionResult> Authenticate(string apiKey)
|
||||
{
|
||||
var userId = await GetUserId(apiKey);
|
||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
|
||||
if (user == null) return Unauthorized();
|
||||
|
||||
return Ok(new { username = user.UserName });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Syncs book progress with Kavita. Will attempt to save the underlying reader position if possible.
|
||||
/// </summary>
|
||||
/// <param name="apiKey"></param>
|
||||
/// <param name="request"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPut("{apiKey}/syncs/progress")]
|
||||
public async Task<ActionResult<KoreaderProgressUpdateDto>> UpdateProgress(string apiKey, KoreaderBookDto request)
|
||||
{
|
||||
var userId = await GetUserId(apiKey);
|
||||
await _koreaderService.SaveProgress(request, userId);
|
||||
|
||||
return Ok(new KoreaderProgressUpdateDto{ Document = request.Document, Timestamp = DateTime.UtcNow });
|
||||
}
|
||||
|
||||
|
||||
[HttpGet("{apiKey}/syncs/progress/{ebookHash}")]
|
||||
public async Task<ActionResult<KoreaderBookDto>> GetProgress(string apiKey, string ebookHash)
|
||||
{
|
||||
var userId = await GetUserId(apiKey);
|
||||
var response = await _koreaderService.GetProgress(ebookHash, userId);
|
||||
_logger.LogDebug("Koreader response progress: {Progress}", response.Progress);
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
private async Task<int> GetUserId(string apiKey)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw new KavitaException(await _localizationService.Get("en", "user-doesnt-exist"));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Mvc;
|
|||
using Nager.ArticleNumber;
|
||||
|
||||
namespace API.Controllers;
|
||||
#nullable enable
|
||||
|
||||
public class PersonController : BaseApiController
|
||||
{
|
||||
|
@ -39,11 +40,11 @@ public class PersonController : BaseApiController
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a list of authors for browsing
|
||||
/// Returns a list of authors & artists for browsing
|
||||
/// </summary>
|
||||
/// <param name="userParams"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("authors")]
|
||||
[HttpPost("all")]
|
||||
public async Task<ActionResult<PagedList<BrowsePersonDto>>> GetAuthorsForBrowse([FromQuery] UserParams? userParams)
|
||||
{
|
||||
userParams ??= UserParams.Default;
|
||||
|
|
33
API/DTOs/Koreader/KoreaderBookDto.cs
Normal file
33
API/DTOs/Koreader/KoreaderBookDto.cs
Normal file
|
@ -0,0 +1,33 @@
|
|||
using API.DTOs.Progress;
|
||||
|
||||
namespace API.DTOs.Koreader;
|
||||
|
||||
/// <summary>
|
||||
/// This is the interface for receiving and sending updates to Koreader. The only fields
|
||||
/// that are actually used are the Document and Progress fields.
|
||||
/// </summary>
|
||||
public class KoreaderBookDto
|
||||
{
|
||||
/// <summary>
|
||||
/// This is the Koreader hash of the book. It is used to identify the book.
|
||||
/// </summary>
|
||||
public string Document { get; set; }
|
||||
/// <summary>
|
||||
/// A randomly generated id from the koreader device. Only used to maintain the Koreader interface.
|
||||
/// </summary>
|
||||
public string Device_id { get; set; }
|
||||
/// <summary>
|
||||
/// The Koreader device name. Only used to maintain the Koreader interface.
|
||||
/// </summary>
|
||||
public string Device { get; set; }
|
||||
/// <summary>
|
||||
/// Percent progress of the book. Only used to maintain the Koreader interface.
|
||||
/// </summary>
|
||||
public float Percentage { get; set; }
|
||||
/// <summary>
|
||||
/// An XPath string read by Koreader to determine the location within the epub.
|
||||
/// Essentially, it is Koreader's equivalent to ProgressDto.BookScrollId.
|
||||
/// </summary>
|
||||
/// <seealso cref="ProgressDto.BookScrollId"/>
|
||||
public string Progress { get; set; }
|
||||
}
|
9
API/DTOs/Koreader/KoreaderProgressUpdateDto.cs
Normal file
9
API/DTOs/Koreader/KoreaderProgressUpdateDto.cs
Normal file
|
@ -0,0 +1,9 @@
|
|||
using System;
|
||||
|
||||
namespace API.DTOs.Koreader;
|
||||
|
||||
public class KoreaderProgressUpdateDto
|
||||
{
|
||||
public string Document { get; set; }
|
||||
public DateTime Timestamp { get; set; }
|
||||
}
|
3200
API/Data/Migrations/20241026114311_KoreaderHash.Designer.cs
generated
Normal file
3200
API/Data/Migrations/20241026114311_KoreaderHash.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
28
API/Data/Migrations/20241026114311_KoreaderHash.cs
Normal file
28
API/Data/Migrations/20241026114311_KoreaderHash.cs
Normal file
|
@ -0,0 +1,28 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class KoreaderHash : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "KoreaderHash",
|
||||
table: "MangaFile",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "KoreaderHash",
|
||||
table: "MangaFile");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -15,7 +15,7 @@ namespace API.Data.Migrations
|
|||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.8");
|
||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.10");
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppRole", b =>
|
||||
{
|
||||
|
@ -1174,6 +1174,9 @@ namespace API.Data.Migrations
|
|||
b.Property<int>("Format")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("KoreaderHash")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastFileAnalysis")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
|
|
@ -10,6 +10,7 @@ public interface IMangaFileRepository
|
|||
{
|
||||
void Update(MangaFile file);
|
||||
Task<IList<MangaFile>> GetAllWithMissingExtension();
|
||||
Task<MangaFile?> GetByKoreaderHash(string hash);
|
||||
}
|
||||
|
||||
public class MangaFileRepository : IMangaFileRepository
|
||||
|
@ -32,4 +33,12 @@ public class MangaFileRepository : IMangaFileRepository
|
|||
.Where(f => string.IsNullOrEmpty(f.Extension))
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<MangaFile?> GetByKoreaderHash(string hash)
|
||||
{
|
||||
if (string.IsNullOrEmpty(hash)) return null;
|
||||
|
||||
return await _context.MangaFile
|
||||
.FirstOrDefaultAsync(f => f.KoreaderHash != null && f.KoreaderHash.Equals(hash, System.StringComparison.CurrentCultureIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -171,6 +171,7 @@ public class PersonRepository : IPersonRepository
|
|||
Id = p.Id,
|
||||
Name = p.Name,
|
||||
Description = p.Description,
|
||||
CoverImage = p.CoverImage,
|
||||
SeriesCount = p.SeriesMetadataPeople
|
||||
.Where(smp => roles.Contains(smp.Role))
|
||||
.Select(smp => smp.SeriesMetadata.SeriesId)
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using API.Entities.Enums;
|
||||
|
@ -21,6 +21,11 @@ public class MangaFile : IEntityDate
|
|||
/// </summary>
|
||||
public required string FilePath { get; set; }
|
||||
/// <summary>
|
||||
/// A hash of the document using Koreader's unique hashing algorithm
|
||||
/// </summary>
|
||||
/// <remark> KoreaderHash is only available for epub types </remark>
|
||||
public string? KoreaderHash { get; set; }
|
||||
/// <summary>
|
||||
/// Number of pages for the given file
|
||||
/// </summary>
|
||||
public int Pages { get; set; }
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
using System.IO.Abstractions;
|
||||
using System.IO.Abstractions;
|
||||
using API.Constants;
|
||||
using API.Data;
|
||||
using API.Helpers;
|
||||
|
@ -54,6 +54,7 @@ public static class ApplicationServiceExtensions
|
|||
services.AddScoped<IMediaConversionService, MediaConversionService>();
|
||||
services.AddScoped<IRecommendationService, RecommendationService>();
|
||||
services.AddScoped<IStreamService, StreamService>();
|
||||
services.AddScoped<IKoreaderService, KoreaderService>();
|
||||
|
||||
services.AddScoped<IScannerService, ScannerService>();
|
||||
services.AddScoped<IProcessSeries, ProcessSeries>();
|
||||
|
|
|
@ -424,7 +424,7 @@ public static class SeriesFilter
|
|||
public static IQueryable<Series> HasTags(this IQueryable<Series> queryable, bool condition,
|
||||
FilterComparison comparison, IList<int> tags)
|
||||
{
|
||||
if (!condition || tags.Count == 0) return queryable;
|
||||
if (!condition || (comparison != FilterComparison.IsEmpty && tags.Count == 0)) return queryable;
|
||||
|
||||
switch (comparison)
|
||||
{
|
||||
|
@ -547,7 +547,7 @@ public static class SeriesFilter
|
|||
public static IQueryable<Series> HasGenre(this IQueryable<Series> queryable, bool condition,
|
||||
FilterComparison comparison, IList<int> genres)
|
||||
{
|
||||
if (!condition || genres.Count == 0) return queryable;
|
||||
if (!condition || (comparison != FilterComparison.IsEmpty && genres.Count == 0)) return queryable;
|
||||
|
||||
switch (comparison)
|
||||
{
|
||||
|
@ -620,7 +620,7 @@ public static class SeriesFilter
|
|||
public static IQueryable<Series> HasCollectionTags(this IQueryable<Series> queryable, bool condition,
|
||||
FilterComparison comparison, IList<int> collectionTags, IList<int> collectionSeries)
|
||||
{
|
||||
if (!condition || collectionTags.Count == 0) return queryable;
|
||||
if (!condition || (comparison != FilterComparison.IsEmpty && collectionTags.Count == 0)) return queryable;
|
||||
|
||||
|
||||
switch (comparison)
|
||||
|
|
42
API/Helpers/Builders/KoreaderBookDtoBuilder.cs
Normal file
42
API/Helpers/Builders/KoreaderBookDtoBuilder.cs
Normal file
|
@ -0,0 +1,42 @@
|
|||
using API.DTOs.Koreader;
|
||||
|
||||
namespace API.Helpers.Builders;
|
||||
|
||||
public class KoreaderBookDtoBuilder : IEntityBuilder<KoreaderBookDto>
|
||||
{
|
||||
private readonly KoreaderBookDto _dto;
|
||||
public KoreaderBookDto Build() => _dto;
|
||||
|
||||
public KoreaderBookDtoBuilder(string documentHash)
|
||||
{
|
||||
_dto = new KoreaderBookDto()
|
||||
{
|
||||
Document = documentHash,
|
||||
Device = "Kavita"
|
||||
};
|
||||
}
|
||||
|
||||
public KoreaderBookDtoBuilder WithDocument(string documentHash)
|
||||
{
|
||||
_dto.Document = documentHash;
|
||||
return this;
|
||||
}
|
||||
|
||||
public KoreaderBookDtoBuilder WithProgress(string progress)
|
||||
{
|
||||
_dto.Progress = progress;
|
||||
return this;
|
||||
}
|
||||
|
||||
public KoreaderBookDtoBuilder WithPercentage(int? pageNum, int pages)
|
||||
{
|
||||
_dto.Percentage = (pageNum ?? 0) / (float) pages;
|
||||
return this;
|
||||
}
|
||||
|
||||
public KoreaderBookDtoBuilder WithDeviceId(string installId, int userId)
|
||||
{
|
||||
_dto.Device_id = installId;
|
||||
return this;
|
||||
}
|
||||
}
|
|
@ -20,7 +20,7 @@ public class MangaFileBuilder : IEntityBuilder<MangaFile>
|
|||
Pages = pages,
|
||||
LastModified = File.GetLastWriteTime(filePath),
|
||||
LastModifiedUtc = File.GetLastWriteTimeUtc(filePath),
|
||||
FileName = Parser.RemoveExtensionIfSupported(filePath)
|
||||
FileName = Parser.RemoveExtensionIfSupported(filePath),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -60,4 +60,17 @@ public class MangaFileBuilder : IEntityBuilder<MangaFile>
|
|||
_mangaFile.Id = Math.Max(id, 0);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate the Hash on the underlying file
|
||||
/// </summary>
|
||||
/// <remarks>Only applicable to Epubs</remarks>
|
||||
public MangaFileBuilder WithHash()
|
||||
{
|
||||
if (_mangaFile.Format != MangaFormat.Epub) return this;
|
||||
|
||||
_mangaFile.KoreaderHash = KoreaderHelper.HashContents(_mangaFile.FilePath);
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
|
111
API/Helpers/KoreaderHelper.cs
Normal file
111
API/Helpers/KoreaderHelper.cs
Normal file
|
@ -0,0 +1,111 @@
|
|||
using API.DTOs.Progress;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace API.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// All things related to Koreader
|
||||
/// </summary>
|
||||
/// <remarks>Original developer: https://github.com/MFDeAngelo</remarks>
|
||||
public static class KoreaderHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Hashes the document according to a custom Koreader hashing algorithm.
|
||||
/// Look at the util.partialMD5 method in the attached link.
|
||||
/// </summary>
|
||||
/// <remarks>The hashing algorithm is relatively quick as it only hashes ~10,000 bytes for the biggest of files.</remarks>
|
||||
/// <see href="https://github.com/koreader/koreader/blob/master/frontend/util.lua#L1040"/>
|
||||
/// <param name="filePath">The path to the file to hash</param>
|
||||
public static string HashContents(string filePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var file = File.OpenRead(filePath);
|
||||
|
||||
const int step = 1024;
|
||||
const int size = 1024;
|
||||
var md5 = MD5.Create();
|
||||
var buffer = new byte[size];
|
||||
|
||||
for (var i = -1; i < 10; i++)
|
||||
{
|
||||
file.Position = step << 2 * i;
|
||||
var bytesRead = file.Read(buffer, 0, size);
|
||||
if (bytesRead > 0)
|
||||
{
|
||||
md5.TransformBlock(buffer, 0, bytesRead, buffer, 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
file.Close();
|
||||
md5.TransformFinalBlock(Array.Empty<byte>(), 0, 0);
|
||||
|
||||
return md5.Hash == null ? null : BitConverter.ToString(md5.Hash).Replace("-", string.Empty).ToUpper();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Koreader can identify documents based on contents or title.
|
||||
/// For now, we only support by contents.
|
||||
/// </summary>
|
||||
public static string HashTitle(string filePath)
|
||||
{
|
||||
var fileName = Path.GetFileName(filePath);
|
||||
var fileNameBytes = Encoding.ASCII.GetBytes(fileName);
|
||||
var bytes = MD5.HashData(fileNameBytes);
|
||||
|
||||
return BitConverter.ToString(bytes).Replace("-", string.Empty);
|
||||
}
|
||||
|
||||
public static void UpdateProgressDto(ProgressDto progress, string koreaderPosition)
|
||||
{
|
||||
var path = koreaderPosition.Split('/');
|
||||
if (path.Length < 6)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var docNumber = path[2].Replace("DocFragment[", string.Empty).Replace("]", string.Empty);
|
||||
progress.PageNum = int.Parse(docNumber) - 1;
|
||||
var lastTag = path[5].ToUpper();
|
||||
|
||||
if (lastTag == "A")
|
||||
{
|
||||
progress.BookScrollId = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
// The format that Kavita accepts as a progress string. It tells Kavita where Koreader last left off.
|
||||
progress.BookScrollId = $"//html[1]/BODY/APP-ROOT[1]/DIV[1]/DIV[1]/DIV[1]/APP-BOOK-READER[1]/DIV[1]/DIV[2]/DIV[1]/DIV[1]/DIV[1]/{lastTag}";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static string GetKoreaderPosition(ProgressDto progressDto)
|
||||
{
|
||||
string lastTag;
|
||||
var koreaderPageNumber = progressDto.PageNum + 1;
|
||||
|
||||
if (string.IsNullOrEmpty(progressDto.BookScrollId))
|
||||
{
|
||||
lastTag = "a";
|
||||
}
|
||||
else
|
||||
{
|
||||
var tokens = progressDto.BookScrollId.Split('/');
|
||||
lastTag = tokens[^1].ToLower();
|
||||
}
|
||||
|
||||
// The format that Koreader accepts as a progress string. It tells Koreader where Kavita last left off.
|
||||
return $"/body/DocFragment[{koreaderPageNumber}]/body/div/{lastTag}";
|
||||
}
|
||||
}
|
82
API/Services/KoreaderService.cs
Normal file
82
API/Services/KoreaderService.cs
Normal file
|
@ -0,0 +1,82 @@
|
|||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.DTOs.Koreader;
|
||||
using API.DTOs.Progress;
|
||||
using API.Helpers;
|
||||
using API.Helpers.Builders;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Services;
|
||||
|
||||
#nullable enable
|
||||
|
||||
public interface IKoreaderService
|
||||
{
|
||||
Task SaveProgress(KoreaderBookDto koreaderBookDto, int userId);
|
||||
Task<KoreaderBookDto> GetProgress(string bookHash, int userId);
|
||||
}
|
||||
|
||||
public class KoreaderService : IKoreaderService
|
||||
{
|
||||
private readonly IReaderService _readerService;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILogger<KoreaderService> _logger;
|
||||
|
||||
public KoreaderService(IReaderService readerService, IUnitOfWork unitOfWork, ILogger<KoreaderService> logger)
|
||||
{
|
||||
_readerService = readerService;
|
||||
_unitOfWork = unitOfWork;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Given a Koreader hash, locate the underlying file and generate/update a progress event.
|
||||
/// </summary>
|
||||
/// <param name="koreaderBookDto"></param>
|
||||
/// <param name="userId"></param>
|
||||
public async Task SaveProgress(KoreaderBookDto koreaderBookDto, int userId)
|
||||
{
|
||||
_logger.LogDebug("Saving Koreader progress for {UserId}: {KoreaderProgress}", userId, koreaderBookDto.Progress);
|
||||
var file = await _unitOfWork.MangaFileRepository.GetByKoreaderHash(koreaderBookDto.Document);
|
||||
if (file == null) return;
|
||||
|
||||
var userProgressDto = await _unitOfWork.AppUserProgressRepository.GetUserProgressDtoAsync(file.ChapterId, userId);
|
||||
if (userProgressDto == null)
|
||||
{
|
||||
// TODO: Handle this case
|
||||
userProgressDto = new ProgressDto()
|
||||
{
|
||||
ChapterId = file.ChapterId,
|
||||
};
|
||||
}
|
||||
// Update the bookScrollId if possible
|
||||
KoreaderHelper.UpdateProgressDto(userProgressDto, koreaderBookDto.Progress);
|
||||
|
||||
await _readerService.SaveReadingProgress(userProgressDto, userId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a Koreader Dto representing current book and the progress within
|
||||
/// </summary>
|
||||
/// <param name="bookHash"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<KoreaderBookDto> GetProgress(string bookHash, int userId)
|
||||
{
|
||||
var settingsDto = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
|
||||
var builder = new KoreaderBookDtoBuilder(bookHash);
|
||||
|
||||
var file = await _unitOfWork.MangaFileRepository.GetByKoreaderHash(bookHash);
|
||||
|
||||
// TODO: How do we handle when file isn't found by hash?
|
||||
if (file == null) return builder.Build();
|
||||
|
||||
var progressDto = await _unitOfWork.AppUserProgressRepository.GetUserProgressDtoAsync(file.ChapterId, userId);
|
||||
var koreaderProgress = KoreaderHelper.GetKoreaderPosition(progressDto);
|
||||
|
||||
return builder.WithProgress(koreaderProgress)
|
||||
.WithPercentage(progressDto?.PageNum, file.Pages)
|
||||
.WithDeviceId(settingsDto.InstallId, userId) // TODO: Should we generate a hash for UserId + InstallId so that this DeviceId is unique to the user on the server?
|
||||
.Build();
|
||||
}
|
||||
}
|
|
@ -9,7 +9,6 @@ namespace API.Services;
|
|||
|
||||
public interface IReadingItemService
|
||||
{
|
||||
ComicInfo? GetComicInfo(string filePath);
|
||||
int GetNumberOfPages(string filePath, MangaFormat format);
|
||||
string GetCoverImage(string filePath, string fileName, MangaFormat format, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default);
|
||||
void Extract(string fileFilePath, string targetDirectory, MangaFormat format, int imageCount = 1);
|
||||
|
@ -51,7 +50,7 @@ public class ReadingItemService : IReadingItemService
|
|||
/// </summary>
|
||||
/// <param name="filePath">Fully qualified path of file</param>
|
||||
/// <returns></returns>
|
||||
public ComicInfo? GetComicInfo(string filePath)
|
||||
private ComicInfo? GetComicInfo(string filePath)
|
||||
{
|
||||
if (Parser.IsEpub(filePath))
|
||||
{
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
|
@ -110,7 +110,7 @@ public class ProcessSeries : IProcessSeries
|
|||
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("[ScannerService] Processing series {SeriesName}", series.OriginalName);
|
||||
_logger.LogInformation("[ScannerService] Processing series {SeriesName} with {Count} files", series.OriginalName, parsedInfos.Count);
|
||||
|
||||
// parsedInfos[0] is not the first volume or chapter. We need to find it using a ComicInfo check (as it uses firstParsedInfo for series sort)
|
||||
var firstParsedInfo = parsedInfos.FirstOrDefault(p => p.ComicInfo != null, firstInfo);
|
||||
|
@ -423,7 +423,7 @@ public class ProcessSeries : IProcessSeries
|
|||
var defaultAdmin = await _unitOfWork.UserRepository.GetDefaultAdminUser(AppUserIncludes.Collections);
|
||||
if (defaultAdmin == null) return;
|
||||
|
||||
_logger.LogDebug("Collection tag(s) found for {SeriesName}, updating collections", series.Name);
|
||||
_logger.LogInformation("Collection tag(s) found for {SeriesName}, updating collections", series.Name);
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
foreach (var collection in firstChapter.SeriesGroup.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
|
||||
|
@ -593,7 +593,6 @@ public class ProcessSeries : IProcessSeries
|
|||
{
|
||||
// Add new volumes and update chapters per volume
|
||||
var distinctVolumes = parsedInfos.DistinctVolumes();
|
||||
_logger.LogTrace("[ScannerService] Updating {DistinctVolumes} volumes on {SeriesName}", distinctVolumes.Count, series.Name);
|
||||
foreach (var volumeNumber in distinctVolumes)
|
||||
{
|
||||
Volume? volume;
|
||||
|
@ -621,7 +620,6 @@ public class ProcessSeries : IProcessSeries
|
|||
volume.LookupName = volumeNumber;
|
||||
volume.Name = volume.GetNumberTitle();
|
||||
|
||||
_logger.LogTrace("[ScannerService] Parsing {SeriesName} - Volume {VolumeNumber}", series.Name, volume.Name);
|
||||
var infos = parsedInfos.Where(p => p.Volumes == volumeNumber).ToArray();
|
||||
|
||||
await UpdateChapters(series, volume, infos, forceUpdate);
|
||||
|
@ -641,7 +639,7 @@ public class ProcessSeries : IProcessSeries
|
|||
if (series.Volumes.Count == nonDeletedVolumes.Count) return;
|
||||
|
||||
|
||||
_logger.LogTrace("[ScannerService] Removed {Count} volumes from {SeriesName} where parsed infos were not mapping with volume name",
|
||||
_logger.LogDebug("[ScannerService] Removed {Count} volumes from {SeriesName} where parsed infos were not mapping with volume name",
|
||||
(series.Volumes.Count - nonDeletedVolumes.Count), series.Name);
|
||||
var deletedVolumes = series.Volumes.Except(nonDeletedVolumes);
|
||||
foreach (var volume in deletedVolumes)
|
||||
|
@ -655,7 +653,7 @@ public class ProcessSeries : IProcessSeries
|
|||
file);
|
||||
}
|
||||
|
||||
_logger.LogTrace("[ScannerService] Removed {SeriesName} - Volume {Volume}: {File}", series.Name, volume.Name, file);
|
||||
_logger.LogDebug("[ScannerService] Removed {SeriesName} - Volume {Volume}: {File}", series.Name, volume.Name, file);
|
||||
}
|
||||
|
||||
series.Volumes = nonDeletedVolumes;
|
||||
|
@ -681,7 +679,7 @@ public class ProcessSeries : IProcessSeries
|
|||
|
||||
if (chapter == null)
|
||||
{
|
||||
_logger.LogTrace(
|
||||
_logger.LogDebug(
|
||||
"[ScannerService] Adding new chapter, {Series} - Vol {Volume} Ch {Chapter}", info.Series, info.Volumes, info.Chapters);
|
||||
chapter = ChapterBuilder.FromParserInfo(info).Build();
|
||||
volume.Chapters.Add(chapter);
|
||||
|
@ -778,7 +776,7 @@ public class ProcessSeries : IProcessSeries
|
|||
// If no files remain after filtering, remove the chapter
|
||||
if (existingChapter.Files.Count != 0) continue;
|
||||
|
||||
_logger.LogTrace("[ScannerService] Removed chapter {Chapter} for Volume {VolumeNumber} on {SeriesName}",
|
||||
_logger.LogDebug("[ScannerService] Removed chapter {Chapter} for Volume {VolumeNumber} on {SeriesName}",
|
||||
existingChapter.Range, volume.Name, parsedInfos[0].Series);
|
||||
volume.Chapters.Remove(existingChapter);
|
||||
}
|
||||
|
@ -789,7 +787,7 @@ public class ProcessSeries : IProcessSeries
|
|||
|
||||
// If no files exist, remove the chapter
|
||||
if (filesExist) continue;
|
||||
_logger.LogTrace("[ScannerService] Removed chapter {Chapter} for Volume {VolumeNumber} on {SeriesName} as no files exist",
|
||||
_logger.LogDebug("[ScannerService] Removed chapter {Chapter} for Volume {VolumeNumber} on {SeriesName} as no files exist",
|
||||
existingChapter.Range, volume.Name, parsedInfos[0].Series);
|
||||
volume.Chapters.Remove(existingChapter);
|
||||
}
|
||||
|
@ -818,6 +816,7 @@ public class ProcessSeries : IProcessSeries
|
|||
var file = new MangaFileBuilder(info.FullFilePath, info.Format, _readingItemService.GetNumberOfPages(info.FullFilePath, info.Format))
|
||||
.WithExtension(fileInfo.Extension)
|
||||
.WithBytes(fileInfo.Length)
|
||||
.WithHash()
|
||||
.Build();
|
||||
chapter.Files.Add(file);
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Company>kavitareader.com</Company>
|
||||
<Product>Kavita</Product>
|
||||
<AssemblyVersion>0.8.3.16</AssemblyVersion>
|
||||
<AssemblyVersion>0.8.3.18</AssemblyVersion>
|
||||
<NeutralLanguage>en</NeutralLanguage>
|
||||
<TieredPGO>true</TieredPGO>
|
||||
</PropertyGroup>
|
||||
|
@ -13,11 +13,11 @@
|
|||
<PackageReference Include="DotNet.Glob" Version="3.1.3" />
|
||||
<PackageReference Include="Flurl.Http" Version="3.2.4" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
|
||||
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.32.0.97167">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="xunit.assert" Version="2.9.1" />
|
||||
<PackageReference Include="xunit.assert" Version="2.9.2" />
|
||||
</ItemGroup>
|
||||
</Project>
|
|
@ -24,13 +24,14 @@
|
|||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:application",
|
||||
"builder": "@angular/build:application",
|
||||
"options": {
|
||||
"outputPath": "dist",
|
||||
"index": "src/index.html",
|
||||
"browser": "src/main.ts",
|
||||
"polyfills": [
|
||||
"zone.js"
|
||||
"@angular/localize/init",
|
||||
"zone.js"
|
||||
],
|
||||
"inlineStyleLanguage": "scss",
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
|
@ -87,7 +88,7 @@
|
|||
"defaultConfiguration": ""
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"builder": "@angular/build:dev-server",
|
||||
"options": {
|
||||
"sslKey": "./ssl/server.key",
|
||||
"sslCert": "./ssl/server.crt",
|
||||
|
@ -101,7 +102,7 @@
|
|||
}
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n",
|
||||
"builder": "@angular/build:extract-i18n",
|
||||
"options": {
|
||||
"buildTarget": "kavita-webui:build"
|
||||
}
|
||||
|
|
10318
UI/Web/package-lock.json
generated
10318
UI/Web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -16,70 +16,69 @@
|
|||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^17.3.4",
|
||||
"@angular/cdk": "^17.3.4",
|
||||
"@angular/common": "^17.3.4",
|
||||
"@angular/compiler": "^17.3.4",
|
||||
"@angular/core": "^17.3.4",
|
||||
"@angular/forms": "^17.3.4",
|
||||
"@angular/localize": "^17.3.4",
|
||||
"@angular/platform-browser": "^17.3.4",
|
||||
"@angular/platform-browser-dynamic": "^17.3.4",
|
||||
"@angular/router": "^17.3.4",
|
||||
"@fortawesome/fontawesome-free": "^6.5.2",
|
||||
"@angular-slider/ngx-slider": "^18.0.0",
|
||||
"@angular/animations": "^18.2.9",
|
||||
"@angular/cdk": "^18.2.10",
|
||||
"@angular/common": "^18.2.9",
|
||||
"@angular/compiler": "^18.2.9",
|
||||
"@angular/core": "^18.2.9",
|
||||
"@angular/forms": "^18.2.9",
|
||||
"@angular/localize": "^18.2.9",
|
||||
"@angular/platform-browser": "^18.2.9",
|
||||
"@angular/platform-browser-dynamic": "^18.2.9",
|
||||
"@angular/router": "^18.2.9",
|
||||
"@fortawesome/fontawesome-free": "^6.6.0",
|
||||
"@iharbeck/ngx-virtual-scroller": "^17.0.2",
|
||||
"@iplab/ngx-file-upload": "^17.1.0",
|
||||
"@jsverse/transloco": "^7.4.3",
|
||||
"@iplab/ngx-file-upload": "^18.0.0",
|
||||
"@jsverse/transloco": "^7.5.0",
|
||||
"@jsverse/transloco-locale": "^7.0.1",
|
||||
"@jsverse/transloco-persist-lang": "^7.0.1",
|
||||
"@jsverse/transloco-persist-lang": "^7.0.2",
|
||||
"@jsverse/transloco-persist-translations": "^7.0.1",
|
||||
"@jsverse/transloco-preload-langs": "^7.0.1",
|
||||
"@microsoft/signalr": "^7.0.14",
|
||||
"@ng-bootstrap/ng-bootstrap": "^16.0.0",
|
||||
"@microsoft/signalr": "^8.0.7",
|
||||
"@ng-bootstrap/ng-bootstrap": "^17.0.1",
|
||||
"@popperjs/core": "^2.11.7",
|
||||
"@swimlane/ngx-charts": "^20.5.0",
|
||||
"@tweenjs/tween.js": "^23.1.1",
|
||||
"@tweenjs/tween.js": "^23.1.3",
|
||||
"bootstrap": "^5.3.2",
|
||||
"charts.css": "^1.1.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"luxon": "^3.4.4",
|
||||
"luxon": "^3.5.0",
|
||||
"ng-circle-progress": "^1.7.1",
|
||||
"ng-lazyload-image": "^9.1.3",
|
||||
"ng-select2-component": "^14.0.1",
|
||||
"ngx-color-picker": "^16.0.0",
|
||||
"ngx-extended-pdf-viewer": "^18.1.9",
|
||||
"ngx-color-picker": "^17.0.0",
|
||||
"ngx-extended-pdf-viewer": "^21.4.6",
|
||||
"ngx-file-drop": "^16.0.0",
|
||||
"ngx-slider-v2": "^17.0.0",
|
||||
"ngx-stars": "^1.6.5",
|
||||
"ngx-toaster": "^1.0.1",
|
||||
"ngx-toastr": "^18.0.0",
|
||||
"ngx-toastr": "^19.0.0",
|
||||
"nosleep.js": "^0.12.0",
|
||||
"rxjs": "^7.8.0",
|
||||
"screenfull": "^6.0.2",
|
||||
"swiper": "^8.4.6",
|
||||
"tslib": "^2.6.2",
|
||||
"zone.js": "^0.14.3"
|
||||
"tslib": "^2.8.0",
|
||||
"zone.js": "^0.14.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^17.3.4",
|
||||
"@angular-eslint/builder": "^17.3.0",
|
||||
"@angular-eslint/eslint-plugin": "^17.3.0",
|
||||
"@angular-eslint/eslint-plugin-template": "^17.3.0",
|
||||
"@angular-eslint/schematics": "^17.3.0",
|
||||
"@angular-eslint/template-parser": "^17.3.0",
|
||||
"@angular/cli": "^17.3.4",
|
||||
"@angular/compiler-cli": "^17.3.4",
|
||||
"@angular-eslint/builder": "^18.4.0",
|
||||
"@angular-eslint/eslint-plugin": "^18.4.0",
|
||||
"@angular-eslint/eslint-plugin-template": "^18.4.0",
|
||||
"@angular-eslint/schematics": "^18.4.0",
|
||||
"@angular-eslint/template-parser": "^18.4.0",
|
||||
"@angular/build": "^18.2.10",
|
||||
"@angular/cli": "^18.2.10",
|
||||
"@angular/compiler-cli": "^18.2.9",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/luxon": "^3.4.0",
|
||||
"@types/node": "^20.10.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
||||
"@typescript-eslint/parser": "^7.2.0",
|
||||
"@types/node": "^22.8.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.11.0",
|
||||
"@typescript-eslint/parser": "^8.11.0",
|
||||
"eslint": "^8.57.0",
|
||||
"jsonminify": "^0.4.2",
|
||||
"karma-coverage": "~2.2.0",
|
||||
"ts-node": "~10.9.1",
|
||||
"typescript": "^5.2.2",
|
||||
"typescript": "^5.5.4",
|
||||
"webpack-bundle-analyzer": "^4.10.2"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +1,5 @@
|
|||
import {Injectable} from '@angular/core';
|
||||
import {
|
||||
HttpRequest,
|
||||
HttpHandler,
|
||||
HttpEvent,
|
||||
HttpInterceptor
|
||||
} from '@angular/common/http';
|
||||
import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor } from '@angular/common/http';
|
||||
import { Observable, throwError } from 'rxjs';
|
||||
import { Router } from '@angular/router';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
|
|
|
@ -1,10 +1,5 @@
|
|||
import {Injectable} from '@angular/core';
|
||||
import {
|
||||
HttpRequest,
|
||||
HttpHandler,
|
||||
HttpEvent,
|
||||
HttpInterceptor
|
||||
} from '@angular/common/http';
|
||||
import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor } from '@angular/common/http';
|
||||
import {Observable, switchMap} from 'rxjs';
|
||||
import { AccountService } from '../_services/account.service';
|
||||
import { take } from 'rxjs/operators';
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
import {TranslocoService} from "@jsverse/transloco";
|
||||
import {HourEstimateRange} from "../_models/series-detail/hour-estimate-range";
|
||||
import {DecimalPipe} from "@angular/common";
|
||||
|
||||
@Pipe({
|
||||
name: 'readTimeLeft',
|
||||
|
@ -8,9 +9,31 @@ import {HourEstimateRange} from "../_models/series-detail/hour-estimate-range";
|
|||
})
|
||||
export class ReadTimeLeftPipe implements PipeTransform {
|
||||
|
||||
constructor(private translocoService: TranslocoService) {}
|
||||
constructor(private readonly translocoService: TranslocoService) {}
|
||||
|
||||
transform(readingTimeLeft: HourEstimateRange): string {
|
||||
return `~${readingTimeLeft.avgHours} ${readingTimeLeft.avgHours > 1 ? this.translocoService.translate('read-time-pipe.hours') : this.translocoService.translate('read-time-pipe.hour')}`;
|
||||
const hoursLabel = readingTimeLeft.avgHours > 1
|
||||
? this.translocoService.translate('read-time-pipe.hours')
|
||||
: this.translocoService.translate('read-time-pipe.hour');
|
||||
|
||||
const formattedHours = this.customRound(readingTimeLeft.avgHours);
|
||||
|
||||
return `~${formattedHours} ${hoursLabel}`;
|
||||
}
|
||||
|
||||
private customRound(value: number): string {
|
||||
const integerPart = Math.floor(value);
|
||||
const decimalPart = value - integerPart;
|
||||
|
||||
if (decimalPart < 0.5) {
|
||||
// Round down to the nearest whole number
|
||||
return integerPart.toString();
|
||||
} else if (decimalPart >= 0.5 && decimalPart < 0.9) {
|
||||
// Return with 1 decimal place
|
||||
return value.toFixed(1);
|
||||
} else {
|
||||
// Round up to the nearest whole number
|
||||
return Math.ceil(value).toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -490,7 +490,7 @@ export class ActionService {
|
|||
this.readingListModalRef.componentInstance.seriesId = seriesId;
|
||||
this.readingListModalRef.componentInstance.volumeIds = volumes.map(v => v.id);
|
||||
this.readingListModalRef.componentInstance.chapterIds = chapters?.map(c => c.id);
|
||||
this.readingListModalRef.componentInstance.title = translate('action.multiple-selections');
|
||||
this.readingListModalRef.componentInstance.title = translate('actionable.multiple-selections');
|
||||
this.readingListModalRef.componentInstance.type = ADD_FLOW.Multiple;
|
||||
|
||||
|
||||
|
@ -530,7 +530,7 @@ export class ActionService {
|
|||
if (this.readingListModalRef != null) { return; }
|
||||
this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md', fullscreen: 'md' });
|
||||
this.readingListModalRef.componentInstance.seriesIds = series.map(v => v.id);
|
||||
this.readingListModalRef.componentInstance.title = translate('action.multiple-selections');
|
||||
this.readingListModalRef.componentInstance.title = translate('actionable.multiple-selections');
|
||||
this.readingListModalRef.componentInstance.type = ADD_FLOW.Multiple_Series;
|
||||
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import {environment} from "../../environments/environment";
|
||||
import {HttpClient} from "@angular/common/http";
|
||||
import { HttpClient } from "@angular/common/http";
|
||||
import {Chapter} from "../_models/chapter";
|
||||
import {TextResonse} from "../_types/text-response";
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {HttpClient} from '@angular/common/http';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import {Injectable} from '@angular/core';
|
||||
import {environment} from 'src/environments/environment';
|
||||
import {UserCollection} from '../_models/collection-tag';
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import {TextResonse} from "../_types/text-response";
|
||||
import {HttpClient} from "@angular/common/http";
|
||||
import { HttpClient } from "@angular/common/http";
|
||||
import {environment} from "../../environments/environment";
|
||||
import {DashboardStream} from "../_models/dashboard/dashboard-stream";
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import {environment} from "../../environments/environment";
|
||||
import {HttpClient} from "@angular/common/http";
|
||||
import { HttpClient } from "@angular/common/http";
|
||||
import {ExternalSource} from "../_models/sidenav/external-source";
|
||||
import {TextResonse} from "../_types/text-response";
|
||||
import {map} from "rxjs/operators";
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
|
||||
import {environment} from "../../environments/environment";
|
||||
import {HttpClient} from "@angular/common/http";
|
||||
import { HttpClient } from "@angular/common/http";
|
||||
import {JumpKey} from "../_models/jumpbar/jump-key";
|
||||
import {SmartFilter} from "../_models/metadata/v2/smart-filter";
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import {environment} from "../../environments/environment";
|
||||
import {HttpClient} from "@angular/common/http";
|
||||
import { HttpClient } from "@angular/common/http";
|
||||
import {Language} from "../_models/metadata/language";
|
||||
|
||||
@Injectable({
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {HttpClient} from '@angular/common/http';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import {Injectable} from '@angular/core';
|
||||
import {tap} from 'rxjs/operators';
|
||||
import {of} from 'rxjs';
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import {DOCUMENT} from '@angular/common';
|
||||
import {DestroyRef, inject, Inject, Injectable, Renderer2, RendererFactory2, RendererStyleFlags2} from '@angular/core';
|
||||
import {distinctUntilChanged, filter, ReplaySubject, take} from 'rxjs';
|
||||
import {HttpClient} from "@angular/common/http";
|
||||
import { HttpClient } from "@angular/common/http";
|
||||
import {environment} from "../../environments/environment";
|
||||
import {SideNavStream} from "../_models/sidenav/sidenav-stream";
|
||||
import {TextResonse} from "../_types/text-response";
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import {HttpClient, HttpParams} from "@angular/common/http";
|
||||
import { HttpClient, HttpParams } from "@angular/common/http";
|
||||
import {environment} from "../../environments/environment";
|
||||
import {Person, PersonRole} from "../_models/metadata/person";
|
||||
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
|
||||
|
@ -44,7 +44,7 @@ export class PersonService {
|
|||
let params = new HttpParams();
|
||||
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
|
||||
|
||||
return this.httpClient.post<PaginatedResult<BrowsePerson[]>>(this.baseUrl + 'person/authors', {}, {observe: 'response', params}).pipe(
|
||||
return this.httpClient.post<PaginatedResult<BrowsePerson[]>>(this.baseUrl + 'person/all', {}, {observe: 'response', params}).pipe(
|
||||
map((response: any) => {
|
||||
return this.utilityService.createPaginatedResult(response) as PaginatedResult<BrowsePerson[]>;
|
||||
})
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {HttpClient, HttpParams} from '@angular/common/http';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import {Injectable} from '@angular/core';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { environment } from 'src/environments/environment';
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import {DOCUMENT} from '@angular/common';
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import {
|
||||
DestroyRef,
|
||||
inject,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import {environment} from "../../environments/environment";
|
||||
import {HttpClient} from "@angular/common/http";
|
||||
import { HttpClient } from "@angular/common/http";
|
||||
import {Volume} from "../_models/volume";
|
||||
import {TextResonse} from "../_types/text-response";
|
||||
|
||||
|
|
|
@ -2,21 +2,23 @@
|
|||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">{{t('title')}}</h4>
|
||||
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="modal.close()"></button>
|
||||
<button type="button" class="btn-close" aria-label="close" (click)="modal.close()"></button>
|
||||
</div>
|
||||
<div class="modal-body scrollable-modal">
|
||||
@if (currentLevel.length > 0) {
|
||||
<button class="btn btn-secondary w-100 mb-3 text-start" (click)="handleBack()">
|
||||
← {{t('back-to', {action: t(currentLevel[currentLevel.length - 1])})}}
|
||||
← {{t('back-to', {action: currentLevel[currentLevel.length - 1]})}}
|
||||
</button>
|
||||
}
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
|
||||
|
||||
@for (action of currentItems; track action.title) {
|
||||
@if (willRenderAction(action)) {
|
||||
<button class="btn btn-outline-primary text-start d-flex justify-content-between align-items-center w-100"
|
||||
(click)="handleItemClick(action)">
|
||||
{{t(action.title)}}
|
||||
{{action.title}}
|
||||
@if (action.children.length > 0 || action.dynamicList) {
|
||||
<span class="ms-1">→</span>
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@ import {
|
|||
Output
|
||||
} from '@angular/core';
|
||||
import {NgClass} from "@angular/common";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {translate, TranslocoDirective} from "@jsverse/transloco";
|
||||
import {Breakpoint, UtilityService} from "../../shared/_services/utility.service";
|
||||
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {Action, ActionItem} from "../../_services/action-factory.service";
|
||||
|
@ -48,7 +48,8 @@ export class ActionableModalComponent implements OnInit {
|
|||
user!: User | undefined;
|
||||
|
||||
ngOnInit() {
|
||||
this.currentItems = this.actions;
|
||||
this.currentItems = this.translateOptions(this.actions);
|
||||
|
||||
this.accountService.currentUser$.pipe(tap(user => {
|
||||
this.user = user;
|
||||
this.cdRef.markForCheck();
|
||||
|
@ -58,17 +59,21 @@ export class ActionableModalComponent implements OnInit {
|
|||
handleItemClick(item: ActionItem<any>) {
|
||||
if (item.children && item.children.length > 0) {
|
||||
this.currentLevel.push(item.title);
|
||||
this.currentItems = item.children;
|
||||
} else if (item.dynamicList) {
|
||||
item.dynamicList.subscribe(dynamicItems => {
|
||||
this.currentLevel.push(item.title);
|
||||
this.currentItems = dynamicItems.map(di => ({
|
||||
...item,
|
||||
title: di.title,
|
||||
_extra: di
|
||||
}));
|
||||
});
|
||||
} else {
|
||||
|
||||
if (item.children.length === 1 && item.children[0].dynamicList) {
|
||||
item.children[0].dynamicList.subscribe(dynamicItems => {
|
||||
this.currentItems = dynamicItems.map(di => ({
|
||||
...item,
|
||||
children: [], // Required as dynamic list is only one deep
|
||||
title: di.title,
|
||||
_extra: di
|
||||
}));
|
||||
});
|
||||
} else {
|
||||
this.currentItems = this.translateOptions(item.children);
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.actionPerformed.emit(item);
|
||||
this.modal.close(item);
|
||||
}
|
||||
|
@ -84,15 +89,15 @@ export class ActionableModalComponent implements OnInit {
|
|||
items = items.find(item => item.title === level)?.children || [];
|
||||
}
|
||||
|
||||
this.currentItems = items;
|
||||
this.currentItems = this.translateOptions(items);
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
}
|
||||
|
||||
// willRenderAction(action: ActionItem<any>) {
|
||||
// if (this.user === undefined) return false;
|
||||
//
|
||||
// return this.accountService.canInvokeAction(this.user, action.action);
|
||||
// }
|
||||
translateOptions(opts: Array<ActionItem<any>>) {
|
||||
return opts.map(a => {
|
||||
return {...a, title: translate('actionable.' + a.title)};
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -124,7 +124,7 @@ export class CardActionablesComponent implements OnInit {
|
|||
openMobileActionableMenu(event: any) {
|
||||
this.preventEvent(event);
|
||||
|
||||
const ref = this.modalService.open(ActionableModalComponent, {fullscreen: 'sm'});
|
||||
const ref = this.modalService.open(ActionableModalComponent, {fullscreen: true, centered: true});
|
||||
ref.componentInstance.actions = this.actions;
|
||||
ref.componentInstance.willRenderAction = this.willRenderAction.bind(this);
|
||||
ref.componentInstance.shouldRenderSubMenu = this.shouldRenderSubMenu.bind(this);
|
||||
|
|
|
@ -1,31 +1,32 @@
|
|||
<ng-container *transloco="let t; read: 'related-tab'">
|
||||
<div style="padding-bottom: 1rem;">
|
||||
@if (relations.length > 0) {
|
||||
<app-carousel-reel [items]="relations" [title]="t('relations-title')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-series-card class="col-auto mt-2 mb-2" [series]="item.series" [libraryId]="item.series.libraryId" [relation]="item.relation"></app-series-card>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
}
|
||||
|
||||
@if (relations.length > 0) {
|
||||
<app-carousel-reel [items]="relations" [title]="t('relations-title')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-series-card class="col-auto mt-2 mb-2" [series]="item.series" [libraryId]="item.series.libraryId" [relation]="item.relation"></app-series-card>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
}
|
||||
|
||||
@if (collections.length > 0) {
|
||||
<app-carousel-reel [items]="collections" [title]="t('collections-title')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-card-item [title]="item.title" [entity]="item"
|
||||
[suppressLibraryLink]="true" [imageUrl]="imageService.getCollectionCoverImage(item.id)"
|
||||
(clicked)="openCollection(item)" [linkUrl]="'/collections/' + item.id" [showFormat]="false"></app-card-item>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
}
|
||||
@if (collections.length > 0) {
|
||||
<app-carousel-reel [items]="collections" [title]="t('collections-title')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-card-item [title]="item.title" [entity]="item"
|
||||
[suppressLibraryLink]="true" [imageUrl]="imageService.getCollectionCoverImage(item.id)"
|
||||
(clicked)="openCollection(item)" [linkUrl]="'/collections/' + item.id" [showFormat]="false"></app-card-item>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
}
|
||||
|
||||
|
||||
@if (readingLists.length > 0) {
|
||||
<app-carousel-reel [items]="readingLists" [title]="t('reading-lists-title')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-card-item [title]="item.title" [entity]="item"
|
||||
[suppressLibraryLink]="true" [imageUrl]="imageService.getReadingListCoverImage(item.id)"
|
||||
(clicked)="openReadingList(item)" [linkUrl]="'/lists/' + item.id" [showFormat]="false"></app-card-item>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
}
|
||||
@if (readingLists.length > 0) {
|
||||
<app-carousel-reel [items]="readingLists" [title]="t('reading-lists-title')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-card-item [title]="item.title" [entity]="item"
|
||||
[suppressLibraryLink]="true" [imageUrl]="imageService.getReadingListCoverImage(item.id)"
|
||||
(clicked)="openReadingList(item)" [linkUrl]="'/lists/' + item.id" [showFormat]="false"></app-card-item>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
}
|
||||
</div>
|
||||
</ng-container>
|
||||
|
|
|
@ -121,18 +121,18 @@ export class AppComponent implements OnInit {
|
|||
this.libraryService.getLibraryNames().pipe(take(1), shareReplay({refCount: true, bufferSize: 1})).subscribe();
|
||||
|
||||
// Get the server version, compare vs localStorage, and if different bust locale cache
|
||||
const versionKey = 'kavita--version';
|
||||
this.serverService.getVersion(user.apiKey).subscribe(version => {
|
||||
const cachedVersion = localStorage.getItem('kavita--version');
|
||||
const cachedVersion = localStorage.getItem(versionKey);
|
||||
console.log('Kavita version: ', version, ' Running version: ', cachedVersion);
|
||||
|
||||
if (cachedVersion == null || cachedVersion != version) {
|
||||
// Bust locale cache
|
||||
localStorage.removeItem('@transloco/translations/timestamp');
|
||||
localStorage.removeItem('@transloco/translations');
|
||||
(this.translocoService as any).cache.delete(localStorage.getItem('kavita-locale') || 'en');
|
||||
(this.translocoService as any).cache.clear();
|
||||
localStorage.setItem('kavita--version', version);
|
||||
this.bustLocaleCache();
|
||||
localStorage.setItem(versionKey, version);
|
||||
location.reload();
|
||||
}
|
||||
localStorage.setItem('kavita--version', version);
|
||||
localStorage.setItem(versionKey, version);
|
||||
});
|
||||
|
||||
// Every hour, have the UI check for an update. People seriously stay out of date
|
||||
|
@ -153,4 +153,12 @@ export class AppComponent implements OnInit {
|
|||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
private bustLocaleCache() {
|
||||
localStorage.removeItem('@transloco/translations/timestamp');
|
||||
localStorage.removeItem('@transloco/translations');
|
||||
localStorage.removeItem('translocoLang');
|
||||
(this.translocoService as any).cache.delete(localStorage.getItem('kavita-locale') || 'en');
|
||||
(this.translocoService as any).cache.clear();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -33,6 +33,8 @@ import {filter, map} from "rxjs/operators";
|
|||
import {UserProgressUpdateEvent} from "../../_models/events/user-progress-update-event";
|
||||
import {ReaderService} from "../../_services/reader.service";
|
||||
import {LibraryType} from "../../_models/library/library";
|
||||
import {Device} from "../../_models/device/device";
|
||||
import {ActionService} from "../../_services/action.service";
|
||||
|
||||
@Component({
|
||||
selector: 'app-chapter-card',
|
||||
|
@ -59,6 +61,7 @@ export class ChapterCardComponent implements OnInit {
|
|||
public readonly imageService = inject(ImageService);
|
||||
public readonly bulkSelectionService = inject(BulkSelectionService);
|
||||
private readonly downloadService = inject(DownloadService);
|
||||
private readonly actionService = inject(ActionService);
|
||||
private readonly messageHub = inject(MessageHubService);
|
||||
private readonly accountService = inject(AccountService);
|
||||
private readonly scrollService = inject(ScrollService);
|
||||
|
@ -183,6 +186,12 @@ export class ChapterCardComponent implements OnInit {
|
|||
return; // Don't propagate the download from a card
|
||||
}
|
||||
|
||||
if (action.action == Action.SendTo) {
|
||||
const device = (action._extra!.data as Device);
|
||||
this.actionService.sendToDevice([this.chapter.id], device);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof action.callback === 'function') {
|
||||
action.callback(action, this.chapter);
|
||||
}
|
||||
|
|
|
@ -30,8 +30,10 @@
|
|||
<div class="row g-0 mt-3 pb-3 ms-md-2 me-md-2">
|
||||
<div class="input-group col-auto me-md-2" style="width: 83%">
|
||||
<label class="input-group-text" for="load-image">{{t('url-label')}}</label>
|
||||
<input type="text" autofocus autocomplete="off" class="form-control" formControlName="coverImageUrl" placeholder="https://" id="load-image" class="form-control">
|
||||
<button class="btn btn-outline-secondary" type="button" id="load-image-addon" (click)="loadImageFromUrl(); mode='all';" [disabled]="(form.get('coverImageUrl')?.value).length === 0">
|
||||
<input type="text" autofocus autocomplete="off" class="form-control" formControlName="coverImageUrl" placeholder="https://" id="load-image">
|
||||
<button class="btn btn-outline-secondary" type="button" id="load-image-addon"
|
||||
(click)="loadImageFromUrl(); mode='all';"
|
||||
[disabled]="(form.get('coverImageUrl')?.value).length === 0">
|
||||
{{t('load')}}
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
@ -1,8 +1,11 @@
|
|||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component, DestroyRef,
|
||||
EventEmitter, HostListener, inject,
|
||||
Component,
|
||||
DestroyRef,
|
||||
EventEmitter,
|
||||
HostListener,
|
||||
inject,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnInit,
|
||||
|
@ -18,7 +21,7 @@ import {SeriesService} from 'src/app/_services/series.service';
|
|||
import {ActionService} from 'src/app/_services/action.service';
|
||||
import {EditSeriesModalComponent} from '../_modals/edit-series-modal/edit-series-modal.component';
|
||||
import {RelationKind} from 'src/app/_models/series-detail/relation-kind';
|
||||
import {CommonModule} from "@angular/common";
|
||||
import {DecimalPipe} from "@angular/common";
|
||||
import {CardItemComponent} from "../card-item/card-item.component";
|
||||
import {RelationshipPipe} from "../../_pipes/relationship.pipe";
|
||||
import {Device} from "../../_models/device/device";
|
||||
|
@ -68,7 +71,9 @@ function deepClone(obj: any): any {
|
|||
@Component({
|
||||
selector: 'app-series-card',
|
||||
standalone: true,
|
||||
imports: [CommonModule, CardItemComponent, RelationshipPipe, CardActionablesComponent, DefaultValuePipe, DownloadIndicatorComponent, EntityTitleComponent, FormsModule, ImageComponent, NgbProgressbar, NgbTooltip, RouterLink, TranslocoDirective, SeriesFormatComponent],
|
||||
imports: [CardItemComponent, RelationshipPipe, CardActionablesComponent, DefaultValuePipe, DownloadIndicatorComponent,
|
||||
EntityTitleComponent, FormsModule, ImageComponent, NgbProgressbar, NgbTooltip, RouterLink, TranslocoDirective,
|
||||
SeriesFormatComponent, DecimalPipe],
|
||||
templateUrl: './series-card.component.html',
|
||||
styleUrls: ['./series-card.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
@ -264,6 +269,9 @@ export class SeriesCardComponent implements OnInit, OnChanges {
|
|||
case Action.RemoveFromOnDeck:
|
||||
this.seriesService.removeFromOnDeck(series.id).subscribe(() => this.reload.emit(series.id));
|
||||
break;
|
||||
case Action.Download:
|
||||
this.downloadService.download('series', this.series);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
|
|
@ -35,6 +35,8 @@ import {Volume} from "../../_models/volume";
|
|||
import {UtilityService} from "../../shared/_services/utility.service";
|
||||
import {LibraryType} from "../../_models/library/library";
|
||||
import {RelationshipPipe} from "../../_pipes/relationship.pipe";
|
||||
import {Device} from "../../_models/device/device";
|
||||
import {ActionService} from "../../_services/action.service";
|
||||
|
||||
@Component({
|
||||
selector: 'app-volume-card',
|
||||
|
@ -63,11 +65,11 @@ export class VolumeCardComponent implements OnInit {
|
|||
public readonly imageService = inject(ImageService);
|
||||
public readonly bulkSelectionService = inject(BulkSelectionService);
|
||||
private readonly downloadService = inject(DownloadService);
|
||||
private readonly actionService = inject(ActionService);
|
||||
private readonly messageHub = inject(MessageHubService);
|
||||
private readonly accountService = inject(AccountService);
|
||||
private readonly scrollService = inject(ScrollService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly actionFactoryService = inject(ActionFactoryService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly readerService = inject(ReaderService);
|
||||
protected readonly utilityService = inject(UtilityService);
|
||||
|
@ -196,6 +198,12 @@ export class VolumeCardComponent implements OnInit {
|
|||
return; // Don't propagate the download from a card
|
||||
}
|
||||
|
||||
if (action.action == Action.SendTo) {
|
||||
const device = (action._extra!.data as Device);
|
||||
this.actionService.sendToDevice(this.volume.chapters.map(c => c.id), device);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof action.callback === 'function') {
|
||||
action.callback(action, this.volume);
|
||||
}
|
||||
|
|
|
@ -163,7 +163,7 @@
|
|||
<a ngbNavLink>{{t('details-tab')}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
@defer (when activeTabId === TabID.Details; prefetch on idle) {
|
||||
<app-details-tab [metadata]="chapter" [genres]="chapter.genres" [tags]="chapter.tags"></app-details-tab>
|
||||
<app-details-tab [metadata]="chapter" [genres]="chapter.genres" [tags]="chapter.tags" [webLinks]="weblinks"></app-details-tab>
|
||||
}
|
||||
</ng-template>
|
||||
</li>
|
||||
|
|
|
@ -190,6 +190,7 @@ export class ChapterDetailComponent implements OnInit {
|
|||
series: Series | null = null;
|
||||
libraryType: LibraryType | null = null;
|
||||
hasReadingProgress = false;
|
||||
weblinks: Array<string> = [];
|
||||
activeTabId = TabID.Details;
|
||||
/**
|
||||
* This is the download we get from download service.
|
||||
|
@ -259,6 +260,7 @@ export class ChapterDetailComponent implements OnInit {
|
|||
|
||||
this.series = results.series;
|
||||
this.chapter = results.chapter;
|
||||
this.weblinks = this.chapter.webLinks.split(',');
|
||||
this.libraryType = results.libraryType;
|
||||
|
||||
this.themeService.setColorScape(this.chapter.primaryColor, this.chapter.secondaryColor);
|
||||
|
@ -281,7 +283,8 @@ export class ChapterDetailComponent implements OnInit {
|
|||
}
|
||||
}), takeUntilDestroyed(this.destroyRef)).subscribe();
|
||||
|
||||
this.showDetailsTab = hasAnyCast(this.chapter) || (this.chapter.genres || []).length > 0 || (this.chapter.tags || []).length > 0;
|
||||
this.showDetailsTab = hasAnyCast(this.chapter) || (this.chapter.genres || []).length > 0 ||
|
||||
(this.chapter.tags || []).length > 0 || this.chapter.webLinks.length > 0;
|
||||
this.isLoading = false;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
|
|
|
@ -171,18 +171,6 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
|
|||
}
|
||||
}
|
||||
|
||||
get ScrollingBlockHeight() {
|
||||
if (this.scrollingBlock === undefined) return 'calc(var(--vh)*100)';
|
||||
const navbar = this.document.querySelector('.navbar') as HTMLElement;
|
||||
if (navbar === null) return 'calc(var(--vh)*100)';
|
||||
|
||||
const companionHeight = this.companionBar!.nativeElement.offsetHeight;
|
||||
const navbarHeight = navbar.offsetHeight;
|
||||
const totalHeight = companionHeight + navbarHeight + 21; //21px to account for padding
|
||||
return 'calc(var(--vh)*100 - ' + totalHeight + 'px)';
|
||||
}
|
||||
|
||||
|
||||
constructor(@Inject(DOCUMENT) private document: Document) {
|
||||
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
|
||||
|
||||
|
@ -299,10 +287,16 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
|
|||
}
|
||||
switch (action.action) {
|
||||
case Action.Promote:
|
||||
this.collectionService.promoteMultipleCollections([this.collectionTag.id], true).subscribe();
|
||||
this.collectionService.promoteMultipleCollections([this.collectionTag.id], true).subscribe(() => {
|
||||
this.collectionTag.promoted = true;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
break;
|
||||
case Action.UnPromote:
|
||||
this.collectionService.promoteMultipleCollections([this.collectionTag.id], false).subscribe();
|
||||
this.collectionService.promoteMultipleCollections([this.collectionTag.id], false).subscribe(() => {
|
||||
this.collectionTag.promoted = false;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
break;
|
||||
case(Action.Edit):
|
||||
this.openEditCollectionTagModal(this.collectionTag);
|
||||
|
|
|
@ -16,7 +16,7 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
|||
import {CardItemComponent} from '../../cards/card-item/card-item.component';
|
||||
import {SeriesCardComponent} from '../../cards/series-card/series-card.component';
|
||||
import {CarouselReelComponent} from '../../carousel/_components/carousel-reel/carousel-reel.component';
|
||||
import {AsyncPipe, NgForOf, NgTemplateOutlet} from '@angular/common';
|
||||
import {AsyncPipe, NgTemplateOutlet} from '@angular/common';
|
||||
import {
|
||||
SideNavCompanionBarComponent
|
||||
} from '../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
|
||||
|
@ -51,7 +51,7 @@ enum StreamId {
|
|||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [SideNavCompanionBarComponent, RouterLink, CarouselReelComponent, SeriesCardComponent,
|
||||
CardItemComponent, AsyncPipe, TranslocoDirective, NgForOf, NgTemplateOutlet, LoadingComponent],
|
||||
CardItemComponent, AsyncPipe, TranslocoDirective, NgTemplateOutlet, LoadingComponent],
|
||||
})
|
||||
export class DashboardComponent implements OnInit {
|
||||
|
||||
|
|
|
@ -30,7 +30,7 @@ import {
|
|||
take,
|
||||
tap
|
||||
} from 'rxjs';
|
||||
import {ChangeContext, LabelType, NgxSliderModule, Options} from 'ngx-slider-v2';
|
||||
import {ChangeContext, LabelType, NgxSliderModule, Options} from '@angular-slider/ngx-slider';
|
||||
import {animate, state, style, transition, trigger} from '@angular/animations';
|
||||
import {FormBuilder, FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms';
|
||||
import {NgbModal, NgbProgressbar} from '@ng-bootstrap/ng-bootstrap';
|
||||
|
|
|
@ -21,7 +21,6 @@
|
|||
height="100vh"
|
||||
[(page)]="currentPage"
|
||||
[textLayer]="true"
|
||||
[useBrowserLocale]="true"
|
||||
[showHandToolButton]="true"
|
||||
[showOpenFileButton]="false"
|
||||
[showPrintButton]="false"
|
||||
|
|
|
@ -102,6 +102,7 @@
|
|||
{{t('cover-image-description')}}
|
||||
</p>
|
||||
<app-cover-image-chooser [(imageUrls)]="imageUrls"
|
||||
(imageUrlsChange)="handleUploadByUrl($event)"
|
||||
(imageSelected)="updateSelectedIndex($event)"
|
||||
(selectedBase64Url)="updateSelectedImage($event)"
|
||||
[showReset]="person.coverImageLocked"
|
||||
|
|
|
@ -102,7 +102,9 @@ export class EditPersonModalComponent implements OnInit {
|
|||
save() {
|
||||
const apis = [];
|
||||
|
||||
if (this.touchedCoverImage || this.coverImageReset) {
|
||||
const hasCoverChanges = this.touchedCoverImage || this.coverImageReset;
|
||||
|
||||
if (hasCoverChanges) {
|
||||
apis.push(this.uploadService.updatePersonCoverImage(this.person.id, this.selectedCover, !this.coverImageReset));
|
||||
}
|
||||
|
||||
|
@ -121,10 +123,16 @@ export class EditPersonModalComponent implements OnInit {
|
|||
apis.push(this.personService.updatePerson(person));
|
||||
|
||||
forkJoin(apis).subscribe(_ => {
|
||||
this.modal.close({success: true, coverImageUpdate: false, person: person});
|
||||
this.modal.close({success: true, coverImageUpdate: hasCoverChanges, person: person});
|
||||
});
|
||||
}
|
||||
|
||||
handleUploadByUrl(urls: Array<string>) {
|
||||
this.selectedCover = urls[0];
|
||||
this.touchedCoverImage = true;
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
updateSelectedIndex(index: number) {
|
||||
this.editForm.patchValue({
|
||||
coverImageIndex: index
|
||||
|
|
|
@ -28,7 +28,7 @@ import {BadgeExpanderComponent} from '../../../shared/badge-expander/badge-expan
|
|||
import {ReadMoreComponent} from '../../../shared/read-more/read-more.component';
|
||||
import {NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {ImageComponent} from '../../../shared/image/image.component';
|
||||
import {AsyncPipe, DatePipe, DecimalPipe, NgClass, NgIf} from '@angular/common';
|
||||
import {AsyncPipe, DatePipe, DecimalPipe, NgClass} from '@angular/common';
|
||||
import {
|
||||
SideNavCompanionBarComponent
|
||||
} from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
|
||||
|
@ -174,7 +174,23 @@ export class ReadingListDetailComponent implements OnInit {
|
|||
this.readingList = rl;
|
||||
this.readingListSummary = (this.readingList.summary === null ? '' : this.readingList.summary).replace(/\n/g, '<br>');
|
||||
this.cdRef.markForCheck();
|
||||
})
|
||||
});
|
||||
});
|
||||
break;
|
||||
case Action.Promote:
|
||||
this.actionService.promoteMultipleReadingLists([this.readingList!], true, () => {
|
||||
if (this.readingList) {
|
||||
this.readingList.promoted = true;
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
});
|
||||
break;
|
||||
case Action.UnPromote:
|
||||
this.actionService.promoteMultipleReadingLists([this.readingList!], false, () => {
|
||||
if (this.readingList) {
|
||||
this.readingList.promoted = false;
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
|
|
@ -438,8 +438,9 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
|||
}
|
||||
|
||||
if (this.currentlyReadingChapter.minNumber === LooseLeafOrDefaultNumber) {
|
||||
return translate(chapterLocaleKey, {num: vol[0].minNumber});
|
||||
return translate(volumeLocaleKey, {num: vol[0].minNumber});
|
||||
}
|
||||
|
||||
return translate(volumeLocaleKey, {num: vol[0].minNumber})
|
||||
+ ' ' + translate(chapterLocaleKey, {num: this.currentlyReadingChapter.minNumber});
|
||||
}
|
||||
|
@ -872,7 +873,8 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
|||
this.showVolumeTab = this.shouldShowVolumeTab();
|
||||
this.showStorylineTab = this.shouldShowStorylineTab();
|
||||
this.showChapterTab = this.shouldShowChaptersTab();
|
||||
this.showDetailsTab = hasAnyCast(this.seriesMetadata) || (this.seriesMetadata?.genres || []).length > 0 || (this.seriesMetadata?.tags || []).length > 0;
|
||||
this.showDetailsTab = hasAnyCast(this.seriesMetadata) || (this.seriesMetadata?.genres || []).length > 0
|
||||
|| (this.seriesMetadata?.tags || []).length > 0 || (this.seriesMetadata?.webLinks || []).length > 0;
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
<div class="row g-0">
|
||||
<div class="col-10">
|
||||
<h6 class="section-title">
|
||||
@if(labelId) {
|
||||
@if (labelId) {
|
||||
<label class="reset-label" [for]="labelId">{{title}}</label>
|
||||
} @else {
|
||||
{{title}}
|
||||
|
|
|
@ -85,6 +85,7 @@ export class SettingItemComponent {
|
|||
toggleEditMode() {
|
||||
|
||||
if (!this.toggleOnViewClick) return;
|
||||
if (!this.canEdit) return;
|
||||
|
||||
this.isEditMode = !this.isEditMode;
|
||||
this.editMode.emit(this.isEditMode);
|
||||
|
|
|
@ -1,10 +1,4 @@
|
|||
import {
|
||||
HttpEvent,
|
||||
HttpEventType,
|
||||
HttpHeaders,
|
||||
HttpProgressEvent,
|
||||
HttpResponse
|
||||
} from "@angular/common/http";
|
||||
import { HttpEvent, HttpEventType, HttpHeaders, HttpProgressEvent, HttpResponse } from "@angular/common/http";
|
||||
import { Observable } from "rxjs";
|
||||
import { scan } from "rxjs/operators";
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@ import {FilterStatement} from "../../_models/metadata/v2/filter-statement";
|
|||
import {FilterCombination} from "../../_models/metadata/v2/filter-combination";
|
||||
import {FilterField} from "../../_models/metadata/v2/filter-field";
|
||||
import {FilterComparison} from "../../_models/metadata/v2/filter-comparison";
|
||||
import {HttpClient} from "@angular/common/http";
|
||||
import { HttpClient } from "@angular/common/http";
|
||||
import {TextResonse} from "../../_types/text-response";
|
||||
import {environment} from "../../../environments/environment";
|
||||
import {map, tap} from "rxjs/operators";
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
<ng-container *transloco="let t; read:'change-age-restriction'">
|
||||
@if (user) {
|
||||
<app-setting-item [title]="t('age-restriction-label')">
|
||||
<app-setting-item [title]="t('age-restriction-label')" [canEdit]="accountService.hasChangeAgeRestrictionRole(user) || accountService.hasAdminRole(user)">
|
||||
<ng-template #view>
|
||||
<span class="col-12">{{user.ageRestriction.ageRating | ageRating }}
|
||||
<span class="col-12" [ngClass]="{'disabled': !accountService.hasChangeAgeRestrictionRole(user) && !accountService.hasAdminRole(user)}">{{user.ageRestriction.ageRating | ageRating }}
|
||||
@if (user.ageRestriction.ageRating !== AgeRating.NotApplicable && user.ageRestriction.includeUnknowns) {
|
||||
<span class="ms-1 me-1">+</span> {{t('unknowns')}}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
.disabled {
|
||||
color: var(--btn-disabled-text-color);
|
||||
}
|
|
@ -16,7 +16,7 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
|||
import { AgeRatingPipe } from '../../_pipes/age-rating.pipe';
|
||||
import { RestrictionSelectorComponent } from '../restriction-selector/restriction-selector.component';
|
||||
import { NgbCollapse } from '@ng-bootstrap/ng-bootstrap';
|
||||
import {AsyncPipe, NgForOf, NgIf} from '@angular/common';
|
||||
import {AsyncPipe, NgClass, NgForOf, NgIf} from '@angular/common';
|
||||
import {translate, TranslocoDirective} from "@jsverse/transloco";
|
||||
import {SettingTitleComponent} from "../../settings/_components/setting-title/setting-title.component";
|
||||
import {ReactiveFormsModule} from "@angular/forms";
|
||||
|
@ -29,11 +29,12 @@ import {SettingItemComponent} from "../../settings/_components/setting-item/sett
|
|||
styleUrls: ['./change-age-restriction.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [NgbCollapse, RestrictionSelectorComponent, AsyncPipe, AgeRatingPipe, TranslocoDirective, SettingTitleComponent, NgForOf, NgIf, ReactiveFormsModule, Select2Module, SettingItemComponent]
|
||||
imports: [NgbCollapse, RestrictionSelectorComponent, AsyncPipe, AgeRatingPipe, TranslocoDirective, SettingTitleComponent,
|
||||
ReactiveFormsModule, SettingItemComponent, NgClass]
|
||||
})
|
||||
export class ChangeAgeRestrictionComponent implements OnInit {
|
||||
|
||||
private readonly accountService = inject(AccountService);
|
||||
protected readonly accountService = inject(AccountService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
@ -64,6 +65,8 @@ export class ChangeAgeRestrictionComponent implements OnInit {
|
|||
|
||||
updateRestrictionSelection(restriction: AgeRestriction) {
|
||||
this.selectedRestriction = restriction;
|
||||
|
||||
this.saveForm();
|
||||
}
|
||||
|
||||
resetForm() {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import {Injectable} from "@angular/core";
|
||||
import {HttpClient} from "@angular/common/http";
|
||||
import { HttpClient } from "@angular/common/http";
|
||||
import {Translation, TranslocoLoader} from "@jsverse/transloco";
|
||||
import cacheBusting from 'i18n-cache-busting.json'; // allowSyntheticDefaultImports must be true
|
||||
|
||||
|
|
|
@ -12,7 +12,7 @@ import { AppRoutingModule } from './app/app-routing.module';
|
|||
import { Title, BrowserModule, bootstrapApplication } from '@angular/platform-browser';
|
||||
import { JwtInterceptor } from './app/_interceptors/jwt.interceptor';
|
||||
import { ErrorInterceptor } from './app/_interceptors/error.interceptor';
|
||||
import {HTTP_INTERCEPTORS, withInterceptorsFromDi, provideHttpClient} from '@angular/common/http';
|
||||
import { HTTP_INTERCEPTORS, withInterceptorsFromDi, provideHttpClient } from '@angular/common/http';
|
||||
import {
|
||||
provideTransloco, TranslocoConfig,
|
||||
TranslocoService
|
||||
|
|
|
@ -53,7 +53,6 @@
|
|||
--default-state-scrollbar: transparent;
|
||||
--text-muted-color: hsla(0,0%,100%,.45);
|
||||
|
||||
|
||||
/* Theming colors that performs a gradient for background. Can be disabled else automatically applied based on cover image colors.
|
||||
* --colorscape-primary-color and the alpha variants will be updated in real time. the default variant is fixed and represents the default state and should
|
||||
* match the non-default/alpha on launch.
|
||||
|
|
|
@ -1,188 +0,0 @@
|
|||
:root .bg-e-ink {
|
||||
--color-scheme: light;
|
||||
--primary-color: black;
|
||||
--primary-color-dark-shade: #3B9E76;
|
||||
--primary-color-darker-shade: #338A67;
|
||||
--primary-color-darkest-shade: #25624A;
|
||||
--error-color: #ff4136;
|
||||
--bs-body-bg: #fff;
|
||||
--body-text-color: black;
|
||||
--btn-icon-filter: invert(1) grayscale(100%) brightness(200%);
|
||||
|
||||
|
||||
/* Navbar */
|
||||
--navbar-bg-color: black;
|
||||
--navbar-text-color: white;
|
||||
--navbar-fa-icon-color: white;
|
||||
--navbar-btn-hover-outline-color: rgba(255, 255, 255, 1);
|
||||
|
||||
/* Inputs */
|
||||
--input-bg-color: #fff;
|
||||
--input-focused-border-color: #ccc;
|
||||
--input-bg-readonly-color: rgba(0,0,0,0.2);
|
||||
--input-placeholder-color: #aeaeae;
|
||||
--input-border-color: #ccc;
|
||||
--input-range-color: var(--primary-color);
|
||||
--input-range-active-color: var(--primary-color-darker-shade);
|
||||
|
||||
/* Buttons */
|
||||
--btn-primary-text-color: black;
|
||||
--btn-primary-bg-color: white;
|
||||
--btn-primary-border-color: black;
|
||||
--btn-primary-hover-text-color: white;
|
||||
--btn-primary-hover-bg-color: black;
|
||||
--btn-primary-hover-border-color: black;
|
||||
--btn-alt-bg-color: #424c72;
|
||||
--btn-alt-border-color: #444f75;
|
||||
--btn-alt-hover-bg-color: #3b4466;
|
||||
--btn-alt-focus-bg-color: #343c59;
|
||||
--btn-alt-focus-boxshadow-color: rgb(68 79 117 / 50%);
|
||||
--btn-fa-icon-color: black;
|
||||
--btn-disabled-bg-color: #020202;
|
||||
--btn-disabled-text-color: white;
|
||||
--btn-disabled-border-color: #6c757d;
|
||||
--btn-fa-icon-color: black;
|
||||
|
||||
|
||||
/* Nav */
|
||||
--nav-link-active-text-color: white;
|
||||
--nav-link-bg-color: black;
|
||||
--nav-link-text-color: black;
|
||||
--nav-link-hover-text-color: var(--primary-color);
|
||||
--nav-tab-border-hover-color: transparent;
|
||||
--nav-tab-active-text-color: white;
|
||||
--nav-tab-text-color: var(--body-text-color);
|
||||
--nav-tab-bg-color: black;
|
||||
--nav-tab-hover-border-color: black;
|
||||
|
||||
/* Checkboxes */
|
||||
--checkbox-checked-bg-color: var(--primary-color);
|
||||
--checkbox-bg-color: white;
|
||||
--checkbox-border-color: var(--primary-color);
|
||||
--checkbox-focus-border-color: var(--input-border-color);
|
||||
|
||||
/* Tagbadge */
|
||||
--tagbadge-bg-color: #c9c9c9;
|
||||
|
||||
/* Side Nav */
|
||||
--side-nav-bg-color: rgba(255,255,255,0.6);
|
||||
--side-nav-mobile-bg-color: rgb(255,255,255);
|
||||
--side-nav-openclose-transition: 1ms;
|
||||
--side-nav-box-shadow: none;
|
||||
--side-nav-mobile-box-shadow: 3px 0em 5px 10em rgb(0 0 0 / 50%);
|
||||
--side-nav-hover-text-color: white;
|
||||
--side-nav-hover-bg-color: black;
|
||||
--side-nav-text-color: black;
|
||||
--side-nav-border-radius: 5px;
|
||||
--side-nav-border: none;
|
||||
--side-nav-border-closed: none;
|
||||
--side-nav-border-transition: 0s;
|
||||
--side-nav-bg-color-transition: 0s;
|
||||
--side-nav-companion-bar-transistion: 0s;
|
||||
--side-nav-closed-bg-color: transparent;
|
||||
--side-nav-item-active-color: var(--primary-color);
|
||||
--side-nav-active-bg-color: rgba(0,0,0,0.5);
|
||||
--side-nav-overlay-color: rgba(0,0,0,1);
|
||||
--side-nav-item-active-text-color: white;
|
||||
|
||||
/* Toasts */
|
||||
--toast-success-bg-color: rgba(74, 198, 148, 0.9);
|
||||
--toast-error-bg-color: #BD362F;
|
||||
--toast-info-bg-color: #2F96B4;
|
||||
--toast-warning-bg-color: #F89406;
|
||||
|
||||
/* Rating star */
|
||||
--ratingstar-star-empty: #b0c4de;
|
||||
--ratingstar-star-filled: var(--primary-color);
|
||||
|
||||
/* Global */
|
||||
--accent-bg-color: rgba(206, 206, 206, 0.5);
|
||||
--accent-text-color: black;
|
||||
--hr-color: rgba(239, 239, 239, 0.125);
|
||||
--grid-breakpoints-xs: $grid-breakpoint-xs;
|
||||
--grid-breakpoints-sm: $grid-breakpoint-sm;
|
||||
--grid-breakpoints-md: $grid-breakpoint-md;
|
||||
--grid-breakpoints-lg: $grid-breakpoint-lg;
|
||||
--grid-breakpoints-xl: $grid-breakpoint-xl;
|
||||
--body-font-family: "EBGaramond", "Helvetica Neue", sans-serif;
|
||||
--brand-font-family: "Spartan", sans-serif;
|
||||
|
||||
/* Breadcrumb */
|
||||
--breadcrumb-bg-color: #eaeaea;
|
||||
--breadcrumb-item-text-color: var(--body-text-color);
|
||||
|
||||
/* Card */
|
||||
--card-text-color: #000;
|
||||
--card-border-width: 0 1px 1px 1px;
|
||||
--card-border-style: solid;
|
||||
--card-border-color: #ccc;
|
||||
--card-progress-bar-color: var(--primary-color);
|
||||
--card-overlay-hover-bg-color: rgba(0, 0, 0, 0.2);
|
||||
|
||||
/* List items */
|
||||
--list-group-item-text-color: var(--body-text-color);
|
||||
--list-group-item-bg-color: white;
|
||||
--list-group-hover-text-color: black;
|
||||
--list-group-hover-bg-color: #eaeaea;
|
||||
--list-group-item-border-color: rgba(239, 239, 239, 0.125);
|
||||
--list-group-active-border-color: none;
|
||||
|
||||
/* Dropdown */
|
||||
--dropdown-item-hover-text-color: white;
|
||||
--dropdown-item-hover-bg-color: var(--primary-color);
|
||||
--dropdown-overlay-color: rgba(0,0,0,0.5);
|
||||
--dropdown-item-bg-color: white;
|
||||
|
||||
/* Manga Reader */
|
||||
--manga-reader-overlay-filter: none;
|
||||
--manga-reader-overlay-bg-color: rgba(0,0,0,0.5);
|
||||
--manga-reader-overlay-text-color: white;
|
||||
--manga-reader-bg-color: black;
|
||||
--manga-reader-next-highlight-bg-color: transparent;
|
||||
--manga-reader-prev-highlight-bg-color: transparent;
|
||||
|
||||
/* Radios */
|
||||
--radio-accent-color: var(--primary-color);
|
||||
--radio-hover-accent-color: var(--primary-color-dark-shade);
|
||||
|
||||
/* Carousel */
|
||||
--carousel-header-text-color: black;
|
||||
--carousel-header-text-decoration: none;
|
||||
--carousel-hover-header-text-decoration: none;
|
||||
|
||||
/** Drawer */
|
||||
--drawer-bg-color: white;
|
||||
--drawer-text-color: black;
|
||||
|
||||
/* Pagination */
|
||||
--pagination-active-link-border-color: var(--primary-color);
|
||||
--pagination-active-link-bg-color: var(--primary-color);
|
||||
--pagination-active-link-text-color: white;
|
||||
--pagination-link-border-color: rgba(239, 239, 239, 1);
|
||||
--pagination-link-text-color: black;
|
||||
--pagination-link-bg-color: white;
|
||||
--pagination-focus-border-color: var(--primary-color);
|
||||
--pagination-link-hover-color: var(--primary-color);
|
||||
|
||||
/** Event Widget */
|
||||
--event-widget-bg-color: white;
|
||||
--event-widget-item-bg-color: lightgrey;
|
||||
--event-widget-text-color: black;
|
||||
--event-widget-item-border-color: lightgrey;
|
||||
--event-widget-border-color: lightgrey;
|
||||
|
||||
/* Popover */
|
||||
--popover-body-bg-color: var(--navbar-bg-color);
|
||||
--popover-body-text-color: var(--navbar-text-color);
|
||||
--popover-outerarrow-color: lightgrey;
|
||||
--popover-arrow-color: lightgrey;
|
||||
--popover-bg-color: lightgrey;
|
||||
--popover-border-color: lightgrey;
|
||||
|
||||
/* Search */
|
||||
--search-result-text-lite-color: rgba(0,0,0,1);
|
||||
|
||||
/* Bulk Selection */
|
||||
--bulk-selection-text-color: white;
|
||||
--bulk-selection-highlight-text-color: white;
|
||||
}
|
|
@ -1,243 +0,0 @@
|
|||
/* Default styles for Kavita */
|
||||
:root {
|
||||
--color-scheme: dark;
|
||||
--primary-color: #4ac694;
|
||||
--primary-color-dark-shade: #3B9E76;
|
||||
--primary-color-darker-shade: #338A67;
|
||||
--primary-color-darkest-shade: #25624A;
|
||||
--error-color: #BD362F;
|
||||
--bs-body-bg: #343a40;
|
||||
--body-text-color: #efefef;
|
||||
--btn-icon-filter: invert(1) grayscale(100%) brightness(200%);
|
||||
--primary-color-scrollbar: rgba(74,198,148,0.75);
|
||||
|
||||
|
||||
/* Navbar */
|
||||
--navbar-bg-color: black;
|
||||
--navbar-text-color: white;
|
||||
--navbar-fa-icon-color: white;
|
||||
--navbar-btn-hover-outline-color: rgba(255, 255, 255, 1);
|
||||
|
||||
/* Inputs */
|
||||
--input-bg-color: #343a40;
|
||||
--input-bg-readonly-color: #434648;
|
||||
--input-focused-border-color: #ccc;
|
||||
--input-text-color: #fff;
|
||||
--input-placeholder-color: #aeaeae;
|
||||
--input-border-color: #ccc;
|
||||
--input-focus-boxshadow-color: rgb(255 255 255 / 50%);
|
||||
|
||||
/* Buttons */
|
||||
--btn-focus-boxshadow-color: rgb(255 255 255 / 50%);
|
||||
--btn-primary-text-color: white;
|
||||
--btn-primary-bg-color: var(--primary-color);
|
||||
--btn-primary-border-color: var(--primary-color);
|
||||
--btn-primary-hover-text-color: white;
|
||||
--btn-primary-hover-bg-color: var(--primary-color-darker-shade);
|
||||
--btn-primary-hover-border-color: var(--primary-color-darker-shade);
|
||||
--btn-alt-bg-color: #424c72;
|
||||
--btn-alt-border-color: #444f75;
|
||||
--btn-alt-hover-bg-color: #3b4466;
|
||||
--btn-alt-focus-bg-color: #343c59;
|
||||
--btn-alt-focus-boxshadow-color: rgb(255 255 255 / 50%);
|
||||
--btn-fa-icon-color: white;
|
||||
--btn-disabled-bg-color: #343a40;
|
||||
--btn-disabled-text-color: white;
|
||||
--btn-disabled-border-color: #6c757d;
|
||||
--bs-btn-disabled-border-color: transparent;
|
||||
|
||||
/* Nav (Tabs) */
|
||||
--nav-tab-border-color: rgba(44, 118, 88, 0.7);
|
||||
--nav-tab-text-color: var(--body-text-color);
|
||||
--nav-tab-bg-color: var(--primary-color);
|
||||
--nav-tab-hover-border-color: var(--primary-color);
|
||||
--nav-tab-active-text-color: white;
|
||||
--nav-tab-border-hover-color: transparent;
|
||||
--nav-tab-hover-text-color: var(--body-text-color);
|
||||
--nav-tab-hover-bg-color: transparent;
|
||||
--nav-tab-border-top: rgba(44, 118, 88, 0.7);
|
||||
--nav-tab-border-left: rgba(44, 118, 88, 0.7);
|
||||
--nav-tab-border-bottom: rgba(44, 118, 88, 0.7);
|
||||
--nav-tab-border-right: rgba(44, 118, 88, 0.7);
|
||||
--nav-tab-hover-border-top: rgba(44, 118, 88, 0.7);
|
||||
--nav-tab-hover-border-left: rgba(44, 118, 88, 0.7);
|
||||
--nav-tab-hover-border-bottom: var(--bs-body-bg);
|
||||
--nav-tab-hover-border-right: rgba(44, 118, 88, 0.7);
|
||||
--nav-tab-active-hover-bg-color: var(--primary-color);
|
||||
--nav-link-bg-color: var(--primary-color);
|
||||
--nav-link-active-text-color: white;
|
||||
--nav-link-text-color: white;
|
||||
|
||||
/* Header */
|
||||
--nav-header-text-color: white;
|
||||
--nav-header-bg-color: rgb(22, 27, 34);
|
||||
|
||||
/* Toasts */
|
||||
--toast-success-bg-color: rgba(59, 158, 118, 0.9);
|
||||
--toast-error-bg-color: #BD362F;
|
||||
--toast-info-bg-color: #2F96B4;
|
||||
--toast-warning-bg-color: #F89406;
|
||||
|
||||
/* Checkboxes/Switch */
|
||||
--checkbox-checked-bg-color: var(--primary-color);
|
||||
--checkbox-border-color: var(--input-focused-border-color);
|
||||
--checkbox-focus-border-color: var(--primary-color);
|
||||
--checkbox-focus-boxshadow-color: rgb(255 255 255 / 50%);
|
||||
|
||||
/* Tag Badge */
|
||||
--tagbadge-border-color: rgba(239, 239, 239, 0.125);
|
||||
--tagbadge-text-color: var(--body-text-color);
|
||||
--tagbadge-bg-color: var(--nav-tab-hover-bg-color);
|
||||
--tagbadge-filled-border-color: rgba(239, 239, 239, 0.125);
|
||||
--tagbadge-filled-text-color: var(--body-text-color);
|
||||
--tagbadge-filled-bg-color: var(--primary-color);
|
||||
|
||||
/* Side Nav */
|
||||
--side-nav-bg-color: rgba(0,0,0,0.2);
|
||||
--side-nav-mobile-bg-color: rgb(25,26,28);
|
||||
--side-nav-openclose-transition: 0.15s ease-in-out;
|
||||
--side-nav-box-shadow: rgba(0,0,0,0.5);
|
||||
--side-nav-mobile-box-shadow: 3px 0em 5px 10em rgb(0 0 0 / 50%);
|
||||
--side-nav-hover-text-color: white;
|
||||
--side-nav-hover-bg-color: black;
|
||||
--side-nav-text-color: white;
|
||||
--side-nav-border-radius: 5px;
|
||||
--side-nav-border: none;
|
||||
--side-nav-border-closed: none;
|
||||
--side-nav-border-transition: 0.5s ease-in-out;
|
||||
--side-nav-companion-bar-transistion: 0.15s linear;
|
||||
--side-nav-bg-color-transition: 0.5s ease-in-out;
|
||||
--side-nav-closed-bg-color: transparent;
|
||||
--side-nav-item-active-color: var(--primary-color);
|
||||
--side-nav-item-active-text-color: white;
|
||||
--side-nav-active-bg-color: rgba(0,0,0,0.5);
|
||||
--side-nav-overlay-color: rgba(0,0,0,0.5);
|
||||
|
||||
|
||||
/* List items */
|
||||
--list-group-item-text-color: var(--body-text-color); /*rgba(74, 198, 148, 0.9)*/
|
||||
--list-group-item-bg-color: #343a40;
|
||||
--list-group-item-border-color: rgba(239, 239, 239, 0.125);
|
||||
--list-group-hover-text-color: white;
|
||||
--list-group-hover-bg-color: rgb(22, 27, 34);
|
||||
--list-group-active-border-color: none;
|
||||
|
||||
/* Popover */
|
||||
--popover-body-bg-color: var(--navbar-bg-color);
|
||||
--popover-body-text-color: var(--navbar-text-color);
|
||||
--popover-outerarrow-color: transparent;
|
||||
--popover-arrow-color: transparent;
|
||||
--popover-bg-color: black;
|
||||
--popover-border-color: black;
|
||||
|
||||
/* Pagination */
|
||||
--pagination-active-link-border-color: var(--primary-color);
|
||||
--pagination-active-link-bg-color: var(--primary-color);
|
||||
--pagination-active-link-text-color: white;
|
||||
--pagination-link-border-color: rgba(239, 239, 239, 0.125);
|
||||
--pagination-link-text-color: white;
|
||||
--pagination-link-bg-color: rgba(1, 4, 9, 0.5);
|
||||
--pagination-focus-border-color: var(--primary-color);
|
||||
--pagination-link-hover-color: var(--primary-color);
|
||||
|
||||
/* Progress Bar */
|
||||
--progress-striped-animated-color: linear-gradient(45deg, rgba(74,198,148, 0.75) 25%, rgba(51, 138, 103, 0.75) 25%, rgba(51, 138, 103, 0.75) 50%, rgba(74,198,148, 0.75) 50%, rgba(74,198,148, 0.75) 75%, rgba(51, 138, 103, 0.75) 75%, rgba(51, 138, 103, 0.75));
|
||||
--progress-bg-color: var(--nav-header-bg-color);
|
||||
--progress-bar-color: var(--primary-color-dark-shade);
|
||||
|
||||
/* Dropdown */
|
||||
--dropdown-item-hover-text-color: white;
|
||||
--dropdown-item-hover-bg-color: var(--primary-color-dark-shade);
|
||||
--dropdown-item-text-color: var(--navbar-text-color);
|
||||
--dropdown-item-bg-color: var(--navbar-bg-color);
|
||||
--dropdown-overlay-color: rgba(0,0,0,0.5);
|
||||
|
||||
/* Accordion */
|
||||
--accordion-header-text-color: rgba(74, 198, 148, 0.9);
|
||||
--accordion-header-bg-color: rgba(52, 60, 70, 0.5);
|
||||
--accordion-body-bg-color: #292929;
|
||||
--accordion-body-border-color: rgba(239, 239, 239, 0.125);
|
||||
--accordion-body-text-color: var(--body-text-color);
|
||||
--accordion-header-collapsed-text-color: rgba(74, 198, 148, 0.9);
|
||||
--accordion-header-collapsed-bg-color: #292929;
|
||||
--accordion-button-focus-border-color: unset;
|
||||
--accordion-button-focus-box-shadow: unset;
|
||||
--accordion-active-body-bg-color: #292929;
|
||||
--accordion-body-box-shadow: none;
|
||||
|
||||
/* Breadcrumb */
|
||||
--breadcrumb-bg-color: #292d32;
|
||||
--breadcrumb-item-text-color: var(--body-text-color);
|
||||
|
||||
/* Rating star */
|
||||
--ratingstar-color: white;
|
||||
--ratingstar-star-empty: #b0c4de;
|
||||
--ratingstar-star-filled: var(--primary-color);
|
||||
|
||||
/* Global */
|
||||
--hr-color: rgba(239, 239, 239, 0.125);
|
||||
--accent-bg-color: rgba(1, 4, 9, 0.5);
|
||||
--accent-text-color: lightgrey;
|
||||
--grid-breakpoints-xs: $grid-breakpoint-xs;
|
||||
--grid-breakpoints-sm: $grid-breakpoint-sm;
|
||||
--grid-breakpoints-md: $grid-breakpoint-md;
|
||||
--grid-breakpoints-lg: $grid-breakpoint-lg;
|
||||
--grid-breakpoints-xl: $grid-breakpoint-xl;
|
||||
--body-font-family: "EBGaramond", "Helvetica Neue", sans-serif;
|
||||
--brand-font-family: "Spartan", sans-serif;
|
||||
|
||||
/* Card */
|
||||
--card-bg-color: rgba(22,27,34,0.5);
|
||||
--card-text-color: var(--body-text-color);
|
||||
--card-border-width: 0 1px 1px 1px;
|
||||
--card-border-style: solid;
|
||||
--card-border-color: transparent;
|
||||
--card-progress-bar-color: var(--primary-color);
|
||||
--card-overlay-bg-color: rgba(0, 0, 0, 0);
|
||||
--card-overlay-hover-bg-color: rgba(0, 0, 0, 0.2);
|
||||
|
||||
/* Slider */
|
||||
--slider-text-color: white;
|
||||
--input-range-color: var(--primary-color);
|
||||
--input-range-active-color: var(--primary-color-darker-shade);
|
||||
|
||||
/* Manga Reader */
|
||||
--manga-reader-overlay-filter: blur(10px);
|
||||
--manga-reader-overlay-bg-color: rgba(0,0,0,0.5);
|
||||
--manga-reader-overlay-text-color: white;
|
||||
--manga-reader-bg-color: black;
|
||||
--manga-reader-next-highlight-bg-color: rgba(65, 225, 100, 0.5);
|
||||
--manga-reader-prev-highlight-bg-color: rgba(65, 105, 225, 0.5);
|
||||
|
||||
/* Radios */
|
||||
--radio-accent-color: var(--primary-color);
|
||||
--radio-hover-accent-color: var(--primary-color);
|
||||
--radio-focus-boxshadow-color: rgb(255 255 255 / 50%);
|
||||
|
||||
/* Carousel */
|
||||
--carousel-header-text-color: var(--body-text-color);
|
||||
--carousel-header-text-decoration: none;
|
||||
--carousel-hover-header-text-decoration: none;
|
||||
|
||||
/** Drawer */
|
||||
--drawer-background-color: black; // TODO: Remove this for bg
|
||||
--drawer-bg-color: #292929;
|
||||
--drawer-text-color: white;
|
||||
|
||||
/** Event Widget */
|
||||
--event-widget-bg-color: rgb(1, 4, 9);
|
||||
--event-widget-item-bg-color: rgb(1, 4, 9);
|
||||
--event-widget-text-color: var(--body-text-color);
|
||||
--event-widget-item-border-color: rgba(53, 53, 53, 0.5);
|
||||
--event-widget-border-color: rgba(1, 4, 9, 0.5);
|
||||
|
||||
/* Search */
|
||||
--search-result-text-lite-color: initial;
|
||||
|
||||
/* Bulk Selection */
|
||||
--bulk-selection-text-color: var(--navbar-text-color);
|
||||
--bulk-selection-highlight-text-color: var(--primary-color);
|
||||
|
||||
/* List Card Item */
|
||||
--card-list-item-bg-color: linear-gradient(180deg, rgba(0,0,0,0.15) 0%, rgba(0,0,0,0.15) 1%, rgba(0,0,0,0) 100%);
|
||||
}
|
|
@ -6,20 +6,19 @@
|
|||
"outDir": "./dist/out-tsc",
|
||||
"removeComments": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": false,
|
||||
"downlevelIteration": true,
|
||||
"experimentalDecorators": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"useDefineForClassFields": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"dom"
|
||||
|
|
1412
openapi.json
1412
openapi.json
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue