Merge branch 'refs/heads/develop' into feature/user-fonts

# Conflicts:
#	API/Services/TaskScheduler.cs
This commit is contained in:
Fesaa 2024-07-08 21:19:07 +02:00
commit 1f2ea8f59d
No known key found for this signature in database
GPG key ID: 9EA789150BEE0E27
100 changed files with 3553 additions and 1416 deletions

175
.github/workflows/release-workflow.yml vendored Normal file
View file

@ -0,0 +1,175 @@
name: Stable Workflow
on:
push:
branches: ['release/**']
pull_request:
branches: [ 'develop' ]
types: [ closed ]
workflow_dispatch:
jobs:
debug:
runs-on: ubuntu-latest
steps:
- name: Debug Info
run: |
echo "Event Name: ${{ github.event_name }}"
echo "Ref: ${{ github.ref }}"
echo "Not Contains Release: ${{ !contains(github.head_ref, 'release') }}"
echo "Matches Develop: ${{ github.ref == 'refs/heads/develop' }}"
if_merged:
if: github.event.pull_request.merged == true && contains(github.head_ref, 'release')
runs-on: ubuntu-latest
steps:
- run: |
echo The PR was merged
build:
name: Upload Kavita.Common for Version Bump
runs-on: ubuntu-latest
if: github.event.pull_request.merged == true && contains(github.head_ref, 'release')
steps:
- name: Checkout Repo
uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/upload-artifact@v4
with:
name: csproj
path: Kavita.Common/Kavita.Common.csproj
stable:
name: Build Stable and Nightly Docker if Release
needs: [ build ]
if: github.event.pull_request.merged == true && contains(github.head_ref, 'release')
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps:
- name: Find Current Pull Request
uses: jwalton/gh-find-current-pr@v1
id: findPr
with:
state: all
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Parse PR body
id: parse-body
run: |
body="Read full changelog: https://github.com/Kareadita/Kavita/releases/latest"
echo $body
echo "BODY=$body" >> $GITHUB_OUTPUT
- name: Check Out Repo
uses: actions/checkout@v4
with:
ref: develop
- name: NodeJS to Compile WebUI
uses: actions/setup-node@v4
with:
node-version: 20
- run: |
cd UI/Web || exit
echo 'Installing web dependencies'
npm ci
echo 'Building UI'
npm run prod
echo 'Copying back to Kavita wwwroot'
rsync -a dist/ ../../API/wwwroot/
cd ../ || exit
- name: Get csproj Version
uses: kzrnm/get-net-sdk-project-versions-action@v2
id: get-version
with:
proj-path: Kavita.Common/Kavita.Common.csproj
- name: Echo csproj version
run: echo "${{steps.get-version.outputs.assembly-version}}"
- name: Parse Version
run: |
version='${{steps.get-version.outputs.assembly-version}}'
newVersion=${version%.*}
echo $newVersion
echo "VERSION=$newVersion" >> $GITHUB_OUTPUT
id: parse-version
- name: Compile dotnet app
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
- name: Install Swashbuckle CLI
run: dotnet tool install -g --version 6.5.0 Swashbuckle.AspNetCore.Cli
- run: ./monorepo-build.sh
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Build and push stable
id: docker_build_stable
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm/v7,linux/arm64
push: true
tags: jvmilazz0/kavita:latest, jvmilazz0/kavita:${{ steps.parse-version.outputs.VERSION }}, ghcr.io/kareadita/kavita:latest, ghcr.io/kareadita/kavita:${{ steps.parse-version.outputs.VERSION }}
- name: Build and push nightly
id: docker_build_nightly
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm/v7,linux/arm64
push: true
tags: jvmilazz0/kavita:nightly, jvmilazz0/kavita:nightly-${{ steps.parse-version.outputs.VERSION }}, ghcr.io/kareadita/kavita:nightly, ghcr.io/kareadita/kavita:nightly-${{ steps.parse-version.outputs.VERSION }}
- name: Image digest
run: echo ${{ steps.docker_build_stable.outputs.digest }}
- name: Image digest
run: echo ${{ steps.docker_build_nightly.outputs.digest }}
- name: Notify Discord
uses: rjstone/discord-webhook-notify@v1
with:
severity: info
description: v${{steps.get-version.outputs.assembly-version}} - ${{ steps.findPr.outputs.title }}
details: '${{ steps.findPr.outputs.body }}'
text: <@&939225192553644133> A new stable build has been released.
webhookUrl: ${{ secrets.DISCORD_DOCKER_UPDATE_URL }}
- name: Notify Discord
uses: rjstone/discord-webhook-notify@v1
with:
severity: info
description: v${{steps.get-version.outputs.assembly-version}} - ${{ steps.findPr.outputs.title }}
details: '${{ steps.findPr.outputs.body }}'
text: <@&939225459156217917> <@&939225350775406643> A new nightly build has been released for docker.
webhookUrl: ${{ secrets.DISCORD_DOCKER_UPDATE_URL }}

5
.gitignore vendored
View file

@ -534,3 +534,8 @@ UI/Web/.vscode/settings.json
/API.Tests/Services/Test Data/ArchiveService/CoverImages/output/*
UI/Web/.angular/
BenchmarkDotNet.Artifacts
API.Tests/Services/Test Data/ImageService/Covers/*_output*
API.Tests/Services/Test Data/ImageService/Covers/*_baseline*
API.Tests/Services/Test Data/ImageService/Covers/index.html

View file

@ -9,8 +9,8 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.6" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="NSubstitute" Version="5.1.0" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="21.0.2" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="21.0.2" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="21.0.22" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="21.0.22" />
<PackageReference Include="xunit" Version="2.8.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@ -28,6 +28,7 @@
<ItemGroup>
<Folder Include="Services\Test Data\ArchiveService\ComicInfos" />
<Folder Include="Services\Test Data\ImageService\Covers\" />
<Folder Include="Services\Test Data\ScannerService\Manga" />
</ItemGroup>

View file

@ -211,6 +211,7 @@ public class MangaParsingTests
[InlineData("Max Level Returner เล่มที่ 5", "Max Level Returner")]
[InlineData("หนึ่งความคิด นิจนิรันดร์ เล่ม 2", "หนึ่งความคิด นิจนิรันดร์")]
[InlineData("不安の種\uff0b - 01", "不安の種\uff0b")]
[InlineData("Giant Ojou-sama - Ch. 33.5 - Volume 04 Bonus Chapter", "Giant Ojou-sama")]
public void ParseSeriesTest(string filename, string expected)
{
Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseSeries(filename, LibraryType.Manga));

View file

@ -0,0 +1,124 @@
using System.IO;
using System.Linq;
using System.Text;
using API.Entities.Enums;
using API.Services;
using NetVips;
using Xunit;
using Image = NetVips.Image;
namespace API.Tests.Services;
public class ImageServiceTests
{
private readonly string _testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ImageService/Covers");
private const string OutputPattern = "_output";
private const string BaselinePattern = "_baseline";
/// <summary>
/// Run this once to get the baseline generation
/// </summary>
[Fact]
public void GenerateBaseline()
{
GenerateFiles(BaselinePattern);
}
/// <summary>
/// Change the Scaling/Crop code then run this continuously
/// </summary>
[Fact]
public void TestScaling()
{
GenerateFiles(OutputPattern);
GenerateHtmlFile();
}
private void GenerateFiles(string outputExtension)
{
// Step 1: Delete any images that have _output in the name
var outputFiles = Directory.GetFiles(_testDirectory, "*_output.*");
foreach (var file in outputFiles)
{
File.Delete(file);
}
// Step 2: Scan the _testDirectory for images
var imageFiles = Directory.GetFiles(_testDirectory, "*.*")
.Where(file => !file.EndsWith("html"))
.Where(file => !file.Contains(OutputPattern) && !file.Contains(BaselinePattern))
.ToList();
// Step 3: Process each image
foreach (var imagePath in imageFiles)
{
var fileName = Path.GetFileNameWithoutExtension(imagePath);
var dims = CoverImageSize.Default.GetDimensions();
using var sourceImage = Image.NewFromFile(imagePath, false, Enums.Access.SequentialUnbuffered);
var size = ImageService.GetSizeForDimensions(sourceImage, dims.Width, dims.Height);
var crop = ImageService.GetCropForDimensions(sourceImage, dims.Width, dims.Height);
using var thumbnail = Image.Thumbnail(imagePath, dims.Width, dims.Height,
size: size,
crop: crop);
var outputFileName = fileName + outputExtension + ".png";
thumbnail.WriteToFile(Path.Join(_testDirectory, outputFileName));
}
}
private void GenerateHtmlFile()
{
var imageFiles = Directory.GetFiles(_testDirectory, "*.*")
.Where(file => !file.EndsWith("html"))
.Where(file => !file.Contains(OutputPattern) && !file.Contains(BaselinePattern))
.ToList();
var htmlBuilder = new StringBuilder();
htmlBuilder.AppendLine("<!DOCTYPE html>");
htmlBuilder.AppendLine("<html lang=\"en\">");
htmlBuilder.AppendLine("<head>");
htmlBuilder.AppendLine("<meta charset=\"UTF-8\">");
htmlBuilder.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
htmlBuilder.AppendLine("<title>Image Comparison</title>");
htmlBuilder.AppendLine("<style>");
htmlBuilder.AppendLine("body { font-family: Arial, sans-serif; }");
htmlBuilder.AppendLine(".container { display: flex; flex-wrap: wrap; }");
htmlBuilder.AppendLine(".image-row { display: flex; align-items: center; margin-bottom: 20px; width: 100% }");
htmlBuilder.AppendLine(".image-row img { margin-right: 10px; max-width: 200px; height: auto; }");
htmlBuilder.AppendLine("</style>");
htmlBuilder.AppendLine("</head>");
htmlBuilder.AppendLine("<body>");
htmlBuilder.AppendLine("<div class=\"container\">");
foreach (var imagePath in imageFiles)
{
var fileName = Path.GetFileNameWithoutExtension(imagePath);
var baselinePath = Path.Combine(_testDirectory, fileName + "_baseline.png");
var outputPath = Path.Combine(_testDirectory, fileName + "_output.png");
var dims = CoverImageSize.Default.GetDimensions();
using var sourceImage = Image.NewFromFile(imagePath, false, Enums.Access.SequentialUnbuffered);
htmlBuilder.AppendLine("<div class=\"image-row\">");
htmlBuilder.AppendLine($"<p>{fileName} ({((double) sourceImage.Width / sourceImage.Height).ToString("F2")}) - {ImageService.WillScaleWell(sourceImage, dims.Width, dims.Height)}</p>");
htmlBuilder.AppendLine($"<img src=\"./{Path.GetFileName(imagePath)}\" alt=\"{fileName}\">");
if (File.Exists(baselinePath))
{
htmlBuilder.AppendLine($"<img src=\"./{Path.GetFileName(baselinePath)}\" alt=\"{fileName} baseline\">");
}
if (File.Exists(outputPath))
{
htmlBuilder.AppendLine($"<img src=\"./{Path.GetFileName(outputPath)}\" alt=\"{fileName} output\">");
}
htmlBuilder.AppendLine("</div>");
}
htmlBuilder.AppendLine("</div>");
htmlBuilder.AppendLine("</body>");
htmlBuilder.AppendLine("</html>");
File.WriteAllText(Path.Combine(_testDirectory, "index.html"), htmlBuilder.ToString());
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 482 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 886 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 KiB

View file

@ -12,9 +12,9 @@
<LangVersion>latestmajor</LangVersion>
</PropertyGroup>
<!-- <Target Name="PostBuild" AfterTargets="Build" Condition=" '$(Configuration)' == 'Debug' ">-->
<!-- <Exec Command="swagger tofile &#45;&#45;output ../openapi.json bin/$(Configuration)/$(TargetFramework)/$(AssemblyName).dll v1" />-->
<!-- </Target>-->
<Target Name="PostBuild" AfterTargets="Build" Condition=" '$(Configuration)' == 'Debug' ">
<Exec Command="swagger tofile --output ../openapi.json bin/$(Configuration)/$(TargetFramework)/$(AssemblyName).dll v1" />
</Target>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<DebugSymbols>false</DebugSymbols>
@ -53,8 +53,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CsvHelper" Version="32.0.3" />
<PackageReference Include="MailKit" Version="4.6.0" />
<PackageReference Include="CsvHelper" Version="33.0.1" />
<PackageReference Include="MailKit" Version="4.7.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.6">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@ -65,44 +65,44 @@
<PackageReference Include="ExCSS" Version="4.2.5" />
<PackageReference Include="Flurl" Version="3.0.7" />
<PackageReference Include="Flurl.Http" Version="3.2.4" />
<PackageReference Include="Hangfire" Version="1.8.12" />
<PackageReference Include="Hangfire.InMemory" Version="0.10.0" />
<PackageReference Include="Hangfire" Version="1.8.14" />
<PackageReference Include="Hangfire.InMemory" Version="0.10.3" />
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
<PackageReference Include="Hangfire.Storage.SQLite" Version="0.4.2" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.61" />
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.12" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.14" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.6" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.6" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.6" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.0" />
<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.Native" Version="8.15.2" />
<PackageReference Include="NReco.Logging.File" Version="1.2.0" />
<PackageReference Include="NReco.Logging.File" Version="1.2.1" />
<PackageReference Include="Serilog" Version="4.0.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="3.2.0-dev-00752" />
<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.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="8.0.1" />
<PackageReference Include="Serilog.Sinks.AspNetCore.SignalR" Version="0.4.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.SignalR.Core" Version="0.1.2" />
<PackageReference Include="SharpCompress" Version="0.37.2" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.4" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.26.0.92422">
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.28.0.94264">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.2" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.6.0" />
<PackageReference Include="System.IO.Abstractions" Version="21.0.2" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.6.2" />
<PackageReference Include="System.IO.Abstractions" Version="21.0.22" />
<PackageReference Include="System.Drawing.Common" Version="8.0.6" />
<PackageReference Include="VersOne.Epub" Version="3.3.2" />
</ItemGroup>

View file

@ -30,25 +30,25 @@ public class CblController : BaseApiController
/// The first step in a cbl import. This validates the cbl file that if an import occured, would it be successful.
/// If this returns errors, the cbl will always be rejected by Kavita.
/// </summary>
/// <param name="file">FormBody with parameter name of cbl</param>
/// <param name="cbl">FormBody with parameter name of cbl</param>
/// <param name="comicVineMatching">Use comic vine matching or not. Defaults to false</param>
/// <returns></returns>
[HttpPost("validate")]
public async Task<ActionResult<CblImportSummaryDto>> ValidateCbl([FromForm(Name = "cbl")] IFormFile file,
[FromForm(Name = "comicVineMatching")] bool comicVineMatching = false)
public async Task<ActionResult<CblImportSummaryDto>> ValidateCbl(IFormFile cbl, bool comicVineMatching = false)
{
var userId = User.GetUserId();
try
{
var cbl = await SaveAndLoadCblFile(file);
var importSummary = await _readingListService.ValidateCblFile(userId, cbl, comicVineMatching);
importSummary.FileName = file.FileName;
var cblReadingList = await SaveAndLoadCblFile(cbl);
var importSummary = await _readingListService.ValidateCblFile(userId, cblReadingList, comicVineMatching);
importSummary.FileName = cbl.FileName;
return Ok(importSummary);
}
catch (ArgumentNullException)
{
return Ok(new CblImportSummaryDto()
{
FileName = file.FileName,
FileName = cbl.FileName,
Success = CblImportResult.Fail,
Results = new List<CblBookResult>()
{
@ -63,7 +63,7 @@ public class CblController : BaseApiController
{
return Ok(new CblImportSummaryDto()
{
FileName = file.FileName,
FileName = cbl.FileName,
Success = CblImportResult.Fail,
Results = new List<CblBookResult>()
{
@ -80,25 +80,26 @@ public class CblController : BaseApiController
/// <summary>
/// Performs the actual import (assuming dryRun = false)
/// </summary>
/// <param name="file">FormBody with parameter name of cbl</param>
/// <param name="cbl">FormBody with parameter name of cbl</param>
/// <param name="dryRun">If true, will only emulate the import but not perform. This should be done to preview what will happen</param>
/// <param name="comicVineMatching">Use comic vine matching or not. Defaults to false</param>
/// <returns></returns>
[HttpPost("import")]
public async Task<ActionResult<CblImportSummaryDto>> ImportCbl([FromForm(Name = "cbl")] IFormFile file,
[FromForm(Name = "dryRun")] bool dryRun = false, [FromForm(Name = "comicVineMatching")] bool comicVineMatching = false)
public async Task<ActionResult<CblImportSummaryDto>> ImportCbl(IFormFile cbl, bool dryRun = false, bool comicVineMatching = false)
{
try
{
var userId = User.GetUserId();
var cbl = await SaveAndLoadCblFile(file);
var importSummary = await _readingListService.CreateReadingListFromCbl(userId, cbl, dryRun, comicVineMatching);
importSummary.FileName = file.FileName;
var cblReadingList = await SaveAndLoadCblFile(cbl);
var importSummary = await _readingListService.CreateReadingListFromCbl(userId, cblReadingList, dryRun, comicVineMatching);
importSummary.FileName = cbl.FileName;
return Ok(importSummary);
} catch (ArgumentNullException)
{
return Ok(new CblImportSummaryDto()
{
FileName = file.FileName,
FileName = cbl.FileName,
Success = CblImportResult.Fail,
Results = new List<CblBookResult>()
{
@ -113,7 +114,7 @@ public class CblController : BaseApiController
{
return Ok(new CblImportSummaryDto()
{
FileName = file.FileName,
FileName = cbl.FileName,
Success = CblImportResult.Fail,
Results = new List<CblBookResult>()
{

View file

@ -21,7 +21,6 @@ using AutoMapper;
using EasyCaching.Core;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration.UserSecrets;
using Microsoft.Extensions.Logging;
using TaskScheduler = API.Services.TaskScheduler;
@ -134,7 +133,7 @@ public class LibraryController : BaseApiController
if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-library"));
await _libraryWatcher.RestartWatching();
_taskScheduler.ScanLibrary(library.Id);
await _taskScheduler.ScanLibrary(library.Id);
await _eventHub.SendMessageAsync(MessageFactory.LibraryModified,
MessageFactory.LibraryModifiedEvent(library.Id, "create"), false);
await _eventHub.SendMessageAsync(MessageFactory.SideNavUpdate,
@ -292,7 +291,7 @@ public class LibraryController : BaseApiController
public async Task<ActionResult> Scan(int libraryId, bool force = false)
{
if (libraryId <= 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "libraryId"));
_taskScheduler.ScanLibrary(libraryId, force);
await _taskScheduler.ScanLibrary(libraryId, force);
return Ok();
}
@ -500,7 +499,7 @@ public class LibraryController : BaseApiController
if (originalFoldersCount != dto.Folders.Count() || typeUpdate)
{
await _libraryWatcher.RestartWatching();
_taskScheduler.ScanLibrary(library.Id);
await _taskScheduler.ScanLibrary(library.Id);
}
if (folderWatchingUpdate)

View file

@ -591,9 +591,22 @@ public class OpdsController : BaseApiController
var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId)).ToList();
foreach (var item in items)
{
feed.Entries.Add(
CreateChapter(apiKey, $"{item.Order} - {item.SeriesName}: {item.Title}",
item.Summary ?? string.Empty, item.ChapterId, item.VolumeId, item.SeriesId, prefix, baseUrl));
var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(item.ChapterId);
// If there is only one file underneath, add a direct acquisition link, otherwise add a subsection
if (chapterDto != null && chapterDto.Files.Count == 1)
{
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(item.SeriesId, userId);
feed.Entries.Add(await CreateChapterWithFile(userId, item.SeriesId, item.VolumeId, item.ChapterId,
chapterDto.Files.First(), series!, chapterDto, apiKey, prefix, baseUrl));
}
else
{
feed.Entries.Add(
CreateChapter(apiKey, $"{item.Order} - {item.SeriesName}: {item.Title}",
item.Summary ?? string.Empty, item.ChapterId, item.VolumeId, item.SeriesId, prefix, baseUrl));
}
}
return CreateXmlResult(SerializeXml(feed));
}
@ -855,6 +868,7 @@ public class OpdsController : BaseApiController
SetFeedId(feed, $"series-{series.Id}");
feed.Links.Add(CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/series-cover?seriesId={seriesId}&apiKey={apiKey}"));
var chapterDict = new Dictionary<int, short>();
var seriesDetail = await _seriesService.GetSeriesDetail(seriesId, userId);
foreach (var volume in seriesDetail.Volumes)
{
@ -866,6 +880,7 @@ public class OpdsController : BaseApiController
var chapterDto = _mapper.Map<ChapterDto>(chapter);
foreach (var mangaFile in chapter.Files)
{
chapterDict.Add(chapterId, 0);
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volume.Id, chapterId, _mapper.Map<MangaFileDto>(mangaFile), series,
chapterDto, apiKey, prefix, baseUrl));
}
@ -879,7 +894,7 @@ public class OpdsController : BaseApiController
chapters = seriesDetail.Chapters;
}
foreach (var chapter in chapters.Where(c => !c.IsSpecial))
foreach (var chapter in chapters.Where(c => !c.IsSpecial && !chapterDict.ContainsKey(c.Id)))
{
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapter.Id);
var chapterDto = _mapper.Map<ChapterDto>(chapter);
@ -914,15 +929,15 @@ public class OpdsController : BaseApiController
var (baseUrl, prefix) = await GetPrefix();
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId);
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId);
var chapters =
(await _unitOfWork.ChapterRepository.GetChaptersAsync(volumeId))
.OrderBy(x => x.MinNumber, _chapterSortComparerDefaultLast);
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId, VolumeIncludes.Chapters);
// var chapters =
// (await _unitOfWork.ChapterRepository.GetChaptersAsync(volumeId))
// .OrderBy(x => x.MinNumber, _chapterSortComparerDefaultLast);
var feed = CreateFeed(series.Name + " - Volume " + volume!.Name + $" - {_seriesService.FormatChapterName(userId, libraryType)}s ",
$"{apiKey}/series/{seriesId}/volume/{volumeId}", apiKey, prefix);
SetFeedId(feed, $"series-{series.Id}-volume-{volume.Id}-{_seriesService.FormatChapterName(userId, libraryType)}s");
foreach (var chapter in chapters)
foreach (var chapter in volume.Chapters)
{
var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapter.Id, ChapterIncludes.Files | ChapterIncludes.People);
foreach (var mangaFile in chapterDto.Files)
@ -1111,7 +1126,8 @@ public class OpdsController : BaseApiController
};
}
private async Task<FeedEntry> CreateChapterWithFile(int userId, int seriesId, int volumeId, int chapterId, MangaFileDto mangaFile, SeriesDto series, ChapterDto chapter, string apiKey, string prefix, string baseUrl)
private async Task<FeedEntry> CreateChapterWithFile(int userId, int seriesId, int volumeId, int chapterId,
MangaFileDto mangaFile, SeriesDto series, ChapterDto chapter, string apiKey, string prefix, string baseUrl)
{
var fileSize =
mangaFile.Bytes > 0 ? DirectoryService.GetHumanReadableBytes(mangaFile.Bytes) :
@ -1120,7 +1136,7 @@ public class OpdsController : BaseApiController
var fileType = _downloadService.GetContentTypeFromFile(mangaFile.FilePath);
var filename = Uri.EscapeDataString(Path.GetFileName(mangaFile.FilePath));
var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId);
var volume = await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, await GetUser(apiKey));
var volume = await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, userId);
var title = $"{series.Name}";

View file

@ -491,4 +491,59 @@ public class ReadingListController : BaseApiController
if (string.IsNullOrEmpty(name)) return true;
return Ok(await _unitOfWork.ReadingListRepository.ReadingListExists(name));
}
/// <summary>
/// Promote/UnPromote multiple reading lists in one go. Will only update the authenticated user's reading lists and will only work if the user has promotion role
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("promote-multiple")]
public async Task<ActionResult> PromoteMultipleReadingLists(PromoteReadingListsDto dto)
{
// This needs to take into account owner as I can select other users cards
var userId = User.GetUserId();
if (!User.IsInRole(PolicyConstants.PromoteRole) && !User.IsInRole(PolicyConstants.AdminRole))
{
return BadRequest(await _localizationService.Translate(userId, "permission-denied"));
}
var readingLists = await _unitOfWork.ReadingListRepository.GetReadingListsByIds(dto.ReadingListIds);
foreach (var readingList in readingLists)
{
if (readingList.AppUserId != userId) continue;
readingList.Promoted = dto.Promoted;
_unitOfWork.ReadingListRepository.Update(readingList);
}
if (!_unitOfWork.HasChanges()) return Ok();
await _unitOfWork.CommitAsync();
return Ok();
}
/// <summary>
/// Delete multiple reading lists in one go
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("delete-multiple")]
public async Task<ActionResult> DeleteMultipleReadingLists(DeleteReadingListsDto dto)
{
// This needs to take into account owner as I can select other users cards
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.ReadingLists);
if (user == null) return Unauthorized();
user.ReadingLists = user.ReadingLists.Where(uc => !dto.ReadingListIds.Contains(uc.Id)).ToList();
_unitOfWork.UserRepository.Update(user);
if (!_unitOfWork.HasChanges()) return Ok();
await _unitOfWork.CommitAsync();
return Ok();
}
}

View file

@ -1,5 +1,8 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
@ -7,11 +10,15 @@ using API.DTOs.Statistics;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Helpers;
using API.Services;
using API.Services.Plus;
using API.Services.Tasks.Scanner.Parser;
using CsvHelper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using MimeTypes;
namespace API.Controllers;
@ -24,15 +31,18 @@ public class StatsController : BaseApiController
private readonly UserManager<AppUser> _userManager;
private readonly ILocalizationService _localizationService;
private readonly ILicenseService _licenseService;
private readonly IDirectoryService _directoryService;
public StatsController(IStatisticService statService, IUnitOfWork unitOfWork,
UserManager<AppUser> userManager, ILocalizationService localizationService, ILicenseService licenseService)
UserManager<AppUser> userManager, ILocalizationService localizationService,
ILicenseService licenseService, IDirectoryService directoryService)
{
_statService = statService;
_unitOfWork = unitOfWork;
_userManager = userManager;
_localizationService = localizationService;
_licenseService = licenseService;
_directoryService = directoryService;
}
[HttpGet("user/{userId}/read")]
@ -111,6 +121,34 @@ public class StatsController : BaseApiController
return Ok(await _statService.GetFileBreakdown());
}
/// <summary>
/// Generates a csv of all file paths for a given extension
/// </summary>
/// <returns></returns>
[Authorize("RequireAdminRole")]
[HttpGet("server/file-extension")]
[ResponseCache(CacheProfileName = "Statistics")]
public async Task<ActionResult> DownloadFilesByExtension(string fileExtension)
{
if (!Regex.IsMatch(fileExtension, Parser.SupportedExtensions))
{
return BadRequest("Invalid file format");
}
var tempFile = Path.Join(_directoryService.TempDirectory,
$"file_breakdown_{fileExtension.Replace(".", string.Empty)}.csv");
if (!_directoryService.FileSystem.File.Exists(tempFile))
{
var results = await _statService.GetFilesByExtension(fileExtension);
await using var writer = new StreamWriter(tempFile);
await using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture);
await csv.WriteRecordsAsync(results);
}
return PhysicalFile(tempFile, MimeTypeMap.GetMimeType(Path.GetExtension(tempFile)),
System.Web.HttpUtility.UrlEncode(Path.GetFileName(tempFile)), true);
}
/// <summary>
/// Returns reading history events for a give or all users, broken up by day, and format

View file

@ -0,0 +1,10 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace API.DTOs.ReadingLists;
public class DeleteReadingListsDto
{
[Required]
public IList<int> ReadingListIds { get; set; }
}

View file

@ -0,0 +1,9 @@
using System.Collections.Generic;
namespace API.DTOs.ReadingLists;
public class PromoteReadingListsDto
{
public IList<int> ReadingListIds { get; init; }
public bool Promoted { get; init; }
}

View file

@ -0,0 +1,15 @@
using CsvHelper.Configuration.Attributes;
namespace API.DTOs.Stats;
/// <summary>
/// Excel export for File Extension Report
/// </summary>
public class FileExtensionExportDto
{
[Name("Path")]
public string FilePath { get; set; }
[Name("Extension")]
public string Extension { get; set; }
}

View file

@ -13,7 +13,7 @@ public class UpdateNotificationDto
/// Semver of the release version
/// <example>0.4.3</example>
/// </summary>
public required string UpdateVersion { get; init; }
public required string UpdateVersion { get; set; }
/// <summary>
/// Release body in HTML
/// </summary>

View file

@ -21,10 +21,11 @@ public static class MigrateEmailTemplates
var files = directoryService.GetFiles(directoryService.CustomizedTemplateDirectory);
if (files.Any())
{
logger.LogCritical("Running MigrateEmailTemplates migration - Completed. This is not an error");
return;
}
logger.LogCritical("Running MigrateEmailTemplates migration - Please be patient, this may take some time. This is not an error");
// Write files to directory
await DownloadAndWriteToFile(EmailChange, Path.Join(directoryService.CustomizedTemplateDirectory, "EmailChange.html"), logger);
await DownloadAndWriteToFile(EmailConfirm, Path.Join(directoryService.CustomizedTemplateDirectory, "EmailConfirm.html"), logger);
@ -33,8 +34,7 @@ public static class MigrateEmailTemplates
await DownloadAndWriteToFile(EmailTest, Path.Join(directoryService.CustomizedTemplateDirectory, "EmailTest.html"), logger);
logger.LogCritical("Running MigrateEmailTemplates migration - Please be patient, this may take some time. This is not an error");
logger.LogCritical("Running MigrateEmailTemplates migration - Completed. This is not an error");
}
private static async Task DownloadAndWriteToFile(string url, string filePath, ILogger<Program> logger)

View file

@ -49,6 +49,7 @@ public interface IReadingListRepository
Task<IList<ReadingList>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat);
Task<int> RemoveReadingListsWithoutSeries();
Task<ReadingList?> GetReadingListByTitleAsync(string name, int userId, ReadingListIncludes includes = ReadingListIncludes.Items);
Task<IEnumerable<ReadingList>> GetReadingListsByIds(IList<int> ids, ReadingListIncludes includes = ReadingListIncludes.Items);
}
public class ReadingListRepository : IReadingListRepository
@ -156,6 +157,15 @@ public class ReadingListRepository : IReadingListRepository
.FirstOrDefaultAsync(x => x.NormalizedTitle != null && x.NormalizedTitle.Equals(normalized) && x.AppUserId == userId);
}
public async Task<IEnumerable<ReadingList>> GetReadingListsByIds(IList<int> ids, ReadingListIncludes includes = ReadingListIncludes.Items)
{
return await _context.ReadingList
.Where(c => ids.Contains(c.Id))
.Includes(includes)
.AsSplitQuery()
.ToListAsync();
}
public void Remove(ReadingListItem item)
{
_context.ReadingListItem.Remove(item);

View file

@ -23,6 +23,10 @@ public enum VolumeIncludes
Chapters = 2,
People = 4,
Tags = 8,
/// <summary>
/// This will include Chapters by default
/// </summary>
Files = 16
}
public interface IVolumeRepository
@ -34,7 +38,7 @@ public interface IVolumeRepository
Task<string?> GetVolumeCoverImageAsync(int volumeId);
Task<IList<int>> GetChapterIdsByVolumeIds(IReadOnlyList<int> volumeIds);
Task<IList<VolumeDto>> GetVolumesDtoAsync(int seriesId, int userId, VolumeIncludes includes = VolumeIncludes.Chapters);
Task<Volume?> GetVolumeAsync(int volumeId);
Task<Volume?> GetVolumeAsync(int volumeId, VolumeIncludes includes = VolumeIncludes.Files);
Task<VolumeDto?> GetVolumeDtoAsync(int volumeId, int userId);
Task<IEnumerable<Volume>> GetVolumesForSeriesAsync(IList<int> seriesIds, bool includeChapters = false);
Task<IEnumerable<Volume>> GetVolumes(int seriesId);
@ -173,11 +177,10 @@ public class VolumeRepository : IVolumeRepository
/// </summary>
/// <param name="volumeId"></param>
/// <returns></returns>
public async Task<Volume?> GetVolumeAsync(int volumeId)
public async Task<Volume?> GetVolumeAsync(int volumeId, VolumeIncludes includes = VolumeIncludes.Files)
{
return await _context.Volume
.Include(vol => vol.Chapters)
.ThenInclude(c => c.Files)
.Includes(includes)
.AsSplitQuery()
.SingleOrDefaultAsync(vol => vol.Id == volumeId);
}

View file

@ -65,22 +65,28 @@ public static class IncludesExtensions
public static IQueryable<Volume> Includes(this IQueryable<Volume> queryable,
VolumeIncludes includes)
{
if (includes.HasFlag(VolumeIncludes.Chapters))
if (includes.HasFlag(VolumeIncludes.Files))
{
queryable = queryable.Include(vol => vol.Chapters);
queryable = queryable
.Include(vol => vol.Chapters.OrderBy(c => c.SortOrder))
.ThenInclude(c => c.Files);
} else if (includes.HasFlag(VolumeIncludes.Chapters))
{
queryable = queryable
.Include(vol => vol.Chapters.OrderBy(c => c.SortOrder));
}
if (includes.HasFlag(VolumeIncludes.People))
{
queryable = queryable
.Include(vol => vol.Chapters)
.Include(vol => vol.Chapters.OrderBy(c => c.SortOrder))
.ThenInclude(c => c.People);
}
if (includes.HasFlag(VolumeIncludes.Tags))
{
queryable = queryable
.Include(vol => vol.Chapters)
.Include(vol => vol.Chapters.OrderBy(c => c.SortOrder))
.ThenInclude(c => c.Tags);
}
@ -104,7 +110,7 @@ public static class IncludesExtensions
{
query = query
.Include(s => s.Volumes)
.ThenInclude(v => v.Chapters);
.ThenInclude(v => v.Chapters.OrderBy(c => c.SortOrder));
}
if (includeFlags.HasFlag(SeriesIncludes.Related))

View file

@ -22,6 +22,7 @@ using API.DTOs.Search;
using API.DTOs.SeriesDetail;
using API.DTOs.Settings;
using API.DTOs.SideNav;
using API.DTOs.Stats;
using API.DTOs.Theme;
using API.Entities;
using API.Entities.Enums;
@ -117,6 +118,10 @@ public class AutoMapperProfiles : Profile
opt =>
opt.MapFrom(src =>
src.People.Where(p => p.Role == PersonRole.Inker).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Imprints,
opt =>
opt.MapFrom(src =>
src.People.Where(p => p.Role == PersonRole.Imprint).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Letterers,
opt =>
opt.MapFrom(src =>
@ -329,5 +334,8 @@ public class AutoMapperProfiles : Profile
opt.MapFrom(src => ReviewService.GetCharacters(src.Body)));
CreateMap<ExternalRecommendation, ExternalSeriesDto>();
CreateMap<MangaFile, FileExtensionExportDto>();
}
}

42
API/I18N/da.json Normal file
View file

@ -0,0 +1,42 @@
{
"locked-out": "Du er låst ude, grundet for mange forsøg. Vent venligt 10 minutter.",
"disabled-account": "Din konto er deaktiveret. Kontakt server administratoren.",
"register-user": "Der opstod en fejl under brugerregistreringen",
"validate-email": "Der opstod en fejl under validering af emailen: {0}",
"confirm-email": "Du skal bekræfte din email først",
"confirm-token-gen": "Der opstod en fejl under oprettelsen af en bekræftigelsestoken",
"invalid-password": "Ugyldigt kodeord",
"username-taken": "Brugernavenet er allerede taget",
"generate-token": "Der opstod en fejl under generering af bekræftigelsesemailtoken. Se logbeskederne",
"generic-user-update": "Der opstod en fejl ved opdatering af brugeren",
"user-already-registered": "Brugeren er allerede registreret som {0}",
"manual-setup-fail": "Manuel opsætning kunne ikke færdiggøres. Venligst annuller, og genopret invitationen",
"not-accessible-password": "Din server er ikke tilgængelig. Et link til nulstilling af kodeord kan findes i loggen",
"user-migration-needed": "Denne bruger skal migreres. Få dem til at logge ud og ind igen for at starte migreringsflowet",
"invalid-username": "Ugyldigt brugernavn",
"generic-invite-email": "Der opstod en fejl under et forsøg på at sende invitationmail'en igen",
"check-updates": "Tjek for opdateringer",
"update-yearly-stats": "Opdater årlige statistikker",
"no-user": "Brugeren eksistere ikke",
"user-already-confirmed": "Brugeren er allerede bekræftet",
"admin-already-exists": "Administrator eksistere allerede",
"chapter-doesnt-exist": "Kapitel findes ikke",
"not-accessible": "Din server er ikke ekstern tilgængelig",
"email-sent": "Email afsendt",
"book-num": "Bog {0}",
"issue-num": "Nummer {0}{1}",
"chapter-num": "Kapitel {0}",
"backup": "Backup",
"age-restriction-update": "Der opstod en fejl ved opdateringen af aldersbegrænsningen",
"user-already-invited": "Brugeren er allerede inviteret med denne mail, og har ikke accepteret invitationen endnu.",
"invalid-email-confirmation": "Ugyldig mailbekræftigelse",
"password-updated": "Kodeord opdateret",
"denied": "Ikke tilladt",
"permission-denied": "Du har ikke tilladelse til at udføre denne operation",
"nothing-to-do": "Intet at lave",
"password-required": "Du skal skrive dit kodeord for at ændre din konto, medmindre du er en administrator",
"invalid-payload": "Ugyldig payload",
"invalid-token": "Ugyldig token",
"unable-to-reset-key": "Noget gik galt, det var ikke muligt at nulstille nøglen",
"share-multiple-emails": "Du kan ikke dele emails henover flere kontoer"
}

View file

@ -38,7 +38,7 @@
"generic-error": "Es ist ein Fehler ist aufgetreten, bitte versuchen Sie es erneut",
"device-doesnt-exist": "Das Gerät existiert nicht",
"generic-device-create": "Beim Erstellen des Geräts ist ein Fehler aufgetreten",
"send-to-kavita-email": "Das Senden an Gerät kann nicht ohne konfigurierten E-Mail-Dienst durchgeführt werden.",
"send-to-kavita-email": "Das Senden an das Gerät kann nicht mit dem E-Mail-Dienst von Kavita durchgeführt werden. Bitte konfigurieren Sie Ihren eigenen.",
"send-to-device-status": "Übertrage Dateien auf Ihr Gerät",
"series-doesnt-exist": "Die Serie existiert nicht",
"volume-doesnt-exist": "Der Band existiert nicht",
@ -111,7 +111,7 @@
"user-no-access-library-from-series": "Der Benutzer hat keinen Zugang zu der Bibliothek, der zu dieser Serie gehört",
"series-restricted-age-restriction": "Benutzer darf diese Serie aufgrund von Altersbeschränkungen nicht sehen",
"book-num": "Buch {0}",
"issue-num": "Fehler {0}{1}",
"issue-num": "Ausgabe {0}{1}",
"chapter-num": "Kapitel {0}",
"reading-list-position": "Position konnte nicht aktualisiert werden",
"libraries-restricted": "Benutzer hat keinen Zugriff auf jegliche Bibliothek",
@ -178,7 +178,7 @@
"unable-to-reset-k+": "Aufgrund eines Fehlers konnte die Kavita+ Lizenz nicht zurückgesetzt werden. Kontaktieren Sie den Kavita+ Support",
"email-not-enabled": "Der Mailversand ist auf diesem Server nicht aktiviert. Sie können diese Aktion nicht durchführen.",
"invalid-email": "Die für den Benutzer hinterlegte E-Mail ist ungültig. Links finden Sie in den Logs.",
"send-to-unallowed": "Sie können nicht an ein Gerät senden, das nicht Ihnen gehört.",
"send-to-unallowed": "Sie können nicht an ein fremdes Gerät senden.",
"send-to-size-limit": "Die Datei(en), die Sie senden möchten, ist/sind zu groß für Ihren E-Mail-Service",
"check-updates": "Updates überprüfen",
"email-settings-invalid": "E-Mail-Einstellungen fehlen Informationen. Stellen Sie sicher, dass alle E-Mail-Einstellungen gespeichert sind.",

View file

@ -1,6 +1,6 @@
{
"confirm-email": "Esmalt pead oma e-posti kinnitama",
"locked-out": "Oled liiga paljude sisselogimiskatsete tõttu süsteemist keelatud. Palun oota 10 minutit.",
"locked-out": "Sinu konto on liiga paljude ebaõnnestunud sisselogimiskatsete tõttu süsteemis piiratud. Palun oota 10 minutit.",
"disabled-account": "Su konto on keelatud. Võta ühendust serveri administraatoriga.",
"register-user": "Kasutaja registreerimisel läks midagi valesti",
"validate-email": "Teie e-posti kinnitamisel ilmnes probleem: {0}",
@ -15,5 +15,147 @@
"generate-token": "Kinnitusmeili koodi loomisel ilmnes probleem. Vaata logisid",
"confirm-token-gen": "Kinnituskoodi loomisel ilmnes probleem",
"nothing-to-do": "Pole midagi teha",
"age-restriction-update": "Vanusepiirangu värskendamisel ilmnes viga"
"age-restriction-update": "Vanusepiirangu värskendamisel ilmnes viga",
"manual-setup-fail": "Mitteautomaatne seadistus ei suuda lõpetada. Palun katkestage ja looge kutse uuesti",
"user-already-registered": "Kasutaja on juba registreeritud kui {0}",
"user-already-confirmed": "Kasutaja on juba kinnitatud",
"generic-user-update": "Kasutajaandmete uuendamisel tekkis viga",
"user-already-invited": "Selle e-posti aadressiga kasutaja on juba kutsutud ja kutse vajab jaatavalt vastamist.",
"generic-invite-user": "Tekkis probleem kasutaja kutsumisel. Palun loe vealogisid.",
"no-user": "Sellenimelist kasutajat ei ole",
"username-taken": "Kasutajanimi on juba kasutuses",
"send-to-unallowed": "Sa ei saa saata seadmele, mis ei ole sinu",
"generic-send-to": "Tekkis viga faili(de) seadmele saatmisel",
"bookmarks-empty": "Järjehoidjad ei saa olla tühjad",
"no-cover-image": "Pole kaanepilti",
"bookmark-doesnt-exist": "Järjehoidjat ei eksisteeri",
"must-be-defined": "{0} peab olema sätestatud",
"generic-favicon": "Domeeni favicon laadimisel tekkis probleem",
"generic-library": "Tekkis möödapääsmatu probleem. Palun proovi uuesti.",
"series-updated": "Edukalt uuendatud",
"no-library-access": "Kasutaja ei oma juurdepääsu sellele kogule",
"user-doesnt-exist": "Kasutajat ei eksisteeri",
"delete-library-while-scan": "Ei saa kustutada tervikkogu, kui skaneerimine on töös. Oota skaneerimise lõppemist või taaskäivita Kavita ning püüa siis uuesti kustutada",
"valid-number": "Peab olema pädev leheküljenumber",
"generic-reading-list-update": "Lugemisloendi uuendamisel tekkis probleem",
"reading-list-position": "Ei õnnestunud uuendada järge",
"series-restricted": "Kasutajal puudub ligipääs sellele sarjale",
"update-yearly-stats": "Uuenda aastate kaupa statistika",
"remove-from-want-to-read": "Lugemisloendi puhastus",
"process-scrobbling-events": "Töötle scrobble juhtumeid",
"user-no-access-library-from-series": "Kasutaja ei oma juurdepääsu täiskogule, milles see seeria on",
"progress-must-exist": "Kasutajal peab olema järg",
"generic-create-temp-archive": "Tekkis probleem ajutise arhiivi loomisel",
"smart-filter-doesnt-exist": "Nutikas filter ei eksisteeri",
"browse-external-sources": "Lehitse väliseid allikaid",
"recently-updated": "Hiljuti uuendatud",
"browse-recently-updated": "Lehitse hiljuti uuendatuid",
"collections": "Kõik kogumid",
"browse-libraries": "Lehitse täiskogude kaupa",
"password-updated": "Parool uuendatud",
"invalid-username": "Vigane kasutajanimi",
"critical-email-migration": "E-posti migreerimisel tekkis viga. Võta toega ühendust",
"email-not-enabled": "E-post ei ole sellel serveril seadistatud. Seda muudatust ei saa teha.",
"chapter-doesnt-exist": "Peatükki ei ole olemas",
"file-missing": "Faili ei leitud raamatust",
"collection-updated": "Kogu uuendatud edukalt",
"collection-deleted": "Kogu kustutatud",
"generic-error": "Midagi ebaõnnestus, palun proovi uuesti",
"collection-doesnt-exist": "Kogu ei eksisteeri",
"device-doesnt-exist": "Seade ei eksisteeri",
"greater-0": "{0} peab olema suurem, kui 0",
"send-to-kavita-email": "Seadmele saatmine ei saa töötada ilma e-posti seadistamata",
"series-doesnt-exist": "Sari ei eksisteeri",
"file-doesnt-exist": "Faili ei eksisteeri",
"library-name-exists": "Kogu nimi juba eksisteerib, palun vali süsteemisiseselt unikaalne nimi.",
"generic-reading-list-create": "Lugemisloendi loomisel tekkis probleem",
"reading-list-doesnt-exist": "Lugemisloendit ei eksisteeri",
"browse-reading-lists": "Lehitse lugemisloendite kaupa",
"external-sources": "Välised allikad",
"smart-filters": "Nutikad filtrid",
"search": "Otsing",
"query-required": "Päringuparameeter on vaja kaasa anda",
"external-source-doesnt-exist": "Väline allikas ei eksisteeri",
"external-source-required": "APIvõti ja serverinimi on vajalikud",
"external-source-already-exists": "Väline allikas on juba olemas",
"device-duplicate": "Sellenimeline seade juba eksisteerib",
"collection-tag-duplicate": "Sellenimeline kogu juba eksisteerib",
"chapter-num": "Peatükk {0}",
"license-check": "Litsentsikontroll",
"check-updates": "Kontrolli uuendusi",
"process-processed-scrobbling-events": "Töötle juba töödeldud scrobble juhtumid",
"backup": "Varund",
"collection-already-exists": "Kogu juba eksisteerib",
"error-import-stack": "Tekkis probleem MAL kuhja importimisel",
"send-to-device-status": "Edastame failid sinu seadmele",
"volume-doesnt-exist": "Raamat ei eksisteeri",
"invalid-filename": "Vigane failinimi",
"generic-library-update": "Tekkis möödapääsmatu probleem tervikkogu uuendamisel.",
"pdf-doesnt-exist": "PDF ei eksisteeri - samas peaks",
"cache-file-find": "Ei leidnud puhverdatud pilti - taaslae ja proovi uuesti.",
"name-required": "Nimi ei või tühjaks jääda",
"reading-list-permission": "Teil ei ole õigusi sellele lugemisloendile või seda loendit ei eksisteeri",
"reading-list-item-delete": "Ei õnnestunud kustutada element(e|i)",
"reading-list-deleted": "Lugemisloend on kustutatud",
"generic-reading-list-delete": "Lugemisloendi kustutamisel tekkis probleem",
"browse-recently-added": "Lehitse hiljuti lisatuid",
"cleanup": "Puhastus",
"issue-num": "Väljaanne {0}{1}",
"book-num": "Raamat {0}",
"series-restricted-age-restriction": "Kasutajal ei ole vanusepiirangust tulenevalt seeriale juurdepääsu",
"send-to-permission": "Kindle ei toeta mitte-EPUB või mitte-PDF formaati",
"device-not-created": "See seade veel ei eksisteeri. Palun loo seade",
"collection-tag-title-required": "Kogu pealkiri ei saa jääda tühjaks",
"epub-html-missing": "Ei suutnud leida sobivat HTML selle lehekülje jaoks",
"epub-malformed": "Failis on süntaksivead! Ei saa lugeda.",
"theme-doesnt-exist": "Teemafail puudub või on vigane",
"external-source-already-in-use": "Juba eksisteerib voog selle välise allikaga",
"sidenav-stream-doesnt-exist": "SideNav voog ei eksisteeri",
"dashboard-stream-doesnt-exist": "Koondpaneeli voog ei eksisteeri",
"smart-filter-already-in-use": "Juba eksisteerib selle nutika filtriga voog",
"favicon-doesnt-exist": "Faviconi ei eksisteeri",
"search-description": "Otsi sarju, kogusid või lugemisloendeid",
"reading-list-restricted": "Lugemisloend ei eksisteeri või teil puudub juurdepääs",
"browse-smart-filters": "Lehitse nutikate filtritega",
"browse-more-in-genre": "Brausi rohkem {0}",
"more-in-genre": "Rohkem žanris {0}",
"browse-collections": "Lehitse kogude kaupa",
"reading-lists": "Lugemisloendid",
"invalid-email-confirmation": "Vigane e-posti kinnitus",
"generic-user-email-update": "Pole võimalik uuendada kasutaja e-posti aadressi. Kontrolli logisid.",
"not-accessible": "Sinu server ei ole väljast ligipääsetav",
"invalid-email": "E-posti aadress selle kasutaja juures ei vasta RFC-le. Vaata palun logisid.",
"not-accessible-password": "Sinu server ei ole ligipääsetav. Sinu parooli taasseadmise link on logides",
"forgot-password-generic": "E-post saadetakse aadressile, mis on meie andmebaasis",
"generic-password-update": "Uue parooli kinnitamisel esines ootamatu viga",
"email-sent": "E-post saadetud",
"user-migration-needed": "Selle kasutaja andmeid on vaja migreerida. Palu tal välja logida, et saaks migratsiooni töövoo käivitada",
"generic-invite-email": "Esines probleem e-postiga kutse taas-saatmisel",
"admin-already-exists": "Administraator on juba määratud",
"account-email-invalid": "Selle administraatorkonto e-posti aadress ei vasta RFC-le. Test e-posti ei saa saata.",
"email-settings-invalid": "E-posti seaded on puudulikud. Veendu, et e-posti seaded saaksid salvestatud.",
"generic-device-create": "Tekkis viga seadme loomisel",
"generic-device-update": "Tekkis viga seadme uuendamisel",
"generic-device-delete": "Tekkis viga seadme kustutamisel",
"send-to-size-limit": "Fail(id) mida üritad saata on e-posti serveri jaoks liiga suured",
"library-doesnt-exist": "Tervikkogu ei eksisteeri",
"invalid-access": "Vigane juurdepääs",
"no-image-for-page": "Pole sellist pilti leheküljel {0}. Proovi taaslaadimist, et võimaldada taaspuhverdamine.",
"bookmark-save": "Ei õnnestunud salvestada järjehoidjat",
"perform-scan": "Palun viige läbi selle seeria või täiskogu skaneerimine, ning proovige uuesti",
"generic-read-progress": "Tekkis probleem järje salvestamisel",
"generic-clear-bookmarks": "Ei suutnud puhastada järjehoidjaid",
"bookmark-permission": "Teil ei ole õigust järjehoidja seadmiseks/kustutamiseks",
"duplicate-bookmark": "Järjehoidja topeltsissekanne on juba olemas",
"reading-list-updated": "Uuendatud",
"bad-copy-files-for-download": "Ei õnnestu kopeerida faile ajutisse kataloogi arhiivina allalaadimiseks.",
"volume-num": "Köide {0}",
"reading-list-title-required": "Lugemisloendi pealkiri ei saa jääda tühjaks",
"reading-list-name-exists": "Sellenimeline lugemisloend on juba olemas",
"check-scrobbling-tokens": "Kontrolli scrobble turvažetoone",
"report-stats": "Raporteeri statistika",
"invalid-path": "Vigane tee",
"not-authenticated": "Kasutaja on autentimata",
"kavita+-data-refresh": "Kavita+andmete värskendus",
"scan-libraries": "Skaneeri täiskogud"
}

View file

@ -193,5 +193,7 @@
"update-yearly-stats": "연간 통계 업데이트",
"process-processed-scrobbling-events": "처리된 스크로블링 이벤트 처리",
"account-email-invalid": "관리자 계정에 등록된 이메일이 유효한 이메일이 아닙니다. 테스트 이메일을 보낼 수 없습니다.",
"email-settings-invalid": "이메일 설정 누락 된 정보. 모든 이메일 설정을 저장합니다."
"email-settings-invalid": "이메일 설정 누락 된 정보. 모든 이메일 설정을 저장합니다.",
"collection-already-exists": "컬렉션은 이미 존재합니다",
"error-import-stack": "MAL 스택을 가져오는 데 문제가 있었습니다"
}

View file

@ -42,5 +42,9 @@
"no-user": "Ngươi dùng không tồn tại",
"user-already-confirmed": "Người dùng này đã xác minh",
"generic-user-update": "Có sự cố đã xảy ra khi cập nhật thông tin người dùng",
"user-already-invited": "Người dùng đã được mời qua email này nhưng chưa chấp nhận lời mời."
"user-already-invited": "Người dùng đã được mời qua email này nhưng chưa chấp nhận lời mời.",
"generate-token": "Đã xảy ra sự cố khi tạo mã xác nhận email. Xem bản ghi",
"locked-out": "Bạn đã bị khóa do quá nhiều lần thử đăng nhập. Vui lòng chờ 10 phút.",
"unable-to-reset-key": "Có sự cố xảy ra, không thể đặt lại khóa",
"share-multiple-emails": "Một Email không thể được dùng chung cho nhiều tải khoản"
}

View file

@ -17,7 +17,7 @@
"file-doesnt-exist": "檔案不存在",
"admin-already-exists": "管理員已存在",
"age-restriction-update": "更新年齡限制時發生錯誤",
"send-to-kavita-email": "無法將 Kavita 的電子郵件服務用於傳送到裝置。請設定您自己的電子郵件服務。",
"send-to-kavita-email": "未設置電子郵件時無法使用傳送到裝置功能",
"not-accessible": "您的伺服器無法從外部存取",
"collections": "所有收藏",
"email-sent": "電子郵件已傳送",
@ -161,5 +161,39 @@
"collection-deleted": "收藏已刪除",
"permission-denied": "您不被允許進行此操作",
"device-doesnt-exist": "裝置不存在",
"generic-series-delete": "刪除系列作品時發生問題"
"generic-series-delete": "刪除系列作品時發生問題",
"recently-updated": "最近更新",
"external-source-already-in-use": "已存在具有此外部來源的串流",
"report-stats": "統計報告",
"backup": "備份",
"more-in-genre": "更多關於類型 {0}",
"account-email-invalid": "管理員帳號檔案中的電子郵件無效。無法發送測試電子郵件。",
"email-not-enabled": "此伺服器未啟用電子郵件功能。您無法執行此操作。",
"error-import-stack": "匯入 MAL 時出現問題",
"send-to-unallowed": "您無法傳送到不是您自己的裝置",
"email-settings-invalid": "電子郵件設定缺少資訊。請確保所有電子郵件設定已保存。",
"collection-already-exists": "組合已存在",
"send-to-size-limit": "您嘗試傳送的文件對於您的電子郵件系統來說過大",
"external-sources": "外部來源",
"dashboard-stream-doesnt-exist": "儀表板串流不存在",
"unable-to-reset-k+": "發生錯誤,無法重置 Kavita+ 授權。請聯繫 Kavita+ 支援",
"check-scrobbling-tokens": "檢查 Scrobbling Tokens",
"cleanup": "清理",
"browse-more-in-genre": "在 {0} 中繼續瀏覽",
"browse-recently-updated": "瀏覽最近更新",
"external-source-required": "需要 API 金鑰和 Host",
"external-source-doesnt-exist": "外部來源不存在",
"check-updates": "檢查更新",
"license-check": "授權檢查",
"process-scrobbling-events": "處理 Scrobbling 事件",
"process-processed-scrobbling-events": "處理已處理的 Scrobbling 事件",
"remove-from-want-to-read": "清理閱讀清單",
"scan-libraries": "掃描資料庫",
"kavita+-data-refresh": "Kavita+ 資料更新",
"update-yearly-stats": "更新年度統計",
"invalid-email": "使用者檔案中的電子郵件無效。請查看日誌以獲得任何連結。",
"browse-external-sources": "瀏覽外部來源",
"sidenav-stream-doesnt-exist": "側邊導覽串流不存在",
"smart-filter-already-in-use": "已存在具有此智慧篩選器的串流",
"external-source-already-exists": "外部來源已存在"
}

View file

@ -97,7 +97,7 @@ public class Program
Task.Run(async () =>
{
// Apply all migrations on startup
logger.LogInformation("Running Migrations");
logger.LogInformation("Running Manual Migrations");
try
{
@ -113,7 +113,7 @@ public class Program
}
await unitOfWork.CommitAsync();
logger.LogInformation("Running Migrations - complete");
logger.LogInformation("Running Manual Migrations - complete");
}).GetAwaiter()
.GetResult();
}

View file

@ -125,14 +125,90 @@ public class ImageService : IImageService
}
}
/// <summary>
/// Tries to determine if there is a better mode for resizing
/// </summary>
/// <param name="image"></param>
/// <param name="targetWidth"></param>
/// <param name="targetHeight"></param>
/// <returns></returns>
public static Enums.Size GetSizeForDimensions(Image image, int targetWidth, int targetHeight)
{
try
{
if (WillScaleWell(image, targetWidth, targetHeight) || IsLikelyWideImage(image.Width, image.Height))
{
return Enums.Size.Force;
}
}
catch (Exception)
{
/* Swallow */
}
return Enums.Size.Both;
}
public static Enums.Interesting? GetCropForDimensions(Image image, int targetWidth, int targetHeight)
{
try
{
if (WillScaleWell(image, targetWidth, targetHeight) || IsLikelyWideImage(image.Width, image.Height))
{
return null;
}
} catch (Exception)
{
/* Swallow */
return null;
}
return Enums.Interesting.Attention;
}
public static bool WillScaleWell(Image sourceImage, int targetWidth, int targetHeight, double tolerance = 0.1)
{
// Calculate the aspect ratios
var sourceAspectRatio = (double) sourceImage.Width / sourceImage.Height;
var targetAspectRatio = (double) targetWidth / targetHeight;
// Compare aspect ratios
if (Math.Abs(sourceAspectRatio - targetAspectRatio) > tolerance)
{
return false; // Aspect ratios differ significantly
}
// Calculate scaling factors
var widthScaleFactor = (double) targetWidth / sourceImage.Width;
var heightScaleFactor = (double) targetHeight / sourceImage.Height;
// Check resolution quality (example thresholds)
if (widthScaleFactor > 2.0 || heightScaleFactor > 2.0)
{
return false; // Scaling factor too large
}
return true; // Image will scale well
}
private static bool IsLikelyWideImage(int width, int height)
{
var aspectRatio = (double) width / height;
return aspectRatio > 1.25;
}
public string GetCoverImage(string path, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size)
{
if (string.IsNullOrEmpty(path)) return string.Empty;
try
{
var dims = size.GetDimensions();
using var thumbnail = Image.Thumbnail(path, dims.Width, height: dims.Height, size: Enums.Size.Force);
var (width, height) = size.GetDimensions();
using var sourceImage = Image.NewFromFile(path, false, Enums.Access.SequentialUnbuffered);
using var thumbnail = Image.Thumbnail(path, width, height: height,
size: GetSizeForDimensions(sourceImage, width, height),
crop: GetCropForDimensions(sourceImage, width, height));
var filename = fileName + encodeFormat.GetExtension();
thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(outputDirectory, filename));
return filename;
@ -156,22 +232,55 @@ public class ImageService : IImageService
/// <returns>File name with extension of the file. This will always write to <see cref="DirectoryService.CoverImageDirectory"/></returns>
public string WriteCoverThumbnail(Stream stream, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default)
{
var dims = size.GetDimensions();
using var thumbnail = Image.ThumbnailStream(stream, dims.Width, height: dims.Height, size: Enums.Size.Force);
var (targetWidth, targetHeight) = size.GetDimensions();
if (stream.CanSeek) stream.Position = 0;
using var sourceImage = Image.NewFromStream(stream);
if (stream.CanSeek) stream.Position = 0;
var scalingSize = GetSizeForDimensions(sourceImage, targetWidth, targetHeight);
var scalingCrop = GetCropForDimensions(sourceImage, targetWidth, targetHeight);
using var thumbnail = sourceImage.ThumbnailImage(targetWidth, targetHeight,
size: scalingSize,
crop: scalingCrop);
var filename = fileName + encodeFormat.GetExtension();
_directoryService.ExistOrCreate(outputDirectory);
try
{
_directoryService.FileSystem.File.Delete(_directoryService.FileSystem.Path.Join(outputDirectory, filename));
} catch (Exception) {/* Swallow exception */}
thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(outputDirectory, filename));
return filename;
try
{
thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(outputDirectory, filename));
return filename;
}
catch (VipsException)
{
// NetVips Issue: https://github.com/kleisauke/net-vips/issues/234
// Saving pdf covers from a stream can fail, so revert to old code
if (stream.CanSeek) stream.Position = 0;
using var thumbnail2 = Image.ThumbnailStream(stream, targetWidth, height: targetHeight,
size: scalingSize,
crop: scalingCrop);
thumbnail2.WriteToFile(_directoryService.FileSystem.Path.Join(outputDirectory, filename));
return filename;
}
}
public string WriteCoverThumbnail(string sourceFile, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default)
{
var dims = size.GetDimensions();
using var thumbnail = Image.Thumbnail(sourceFile, dims.Width, height: dims.Height, size: Enums.Size.Force);
var (width, height) = size.GetDimensions();
using var sourceImage = Image.NewFromFile(sourceFile, false, Enums.Access.SequentialUnbuffered);
using var thumbnail = Image.Thumbnail(sourceFile, width, height: height,
size: GetSizeForDimensions(sourceImage, width, height),
crop: GetCropForDimensions(sourceImage, width, height));
var filename = fileName + encodeFormat.GetExtension();
_directoryService.ExistOrCreate(outputDirectory);
try
@ -426,7 +535,7 @@ public class ImageService : IImageService
public static void CreateMergedImage(IList<string> coverImages, CoverImageSize size, string dest)
{
var dims = size.GetDimensions();
var (width, height) = size.GetDimensions();
int rows, cols;
if (coverImages.Count == 1)
@ -446,7 +555,7 @@ public class ImageService : IImageService
}
var image = Image.Black(dims.Width, dims.Height);
var image = Image.Black(width, height);
var thumbnailWidth = image.Width / cols;
var thumbnailHeight = image.Height / rows;

View file

@ -501,6 +501,7 @@ public class SeriesService : ISeriesService
StorylineChapters = storylineChapters,
TotalCount = chapters.Count,
UnreadCount = chapters.Count(c => c.Pages > 0 && c.PagesRead < c.Pages),
// TODO: See if we can get the ContinueFrom here
};
}

View file

@ -5,10 +5,12 @@ using System.Threading.Tasks;
using API.Data;
using API.DTOs;
using API.DTOs.Statistics;
using API.DTOs.Stats;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Extensions.QueryExtensions;
using API.Helpers;
using API.Services.Plus;
using API.Services.Tasks.Scanner.Parser;
using AutoMapper;
@ -35,6 +37,7 @@ public interface IStatisticService
Task UpdateServerStatistics();
Task<long> TimeSpentReadingForUsersAsync(IList<int> userIds, IList<int> libraryIds);
Task<KavitaPlusMetadataBreakdownDto> GetKavitaPlusMetadataBreakdown();
Task<IEnumerable<FileExtensionExportDto>> GetFilesByExtension(string fileExtension);
}
/// <summary>
@ -559,6 +562,16 @@ public class StatisticService : IStatisticService
}
public async Task<IEnumerable<FileExtensionExportDto>> GetFilesByExtension(string fileExtension)
{
var query = _context.MangaFile
.Where(f => f.Extension == fileExtension)
.ProjectTo<FileExtensionExportDto>(_mapper.ConfigurationProvider)
.OrderBy(f => f.FilePath);
return await query.ToListAsync();
}
public async Task<IEnumerable<TopReadDto>> GetTopUsers(int days)
{
var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList();

View file

@ -4,11 +4,13 @@ using System.Collections.Immutable;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.Entities.Enums;
using API.Helpers.Converters;
using API.Services.Plus;
using API.Services.Tasks;
using API.Services.Tasks.Metadata;
using API.SignalR;
using Hangfire;
using Microsoft.Extensions.Logging;
@ -22,12 +24,12 @@ public interface ITaskScheduler
Task ScheduleKavitaPlusTasks();
void ScanFolder(string folderPath, string originalPath, TimeSpan delay);
void ScanFolder(string folderPath);
void ScanLibrary(int libraryId, bool force = false);
void ScanLibraries(bool force = false);
Task ScanLibrary(int libraryId, bool force = false);
Task ScanLibraries(bool force = false);
void CleanupChapters(int[] chapterIds);
void RefreshMetadata(int libraryId, bool forceUpdate = true);
void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = false);
void ScanSeries(int libraryId, int seriesId, bool forceUpdate = false);
Task ScanSeries(int libraryId, int seriesId, bool forceUpdate = false);
void AnalyzeFilesForSeries(int libraryId, int seriesId, bool forceUpdate = false);
void AnalyzeFilesForLibrary(int libraryId, bool forceUpdate = false);
void CancelStatsTasks();
@ -59,6 +61,7 @@ public class TaskScheduler : ITaskScheduler
private readonly IExternalMetadataService _externalMetadataService;
private readonly ISmartCollectionSyncService _smartCollectionSyncService;
private readonly IFontService _fontService;
private readonly IEventHub _eventHub;
public static BackgroundJobServer Client => new ();
public const string ScanQueue = "scan";
@ -79,7 +82,7 @@ public class TaskScheduler : ITaskScheduler
public const string KavitaPlusDataRefreshId = "kavita+-data-refresh";
public const string KavitaPlusStackSyncId = "kavita+-stack-sync";
private static readonly ImmutableArray<string> ScanTasks =
public static readonly ImmutableArray<string> ScanTasks =
["ScannerService", "ScanLibrary", "ScanLibraries", "ScanFolder", "ScanSeries"];
private static readonly Random Rnd = new Random();
@ -95,7 +98,7 @@ public class TaskScheduler : ITaskScheduler
ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService,
IThemeService themeService, IWordCountAnalyzerService wordCountAnalyzerService, IStatisticService statisticService,
IMediaConversionService mediaConversionService, IScrobblingService scrobblingService, ILicenseService licenseService,
IExternalMetadataService externalMetadataService, ISmartCollectionSyncService smartCollectionSyncService,
IExternalMetadataService externalMetadataService, ISmartCollectionSyncService smartCollectionSyncService, IEventHub eventHub,
IFontService fontService)
{
_cacheService = cacheService;
@ -116,6 +119,7 @@ public class TaskScheduler : ITaskScheduler
_externalMetadataService = externalMetadataService;
_smartCollectionSyncService = smartCollectionSyncService;
_fontService = fontService;
_eventHub = eventHub;
}
public async Task ScheduleTasks()
@ -127,7 +131,7 @@ public class TaskScheduler : ITaskScheduler
{
var scanLibrarySetting = setting;
_logger.LogDebug("Scheduling Scan Library Task for {Setting}", scanLibrarySetting);
RecurringJob.AddOrUpdate(ScanLibrariesTaskId, () => _scannerService.ScanLibraries(false),
RecurringJob.AddOrUpdate(ScanLibrariesTaskId, () => ScanLibraries(false),
() => CronConverter.ConvertToCronNotation(scanLibrarySetting), RecurringJobOptions);
}
else
@ -324,18 +328,21 @@ public class TaskScheduler : ITaskScheduler
/// Attempts to call ScanLibraries on ScannerService, but if another scan task is in progress, will reschedule the invocation for 3 hours in future.
/// </summary>
/// <param name="force"></param>
public void ScanLibraries(bool force = false)
public async Task ScanLibraries(bool force = false)
{
if (RunningAnyTasksByMethod(ScanTasks, ScanQueue))
{
_logger.LogInformation("A Scan is already running, rescheduling ScanLibraries in 3 hours");
// Send InfoEvent to UI as this is invoked my API
BackgroundJob.Schedule(() => ScanLibraries(force), TimeSpan.FromHours(3));
await _eventHub.SendMessageAsync(MessageFactory.Info, MessageFactory.InfoEvent($"Scan libraries task delayed",
$"A scan was ongoing during processing of the scan libraries task. Task has been rescheduled for 3 hours: {DateTime.Now.AddHours(3)}"));
return;
}
BackgroundJob.Enqueue(() => _scannerService.ScanLibraries(force));
}
public void ScanLibrary(int libraryId, bool force = false)
public async Task ScanLibrary(int libraryId, bool force = false)
{
if (HasScanTaskRunningForLibrary(libraryId))
{
@ -344,15 +351,18 @@ public class TaskScheduler : ITaskScheduler
}
if (RunningAnyTasksByMethod(ScanTasks, ScanQueue))
{
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId);
_logger.LogInformation("A Scan is already running, rescheduling ScanLibrary in 3 hours");
await _eventHub.SendMessageAsync(MessageFactory.Info, MessageFactory.InfoEvent($"Scan library task delayed",
$"A scan was ongoing during processing of the {library!.Name} scan task. Task has been rescheduled for 3 hours: {DateTime.Now.AddHours(3)}"));
BackgroundJob.Schedule(() => ScanLibrary(libraryId, force), TimeSpan.FromHours(3));
return;
}
_logger.LogInformation("Enqueuing library scan for: {LibraryId}", libraryId);
BackgroundJob.Enqueue(() => _scannerService.ScanLibrary(libraryId, force, true));
var jobId = BackgroundJob.Enqueue(() => _scannerService.ScanLibrary(libraryId, force, true));
// When we do a scan, force cache to re-unpack in case page numbers change
BackgroundJob.Enqueue(() => _cleanupService.CleanupCacheDirectory());
BackgroundJob.ContinueJobWith(jobId, () => _cleanupService.CleanupCacheDirectory());
}
public void TurnOnScrobbling(int userId = 0)
@ -393,7 +403,7 @@ public class TaskScheduler : ITaskScheduler
BackgroundJob.Enqueue(() => _metadataService.GenerateCoversForSeries(libraryId, seriesId, forceUpdate));
}
public void ScanSeries(int libraryId, int seriesId, bool forceUpdate = false)
public async Task ScanSeries(int libraryId, int seriesId, bool forceUpdate = false)
{
if (HasAlreadyEnqueuedTask(ScannerService.Name, "ScanSeries", [seriesId, forceUpdate], ScanQueue))
{
@ -403,7 +413,10 @@ public class TaskScheduler : ITaskScheduler
if (RunningAnyTasksByMethod(ScanTasks, ScanQueue))
{
// BUG: This can end up triggering a ton of scan series calls (but i haven't seen in practice)
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.None);
_logger.LogInformation("A Scan is already running, rescheduling ScanSeries in 10 minutes");
await _eventHub.SendMessageAsync(MessageFactory.Info, MessageFactory.InfoEvent($"Scan series task delayed: {series!.Name}",
$"A scan was ongoing during processing of the scan series task. Task has been rescheduled for 10 minutes: {DateTime.Now.AddMinutes(10)}"));
BackgroundJob.Schedule(() => ScanSeries(libraryId, seriesId, forceUpdate), TimeSpan.FromMinutes(10));
return;
}
@ -474,6 +487,7 @@ public class TaskScheduler : ITaskScheduler
HasAlreadyEnqueuedTask(ScannerService.Name, "ScanSeries", [seriesId, false], ScanQueue, checkRunningJobs);
}
/// <summary>
/// Checks if this same invocation is already enqueued or scheduled
/// </summary>
@ -482,6 +496,7 @@ public class TaskScheduler : ITaskScheduler
/// <param name="args">object[] of arguments in the order they are passed to enqueued job</param>
/// <param name="queue">Queue to check against. Defaults to "default"</param>
/// <param name="checkRunningJobs">Check against running jobs. Defaults to false.</param>
/// <param name="checkArgs">Check against arguments. Defaults to true.</param>
/// <returns></returns>
public static bool HasAlreadyEnqueuedTask(string className, string methodName, object[] args, string queue = DefaultQueue, bool checkRunningJobs = false)
{

View file

@ -150,13 +150,28 @@ public class LibraryWatcher : ILibraryWatcher
{
_logger.LogTrace("[LibraryWatcher] Changed: {FullPath}, {Name}, {ChangeType}", e.FullPath, e.Name, e.ChangeType);
if (e.ChangeType != WatcherChangeTypes.Changed) return;
BackgroundJob.Enqueue(() => ProcessChange(e.FullPath, string.IsNullOrEmpty(_directoryService.FileSystem.Path.GetExtension(e.Name))));
var isDirectoryChange = string.IsNullOrEmpty(_directoryService.FileSystem.Path.GetExtension(e.Name));
if (TaskScheduler.HasAlreadyEnqueuedTask("LibraryWatcher", "ProcessChange", [e.FullPath, isDirectoryChange],
checkRunningJobs: true))
{
return;
}
BackgroundJob.Enqueue(() => ProcessChange(e.FullPath, isDirectoryChange));
}
private void OnCreated(object sender, FileSystemEventArgs e)
{
_logger.LogTrace("[LibraryWatcher] Created: {FullPath}, {Name}", e.FullPath, e.Name);
BackgroundJob.Enqueue(() => ProcessChange(e.FullPath, !_directoryService.FileSystem.File.Exists(e.Name)));
var isDirectoryChange = !_directoryService.FileSystem.File.Exists(e.Name);
if (TaskScheduler.HasAlreadyEnqueuedTask("LibraryWatcher", "ProcessChange", [e.FullPath, isDirectoryChange],
checkRunningJobs: true))
{
return;
}
BackgroundJob.Enqueue(() => ProcessChange(e.FullPath, isDirectoryChange));
}
/// <summary>
@ -168,6 +183,11 @@ public class LibraryWatcher : ILibraryWatcher
var isDirectory = string.IsNullOrEmpty(_directoryService.FileSystem.Path.GetExtension(e.Name));
if (!isDirectory) return;
_logger.LogTrace("[LibraryWatcher] Deleted: {FullPath}, {Name}", e.FullPath, e.Name);
if (TaskScheduler.HasAlreadyEnqueuedTask("LibraryWatcher", "ProcessChange", [e.FullPath, true],
checkRunningJobs: true))
{
return;
}
BackgroundJob.Enqueue(() => ProcessChange(e.FullPath, true));
}
@ -298,7 +318,7 @@ public class LibraryWatcher : ILibraryWatcher
/// This is called via Hangfire to decrement the counter. Must work around a lock
/// </summary>
// ReSharper disable once MemberCanBePrivate.Global
public void UpdateLastBufferOverflow()
public static void UpdateLastBufferOverflow()
{
lock (Lock)
{

View file

@ -351,14 +351,17 @@ public class ParseScannedFiles
{
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.FileScanProgressEvent("File Scan Starting", library.Name, ProgressEventType.Started));
_logger.LogDebug("[ScannerService] Library {LibraryName} Step 1.A: Process {FolderCount} folders", library.Name, folders.Count());
var processedScannedSeries = new List<ScannedSeriesResult>();
//var processedScannedSeries = new ConcurrentBag<ScannedSeriesResult>();
foreach (var folderPath in folders)
{
try
{
_logger.LogDebug("\t[ScannerService] Library {LibraryName} Step 1.B: Scan files in {Folder}", library.Name, folderPath);
var scanResults = await ProcessFiles(folderPath, isLibraryScan, seriesPaths, library, forceCheck);
_logger.LogDebug("\t[ScannerService] Library {LibraryName} Step 1.C: Process files in {Folder}", library.Name, folderPath);
foreach (var scanResult in scanResults)
{
await ParseAndTrackSeries(library, seriesPaths, scanResult, processedScannedSeries);

View file

@ -235,7 +235,7 @@ public static class Parser
// [SugoiSugoi]_NEEDLESS_Vol.2_-_Disk_The_Informant_5_[ENG].rar, Yuusha Ga Shinda! - Vol.tbd Chapter 27.001 V2 Infection ①.cbz,
// Nagasarete Airantou - Vol. 30 Ch. 187.5 - Vol.30 Omake
new Regex(
@"^(?<Series>.+?)(\s*Chapter\s*\d+)?(\s|_|\-\s)+Vol(ume)?\.?(\d+|tbd|\s\d).+?",
@"^(?<Series>.+?)(?:\s*|_|\-\s*)+(?:Ch(?:apter|\.|)\s*\d+(?:\.\d+)?(?:\s*|_|\-\s*)+)?Vol(?:ume|\.|)\s*(?:\d+|tbd)(?:\s|_|\-\s*).+",
MatchOptions, RegexTimeout),
// Ichiban_Ushiro_no_Daimaou_v04_ch34_[VISCANS].zip, VanDread-v01-c01.zip
new Regex(
@ -764,7 +764,10 @@ public static class Parser
var group = matches
.Select(match => match.Groups["Series"])
.FirstOrDefault(group => group.Success && group != Match.Empty);
if (group != null) return CleanTitle(group.Value);
if (group != null)
{
return CleanTitle(group.Value);
}
}
return string.Empty;

View file

@ -76,6 +76,7 @@ public enum ScanCancelReason
public class ScannerService : IScannerService
{
public const string Name = "ScannerService";
private const int Timeout = 60 * 60 * 60; // 2.5 days
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<ScannerService> _logger;
private readonly IMetadataService _metadataService;
@ -156,11 +157,11 @@ public class ScannerService : IScannerService
}
// TODO: Figure out why we have the library type restriction here
if (series != null && series.Library.Type is not (LibraryType.Book or LibraryType.LightNovel))
if (series != null)// && series.Library.Type is not (LibraryType.Book or LibraryType.LightNovel)
{
if (TaskScheduler.HasScanTaskRunningForSeries(series.Id))
{
_logger.LogInformation("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this series. Dropping request", folder);
_logger.LogDebug("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this series. Dropping request", folder);
return;
}
_logger.LogInformation("[ScannerService] Scan folder invoked for {Folder}, Series matched to folder and ScanSeries enqueued for 1 minute", folder);
@ -168,6 +169,7 @@ public class ScannerService : IScannerService
return;
}
// This is basically rework of what's already done in Library Watcher but is needed if invoked via API
var parentDirectory = _directoryService.GetParentDirectoryName(folder);
if (string.IsNullOrEmpty(parentDirectory)) return;
@ -183,7 +185,7 @@ public class ScannerService : IScannerService
{
if (TaskScheduler.HasScanTaskRunningForLibrary(library.Id))
{
_logger.LogInformation("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this library. Dropping request", folder);
_logger.LogDebug("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this library. Dropping request", folder);
return;
}
BackgroundJob.Schedule(() => ScanLibrary(library.Id, false, true), TimeSpan.FromMinutes(1));
@ -196,12 +198,21 @@ public class ScannerService : IScannerService
/// <param name="seriesId"></param>
/// <param name="bypassFolderOptimizationChecks">Not Used. Scan series will always force</param>
[Queue(TaskScheduler.ScanQueue)]
[DisableConcurrentExecution(Timeout)]
[AutomaticRetry(Attempts = 200, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
public async Task ScanSeries(int seriesId, bool bypassFolderOptimizationChecks = true)
{
if (TaskScheduler.HasAlreadyEnqueuedTask(Name, "ScanSeries", [seriesId, bypassFolderOptimizationChecks], TaskScheduler.ScanQueue))
{
_logger.LogInformation("[ScannerService] Scan series invoked but a task is already running/enqueued. Dropping request");
return;
}
var sw = Stopwatch.StartNew();
var series = await _unitOfWork.SeriesRepository.GetFullSeriesForSeriesIdAsync(seriesId);
if (series == null) return; // This can occur when UI deletes a series but doesn't update and user re-requests update
var existingChapterIdsToClean = await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(new[] {seriesId});
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId, LibraryIncludes.Folders | LibraryIncludes.FileTypes | LibraryIncludes.ExcludePatterns);
@ -434,7 +445,7 @@ public class ScannerService : IScannerService
// Check if any of the folder roots are not available (ie disconnected from network, etc) and fail if any of them are
if (folders.Any(f => !_directoryService.IsDriveMounted(f)))
{
_logger.LogCritical("Some of the root folders for library ({LibraryName} are not accessible. Please check that drives are connected and rescan. Scan will be aborted", libraryName);
_logger.LogCritical("[ScannerService] Some of the root folders for library ({LibraryName} are not accessible. Please check that drives are connected and rescan. Scan will be aborted", libraryName);
await _eventHub.SendMessageAsync(MessageFactory.Error,
MessageFactory.ErrorEvent("Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted",
@ -448,7 +459,7 @@ public class ScannerService : IScannerService
if (folders.Any(f => _directoryService.IsDirectoryEmpty(f)))
{
// That way logging and UI informing is all in one place with full context
_logger.LogError("Some of the root folders for the library are empty. " +
_logger.LogError("[ScannerService] Some of the root folders for the library are empty. " +
"Either your mount has been disconnected or you are trying to delete all series in the library. " +
"Scan has be aborted. " +
"Check that your mount is connected or change the library's root folder and rescan");
@ -465,17 +476,25 @@ public class ScannerService : IScannerService
}
[Queue(TaskScheduler.ScanQueue)]
[DisableConcurrentExecution(60 * 60 * 60)]
[DisableConcurrentExecution(Timeout)]
[AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
public async Task ScanLibraries(bool forceUpdate = false)
{
_logger.LogInformation("Starting Scan of All Libraries, Forced: {Forced}", forceUpdate);
_logger.LogInformation("[ScannerService] Starting Scan of All Libraries, Forced: {Forced}", forceUpdate);
foreach (var lib in await _unitOfWork.LibraryRepository.GetLibrariesAsync())
{
// BUG: This will trigger the first N libraries to scan over and over if there is always an interruption later in the chain
if (TaskScheduler.HasScanTaskRunningForLibrary(lib.Id))
{
// We don't need to send SignalR event as this is a background job that user doesn't need insight into
_logger.LogInformation("[ScannerService] Scan library invoked via nightly scan job but a task is already running for {LibraryName}. Rescheduling for 4 hours", lib.Name);
await Task.Delay(TimeSpan.FromHours(4));
}
await ScanLibrary(lib.Id, forceUpdate, true);
}
_processSeries.Reset();
_logger.LogInformation("Scan of All Libraries Finished");
_logger.LogInformation("[ScannerService] Scan of All Libraries Finished");
}
@ -488,13 +507,14 @@ public class ScannerService : IScannerService
/// <param name="forceUpdate">Defaults to false</param>
/// <param name="isSingleScan">Defaults to true. Is this a standalone invocation or is it in a loop?</param>
[Queue(TaskScheduler.ScanQueue)]
[DisableConcurrentExecution(60 * 60 * 60)]
[DisableConcurrentExecution(Timeout)]
[AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
public async Task ScanLibrary(int libraryId, bool forceUpdate = false, bool isSingleScan = true)
{
var sw = Stopwatch.StartNew();
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId,
LibraryIncludes.Folders | LibraryIncludes.FileTypes | LibraryIncludes.ExcludePatterns);
var libraryFolderPaths = library!.Folders.Select(fp => fp.Path).ToList();
if (!await CheckMounts(library.Name, libraryFolderPaths)) return;
@ -506,23 +526,27 @@ public class ScannerService : IScannerService
var shouldUseLibraryScan = !(await _unitOfWork.LibraryRepository.DoAnySeriesFoldersMatch(libraryFolderPaths));
if (!shouldUseLibraryScan)
{
_logger.LogError("Library {LibraryName} consists of one or more Series folders, using series scan", library.Name);
_logger.LogError("[ScannerService] Library {LibraryName} consists of one or more Series folders, using series scan", library.Name);
}
_logger.LogDebug("[ScannerService] Library {LibraryName} Step 1: Scan Files", library.Name);
var (scanElapsedTime, processedSeries) = await ScanFiles(library, libraryFolderPaths,
shouldUseLibraryScan, forceUpdate);
_logger.LogDebug("[ScannerService] Library {LibraryName} Step 2: Track Found Series", library.Name);
var parsedSeries = new Dictionary<ParsedSeries, IList<ParserInfo>>();
TrackFoundSeriesAndFiles(parsedSeries, processedSeries);
// We need to remove any keys where there is no actual parser info
_logger.LogDebug("[ScannerService] Library {LibraryName} Step 3: Process Parsed Series", library.Name);
var totalFiles = await ProcessParsedSeries(forceUpdate, parsedSeries, library, scanElapsedTime);
UpdateLastScanned(library);
_unitOfWork.LibraryRepository.Update(library);
_logger.LogDebug("[ScannerService] Library {LibraryName} Step 4: Save Library", library.Name);
if (await _unitOfWork.CommitAsync())
{
if (isSingleScan)
@ -543,6 +567,7 @@ public class ScannerService : IScannerService
totalFiles, parsedSeries.Count, sw.ElapsedMilliseconds, library.Name);
}
_logger.LogDebug("[ScannerService] Library {LibraryName} Step 5: Remove Deleted Series", library.Name);
await RemoveSeriesNotFound(parsedSeries, library);
}
else

View file

@ -91,12 +91,39 @@ public class VersionUpdaterService : IVersionUpdaterService
// Find the latest dto
var latestRelease = updateDtos[0]!;
var updateVersion = new Version(latestRelease.UpdateVersion);
var isNightly = BuildInfo.Version > new Version(latestRelease.UpdateVersion);
// isNightly can be true when we compare something like v0.8.1 vs v0.8.1.0
if (IsVersionEqualToBuildVersion(updateVersion))
{
//latestRelease.UpdateVersion = BuildInfo.Version.ToString();
isNightly = false;
}
latestRelease.IsOnNightlyInRelease = isNightly;
return updateDtos;
}
private static bool IsVersionEqualToBuildVersion(Version updateVersion)
{
return updateVersion.Revision < 0 && BuildInfo.Version.Revision == 0 &&
CompareWithoutRevision(BuildInfo.Version, updateVersion);
}
private static bool CompareWithoutRevision(Version v1, Version v2)
{
if (v1.Major != v2.Major)
return v1.Major == v2.Major;
if (v1.Minor != v2.Minor)
return v1.Minor == v2.Minor;
if (v1.Build != v2.Build)
return v1.Build == v2.Build;
return true;
}
public async Task<int> GetNumberOfReleasesBehind()
{
var updates = await GetAllReleases();
@ -109,6 +136,7 @@ public class VersionUpdaterService : IVersionUpdaterService
var updateVersion = new Version(update.Tag_Name.Replace("v", string.Empty));
var currentVersion = BuildInfo.Version.ToString(4);
return new UpdateNotificationDto()
{
CurrentVersion = currentVersion,
@ -118,7 +146,7 @@ public class VersionUpdaterService : IVersionUpdaterService
UpdateUrl = update.Html_Url,
IsDocker = OsInfo.IsDocker,
PublishDate = update.Published_At,
IsReleaseEqual = BuildInfo.Version == updateVersion,
IsReleaseEqual = IsVersionEqualToBuildVersion(updateVersion),
IsReleaseNewer = BuildInfo.Version < updateVersion,
};
}

View file

@ -345,6 +345,7 @@ public static class MessageFactory
EventType = ProgressEventType.Single,
Body = new
{
Name = Error,
Title = title,
SubTitle = subtitle,
}
@ -362,6 +363,7 @@ public static class MessageFactory
EventType = ProgressEventType.Single,
Body = new
{
Name = Info,
Title = title,
SubTitle = subtitle,
}

View file

@ -137,14 +137,14 @@ public class Startup
{
c.SwaggerDoc("v1", new OpenApiInfo
{
Version = BuildInfo.Version.ToString(),
Version = "3.1.0",
Title = "Kavita",
Description = "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required.",
Description = $"Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v{BuildInfo.Version.ToString()}",
License = new OpenApiLicense
{
Name = "GPL-3.0",
Url = new Uri("https://github.com/Kareadita/Kavita/blob/develop/LICENSE")
}
},
});
var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
@ -176,7 +176,7 @@ public class Startup
Url = "{protocol}://{hostpath}",
Variables = new Dictionary<string, OpenApiServerVariable>
{
{ "protocol", new OpenApiServerVariable { Default = "http", Enum = new List<string> { "http", "https" } } },
{ "protocol", new OpenApiServerVariable { Default = "http", Enum = ["http", "https"]} },
{ "hostpath", new OpenApiServerVariable { Default = "localhost:5000" } }
}
});
@ -207,7 +207,7 @@ public class Startup
.UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings()
.UseInMemoryStorage());
//.UseSQLiteStorage("config/Hangfire.db")); // UseSQLiteStorage - SQLite has some issues around resuming jobs when aborted (and locking can cause high utilization)
//.UseSQLiteStorage("config/Hangfire.db")); // UseSQLiteStorage - SQLite has some issues around resuming jobs when aborted (and locking can cause high utilization) (NOTE: There is code to clear jobs on startup a redditor gave me)
// Add the processing server as IHostedService
services.AddHangfireServer(options =>
@ -427,8 +427,8 @@ public class Startup
catch (Exception)
{
/* Swallow Exception */
Console.WriteLine($"Kavita - v{BuildInfo.Version}");
}
Console.WriteLine($"Kavita - v{BuildInfo.Version}");
});
logger.LogInformation("Starting with base url as {BaseUrl}", basePath);

View file

@ -2,7 +2,7 @@
"TokenKey": "super secret unguessable key that is longer because we require it",
"Port": 5000,
"IpAddresses": "0.0.0.0,::",
"BaseUrl": "/tes/",
"BaseUrl": "/",
"Cache": 75,
"AllowIFraming": false
}

View file

@ -3,7 +3,7 @@
<TargetFramework>net8.0</TargetFramework>
<Company>kavitareader.com</Company>
<Product>Kavita</Product>
<AssemblyVersion>0.8.1.12</AssemblyVersion>
<AssemblyVersion>0.8.2.0</AssemblyVersion>
<NeutralLanguage>en</NeutralLanguage>
<TieredPGO>true</TieredPGO>
</PropertyGroup>
@ -14,10 +14,10 @@
<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="SonarAnalyzer.CSharp" Version="9.26.0.92422">
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.28.0.94264">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="xunit.assert" Version="2.8.1" />
</ItemGroup>
</Project>
</Project>

View file

@ -550,7 +550,7 @@ export class ActionFactoryService {
}
],
},
// RBS will handle rendering this, so non-admins with download are appicable
// RBS will handle rendering this, so non-admins with download are applicable
{
action: Action.Download,
title: 'download',
@ -583,6 +583,20 @@ export class ActionFactoryService {
class: 'danger',
children: [],
},
{
action: Action.Promote,
title: 'promote',
callback: this.dummyCallback,
requiresAdmin: false,
children: [],
},
{
action: Action.UnPromote,
title: 'unpromote',
callback: this.dummyCallback,
requiresAdmin: false,
children: [],
},
];
this.bookmarkActions = [

View file

@ -24,6 +24,7 @@ import {UserCollection} from "../_models/collection-tag";
import {CollectionTagService} from "./collection-tag.service";
import {SmartFilter} from "../_models/metadata/v2/smart-filter";
import {FilterService} from "./filter.service";
import {ReadingListService} from "./reading-list.service";
export type LibraryActionCallback = (library: Partial<Library>) => void;
export type SeriesActionCallback = (series: Series) => void;
@ -48,7 +49,8 @@ export class ActionService implements OnDestroy {
constructor(private libraryService: LibraryService, private seriesService: SeriesService,
private readerService: ReaderService, private toastr: ToastrService, private modalService: NgbModal,
private confirmService: ConfirmService, private memberService: MemberService, private deviceService: DeviceService,
private readonly collectionTagService: CollectionTagService, private filterService: FilterService) { }
private readonly collectionTagService: CollectionTagService, private filterService: FilterService,
private readonly readingListService: ReadingListService) { }
ngOnDestroy() {
this.onDestroy.next();
@ -386,7 +388,7 @@ export class ActionService implements OnDestroy {
}
/**
* Mark all series as Unread.
* Mark all collections as promoted/unpromoted.
* @param collections UserCollection, should have id, pagesRead populated
* @param promoted boolean, promoted state
* @param callback Optional callback to perform actions after API completes
@ -422,6 +424,43 @@ export class ActionService implements OnDestroy {
});
}
/**
* Mark all reading lists as promoted/unpromoted.
* @param readingLists UserCollection, should have id, pagesRead populated
* @param promoted boolean, promoted state
* @param callback Optional callback to perform actions after API completes
*/
promoteMultipleReadingLists(readingLists: Array<ReadingList>, promoted: boolean, callback?: BooleanActionCallback) {
this.readingListService.promoteMultipleReadingLists(readingLists.map(v => v.id), promoted).pipe(take(1)).subscribe(() => {
if (promoted) {
this.toastr.success(translate('toasts.reading-list-promoted'));
} else {
this.toastr.success(translate('toasts.reading-list-unpromoted'));
}
if (callback) {
callback(true);
}
});
}
/**
* Deletes multiple collections
* @param readingLists ReadingList, should have id
* @param callback Optional callback to perform actions after API completes
*/
async deleteMultipleReadingLists(readingLists: Array<ReadingList>, callback?: BooleanActionCallback) {
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-reading-list'))) return;
this.readingListService.deleteMultipleReadingLists(readingLists.map(v => v.id)).pipe(take(1)).subscribe(() => {
this.toastr.success(translate('toasts.reading-lists-deleted'));
if (callback) {
callback(true);
}
});
}
addMultipleToReadingList(seriesId: number, volumes: Array<Volume>, chapters?: Array<Chapter>, callback?: BooleanActionCallback) {
if (this.readingListModalRef != null) { return; }
this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md', fullscreen: 'md' });

View file

@ -8,7 +8,7 @@ import { PaginatedResult } from '../_models/pagination';
import { ReadingList, ReadingListItem } from '../_models/reading-list';
import { CblImportSummary } from '../_models/reading-list/cbl/cbl-import-summary';
import { TextResonse } from '../_types/text-response';
import { ActionItem } from './action-factory.service';
import {Action, ActionItem} from './action-factory.service';
@Injectable({
providedIn: 'root'
@ -87,9 +87,15 @@ export class ReadingListService {
return this.httpClient.post<string>(this.baseUrl + 'readinglist/remove-read?readingListId=' + readingListId, {}, TextResonse);
}
actionListFilter(action: ActionItem<ReadingList>, readingList: ReadingList, isAdmin: boolean) {
if (readingList?.promoted && !isAdmin) return false;
actionListFilter(action: ActionItem<ReadingList>, readingList: ReadingList, canPromote: boolean) {
const isPromotionAction = action.action == Action.Promote || action.action == Action.UnPromote;
if (isPromotionAction) return canPromote;
return true;
// if (readingList?.promoted && !isAdmin) return false;
// return true;
}
nameExists(name: string) {
@ -107,4 +113,14 @@ export class ReadingListService {
getCharacters(readingListId: number) {
return this.httpClient.get<Array<Person>>(this.baseUrl + 'readinglist/characters?readingListId=' + readingListId);
}
promoteMultipleReadingLists(listIds: Array<number>, promoted: boolean) {
return this.httpClient.post(this.baseUrl + 'readinglist/promote-multiple', {readingListIds: listIds, promoted}, TextResonse);
}
deleteMultipleReadingLists(listIds: Array<number>) {
return this.httpClient.post(this.baseUrl + 'readinglist/delete-multiple', {readingListIds: listIds}, TextResonse);
}
}

View file

@ -1,9 +1,9 @@
import { HttpClient } from '@angular/common/http';
import {inject, Injectable} from '@angular/core';
import {Inject, inject, Injectable} from '@angular/core';
import { environment } from 'src/environments/environment';
import { UserReadStatistics } from '../statistics/_models/user-read-statistics';
import { PublicationStatusPipe } from '../_pipes/publication-status.pipe';
import { map } from 'rxjs';
import {asyncScheduler, finalize, map, tap} from 'rxjs';
import { MangaFormatPipe } from '../_pipes/manga-format.pipe';
import { FileExtensionBreakdown } from '../statistics/_models/file-breakdown';
import { TopUserRead } from '../statistics/_models/top-reads';
@ -15,6 +15,10 @@ import { MangaFormat } from '../_models/manga-format';
import { TextResonse } from '../_types/text-response';
import {TranslocoService} from "@ngneat/transloco";
import {KavitaPlusMetadataBreakdown} from "../statistics/_models/kavitaplus-metadata-breakdown";
import {throttleTime} from "rxjs/operators";
import {DEBOUNCE_TIME} from "../shared/_services/download.service";
import {download} from "../shared/_models/download";
import {Saver, SAVER} from "../_providers/saver.provider";
export enum DayOfWeek
{
@ -37,7 +41,7 @@ export class StatisticsService {
publicationStatusPipe = new PublicationStatusPipe(this.translocoService);
mangaFormatPipe = new MangaFormatPipe(this.translocoService);
constructor(private httpClient: HttpClient) { }
constructor(private httpClient: HttpClient, @Inject(SAVER) private save: Saver) { }
getUserStatistics(userId: number, libraryIds: Array<number> = []) {
// TODO: Convert to httpParams object
@ -109,6 +113,20 @@ export class StatisticsService {
return this.httpClient.get<FileExtensionBreakdown>(this.baseUrl + 'stats/server/file-breakdown');
}
downloadFileBreakdown(extension: string) {
return this.httpClient.get(this.baseUrl + 'stats/server/file-extension?fileExtension=' + encodeURIComponent(extension),
{observe: 'events', responseType: 'blob', reportProgress: true}
).pipe(
throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
download((blob, filename) => {
this.save(blob, decodeURIComponent(filename));
}),
// tap((d) => this.updateDownloadState(d, downloadType, subtitle, 0)),
// finalize(() => this.finalizeDownloadState(downloadType, subtitle))
);
}
getReadCountByDay(userId: number = 0, days: number = 0) {
return this.httpClient.get<Array<any>>(this.baseUrl + 'stats/reading-count-by-day?userId=' + userId + '&days=' + days);
}

View file

@ -1,4 +1,4 @@
import { Directive, EventEmitter, Input, Output } from "@angular/core";
import {ChangeDetectorRef, Directive, EventEmitter, inject, Input, OnInit, Output} from "@angular/core";
export const compare = (v1: string | number, v2: string | number) => (v1 < v2 ? -1 : v1 > v2 ? 1 : 0);
export type SortColumn<T> = keyof T | '';
@ -11,6 +11,7 @@ export interface SortEvent<T> {
}
@Directive({
// eslint-disable-next-line @angular-eslint/directive-selector
selector: 'th[sortable]',
host: {
'[class.asc]': 'direction === "asc"',
@ -29,4 +30,4 @@ export class SortableHeader<T> {
this.direction = rotate[this.direction];
this.sort.emit({ column: this.sortable, direction: this.direction });
}
}
}

View file

@ -4,7 +4,7 @@ import {ReplaySubject} from 'rxjs';
import {filter} from 'rxjs/operators';
import {Action, ActionFactoryService, ActionItem} from '../_services/action-factory.service';
type DataSource = 'volume' | 'chapter' | 'special' | 'series' | 'bookmark' | 'sideNavStream' | 'collection';
type DataSource = 'volume' | 'chapter' | 'special' | 'series' | 'bookmark' | 'sideNavStream' | 'collection' | 'readingList';
/**
* Responsible for handling selections on cards. Can handle multiple card sources next to each other in different loops.
@ -159,6 +159,10 @@ export class BulkSelectionService {
return this.applyFilterToList(this.actionFactory.getCollectionTagActions(callback), [Action.Promote, Action.UnPromote, Action.Delete]);
}
if (Object.keys(this.selectedCards).filter(item => item === 'readingList').length > 0) {
return this.applyFilterToList(this.actionFactory.getReadingListActions(callback), [Action.Promote, Action.UnPromote, Action.Delete]);
}
return this.applyFilterToList(this.actionFactory.getVolumeActions(callback), allowedActions);
}

View file

@ -4,6 +4,7 @@
<h6 subtitle>{{t('item-count', {num: collections.length | number})}}</h6>
</app-side-nav-companion-bar>
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
<app-card-detail-layout
[isLoading]="isLoading"
[items]="collections"

View file

@ -4,27 +4,33 @@
<app-card-actionables [actions]="actions" (actionHandler)="performAction($event)"></app-card-actionables>
<span>{{libraryName}}</span>
</h2>
<h6 subtitle class="subtitle-with-actionables" *ngIf="active.fragment === ''">{{t('common.series-count', {num: pagination.totalItems | number})}} </h6>
@if (active.fragment === '') {
<h6 subtitle class="subtitle-with-actionables">{{t('common.series-count', {num: pagination.totalItems | number})}} </h6>
}
</app-side-nav-companion-bar>
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
<app-loading [absolute]="true" [loading]="bulkLoader"></app-loading>
<app-card-detail-layout *ngIf="filter"
[isLoading]="loadingSeries"
[items]="series"
[pagination]="pagination"
[filterSettings]="filterSettings"
[trackByIdentity]="trackByIdentity"
[filterOpen]="filterOpen"
[jumpBarKeys]="jumpKeys"
[refresh]="refresh"
(applyFilter)="updateFilter($event)"
>
<ng-template #cardItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="libraryId" [suppressLibraryLink]="true" (reload)="loadPage()"
(selection)="bulkSelectionService.handleCardSelection('series', position, series.length, $event)"
[selected]="bulkSelectionService.isCardSelected('series', position)" [allowSelection]="true"></app-series-card>
</ng-template>
</app-card-detail-layout>
@if (filter) {
<app-card-detail-layout
[isLoading]="loadingSeries"
[items]="series"
[pagination]="pagination"
[filterSettings]="filterSettings"
[trackByIdentity]="trackByIdentity"
[filterOpen]="filterOpen"
[jumpBarKeys]="jumpKeys"
[refresh]="refresh"
(applyFilter)="updateFilter($event)"
>
<ng-template #cardItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="libraryId" [suppressLibraryLink]="true" (reload)="loadPage()"
(selection)="bulkSelectionService.handleCardSelection('series', position, series.length, $event)"
[selected]="bulkSelectionService.isCardSelected('series', position)" [allowSelection]="true"></app-series-card>
</ng-template>
</app-card-detail-layout>
}
</ng-container>

View file

@ -33,18 +33,18 @@ import {SentenceCasePipe} from '../_pipes/sentence-case.pipe';
import {BulkOperationsComponent} from '../cards/bulk-operations/bulk-operations.component';
import {SeriesCardComponent} from '../cards/series-card/series-card.component';
import {CardDetailLayoutComponent} from '../cards/card-detail-layout/card-detail-layout.component';
import {DecimalPipe, NgFor, NgIf} from '@angular/common';
import {DecimalPipe} from '@angular/common';
import {NgbNav, NgbNavContent, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavOutlet} from '@ng-bootstrap/ng-bootstrap';
import {
SideNavCompanionBarComponent
} from '../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
import {TranslocoDirective} from "@ngneat/transloco";
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
import {MetadataService} from "../_services/metadata.service";
import {FilterComparison} from "../_models/metadata/v2/filter-comparison";
import {FilterField} from "../_models/metadata/v2/filter-field";
import {CardActionablesComponent} from "../_single-module/card-actionables/card-actionables.component";
import {LoadingComponent} from "../shared/loading/loading.component";
import {debounceTime, ReplaySubject, tap} from "rxjs";
@Component({
selector: 'app-library-detail',
@ -52,14 +52,25 @@ import {LoadingComponent} from "../shared/loading/loading.component";
styleUrls: ['./library-detail.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [SideNavCompanionBarComponent, CardActionablesComponent, NgbNav, NgFor, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent, NgIf
, CardDetailLayoutComponent, SeriesCardComponent, BulkOperationsComponent, NgbNavOutlet, DecimalPipe, SentenceCasePipe, TranslocoDirective, LoadingComponent]
imports: [SideNavCompanionBarComponent, CardActionablesComponent, NgbNav, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent,
CardDetailLayoutComponent, SeriesCardComponent, BulkOperationsComponent, NgbNavOutlet, DecimalPipe, SentenceCasePipe, TranslocoDirective, LoadingComponent]
})
export class LibraryDetailComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef);
private readonly metadataService = inject(MetadataService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly seriesService = inject(SeriesService);
private readonly libraryService = inject(LibraryService);
private readonly titleService = inject(Title);
private readonly actionFactoryService = inject(ActionFactoryService);
private readonly actionService = inject(ActionService);
private readonly hubService = inject(MessageHubService);
private readonly utilityService = inject(UtilityService);
private readonly filterUtilityService = inject(FilterUtilitiesService);
public readonly navService = inject(NavService);
public readonly bulkSelectionService = inject(BulkSelectionService);
libraryId!: number;
libraryName = '';
@ -82,6 +93,8 @@ export class LibraryDetailComponent implements OnInit {
];
active = this.tabs[0];
loadPageSource = new ReplaySubject(1);
loadPage$ = this.loadPageSource.asObservable();
bulkActionCallback = async (action: ActionItem<any>, data: any) => {
const selectedSeriesIndices = this.bulkSelectionService.getSelectedCardsForSource('series');
@ -142,10 +155,8 @@ export class LibraryDetailComponent implements OnInit {
}
}
constructor(private route: ActivatedRoute, private router: Router, private seriesService: SeriesService,
private libraryService: LibraryService, private titleService: Title, private actionFactoryService: ActionFactoryService,
private actionService: ActionService, public bulkSelectionService: BulkSelectionService, private hubService: MessageHubService,
private utilityService: UtilityService, public navService: NavService, private filterUtilityService: FilterUtilitiesService) {
constructor() {
const routeId = this.route.snapshot.paramMap.get('libraryId');
if (routeId === null) {
this.router.navigateByUrl('/home');
@ -180,6 +191,8 @@ export class LibraryDetailComponent implements OnInit {
this.filterSettings.presetsV2 = this.filter;
this.loadPage$.pipe(takeUntilDestroyed(this.destroyRef), debounceTime(100), tap(_ => this.loadPage())).subscribe();
this.cdRef.markForCheck();
});
}
@ -191,7 +204,7 @@ export class LibraryDetailComponent implements OnInit {
const seriesAdded = event.payload as SeriesAddedEvent;
if (seriesAdded.libraryId !== this.libraryId) return;
if (!this.utilityService.deepEqual(this.filter, this.filterActiveCheck)) {
this.loadPage();
this.loadPageSource.next(true);
return;
}
this.seriesService.getSeries(seriesAdded.seriesId).subscribe(s => {
@ -211,7 +224,7 @@ export class LibraryDetailComponent implements OnInit {
const seriesRemoved = event.payload as SeriesRemovedEvent;
if (seriesRemoved.libraryId !== this.libraryId) return;
if (!this.utilityService.deepEqual(this.filter, this.filterActiveCheck)) {
this.loadPage(); // TODO: This can be quite expensive when bulk deleting. We can refactor this to an ReplaySubject to debounce
this.loadPageSource.next(true);
return;
}
@ -286,12 +299,12 @@ export class LibraryDetailComponent implements OnInit {
this.filter = data.filterV2;
if (data.isFirst) {
this.loadPage();
this.loadPageSource.next(true);
return;
}
this.filterUtilityService.updateUrlFromFilter(this.filter).subscribe((encodedFilter) => {
this.loadPage();
this.loadPageSource.next(true);
});
}

View file

@ -1,39 +1,49 @@
<ng-container *transloco="let t; read: 'infinite-scroller'">
<div class="fixed-top overlay" *ngIf="showDebugBar()">
<strong>Captures Scroll Events:</strong> {{!this.isScrolling && this.allImagesLoaded}}
<strong>Is Scrolling:</strong> {{isScrollingForwards() ? 'Forwards' : 'Backwards'}} {{this.isScrolling}}
<strong>All Images Loaded:</strong> {{this.allImagesLoaded}}
<strong>Prefetched</strong> {{minPageLoaded}}-{{maxPageLoaded}}
<strong>Pages:</strong> {{pageNum}} / {{totalPages - 1}}
<strong>At Top:</strong> {{atTop}}
<strong>At Bottom:</strong> {{atBottom}}
<strong>Total Height:</strong> {{getTotalHeight()}}
<strong>Total Scroll:</strong> {{getTotalScroll()}}
<strong>Scroll Top:</strong> {{getScrollTop()}}
</div>
<div *ngIf="atTop" #topSpacer class="spacer top" role="alert" (click)="loadPrevChapter.emit()">
<div style="height: 200px"></div>
<div>
<button class="btn btn-icon mx-auto">
<i class="fa fa-angle-double-up animate" aria-hidden="true"></i>
</button>
<span class="mx-auto text">{{t('continuous-reading-prev-chapter')}}</span>
<button class="btn btn-icon mx-auto">
<i class="fa fa-angle-double-up animate" aria-hidden="true"></i>
</button>
<span class="visually-hidden">{{t('continuous-reading-prev-chapter-alt')}}</span>
@if (showDebugBar()) {
<div class="fixed-top overlay">
<strong>Captures Scroll Events:</strong> {{!this.isScrolling && this.allImagesLoaded}}
<strong>Is Scrolling:</strong> {{isScrollingForwards() ? 'Forwards' : 'Backwards'}} {{this.isScrolling}}
<strong>All Images Loaded:</strong> {{this.allImagesLoaded}}
<strong>Prefetched</strong> {{minPageLoaded}}-{{maxPageLoaded}}
<strong>Pages:</strong> {{pageNum}} / {{totalPages - 1}}
<strong>At Top:</strong> {{atTop}}
<strong>At Bottom:</strong> {{atBottom}}
<strong>Total Height:</strong> {{getTotalHeight()}}
<strong>Total Scroll:</strong> {{getTotalScroll()}}
<strong>Scroll Top:</strong> {{getScrollTop()}}
</div>
</div>
}
@if (atTop) {
<div #topSpacer class="spacer top" role="alert" (click)="loadPrevChapter.emit()">
<div class="empty-space"></div>
<div>
<button class="btn btn-icon mx-auto">
<i class="fa fa-angle-double-up animate" aria-hidden="true"></i>
</button>
<span class="mx-auto text">{{t('continuous-reading-prev-chapter')}}</span>
<button class="btn btn-icon mx-auto">
<i class="fa fa-angle-double-up animate" aria-hidden="true"></i>
</button>
<span class="visually-hidden">{{t('continuous-reading-prev-chapter-alt')}}</span>
</div>
</div>
}
<div infinite-scroll [infiniteScrollDistance]="1" [infiniteScrollThrottle]="50">
<ng-container *ngFor="let item of webtoonImages | async; let index = index;">
<img src="{{item.src}}" style="display: block"
@for(item of webtoonImages | async; let index = $index; track item.src) {
<img src="{{item.src}}" style="display: block;" [ngStyle]="{'width': widthOverride$ | async}"
[style.filter]="(darkness$ | async) ?? '' | safeStyle"
class="mx-auto {{pageNum === item.page && showDebugOutline() ? 'active': ''}} {{areImagesWiderThanWindow ? 'full-width' : ''}}"
rel="nofollow" alt="image" (load)="onImageLoad($event)" id="page-{{item.page}}" [attr.page]="item.page" ondragstart="return false;" onselectstart="return false;">
</ng-container>
rel="nofollow"
alt="image"
(load)="onImageLoad($event)"
id="page-{{item.page}}"
[attr.page]="item.page"
ondragstart="return false;"
onselectstart="return false;">
}
</div>
<div #bottomSpacer class="spacer bottom" role="alert" (click)="loadNextChapter.emit()">
@ -47,7 +57,7 @@
</button>
<span class="visually-hidden">{{t('continuous-reading-next-chapter-alt')}}</span>
</div>
<div style="height: 200px"></div>
<div class="empty-space"></div>
</div>
</ng-container>

View file

@ -21,8 +21,12 @@
.text {
z-index: 101;
}
.empty-space {
height: 200px;
}
}

View file

@ -1,4 +1,4 @@
import { DOCUMENT, NgIf, NgFor, AsyncPipe } from '@angular/common';
import {DOCUMENT, AsyncPipe, NgStyle} from '@angular/common';
import {
AfterViewInit,
ChangeDetectionStrategy,
@ -16,7 +16,7 @@ import {
Renderer2,
SimpleChanges, ViewChild
} from '@angular/core';
import {BehaviorSubject, filter, fromEvent, map, Observable, of, ReplaySubject} from 'rxjs';
import {BehaviorSubject, fromEvent, map, Observable, of, ReplaySubject} from 'rxjs';
import { debounceTime } from 'rxjs/operators';
import { ScrollService } from 'src/app/_services/scroll.service';
import { ReaderService } from '../../../_services/reader.service';
@ -25,7 +25,6 @@ import { WebtoonImage } from '../../_models/webtoon-image';
import { ManagaReaderService } from '../../_service/managa-reader.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {TranslocoDirective} from "@ngneat/transloco";
import {MangaReaderComponent} from "../manga-reader/manga-reader.component";
import {InfiniteScrollModule} from "ngx-infinite-scroll";
import {ReaderSetting} from "../../_models/reader-setting";
import {SafeStylePipe} from "../../../_pipes/safe-style.pipe";
@ -63,7 +62,7 @@ const enum DEBUG_MODES {
styleUrls: ['./infinite-scroller.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [NgIf, NgFor, AsyncPipe, TranslocoDirective, InfiniteScrollModule, SafeStylePipe]
imports: [AsyncPipe, TranslocoDirective, InfiniteScrollModule, SafeStylePipe, NgStyle]
})
export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy, AfterViewInit {
@ -174,6 +173,14 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy,
*/
debugLogFilter: Array<string> = ['[PREFETCH]', '[Intersection]', '[Visibility]', '[Image Load]'];
/**
* Width override for maunal width control
* 2 observables needed to avoid flickering, probably due to data races, when changing the width
* this allows to precicely define execution order
*/
widthOverride$ : Observable<string> = new Observable<string>();
widthSliderValue$ : Observable<string> = new Observable<string>();
get minPageLoaded() {
return Math.min(...Object.values(this.imagesLoaded));
}
@ -232,6 +239,31 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy,
takeUntilDestroyed(this.destroyRef)
);
this.widthSliderValue$ = this.readerSettings$.pipe(
map(values => (parseInt(values.widthSlider) <= 0) ? '' : values.widthSlider + '%'),
takeUntilDestroyed(this.destroyRef)
);
this.widthOverride$ = this.widthSliderValue$;
//perfom jump so the page stays in view
this.widthSliderValue$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => {
this.currentPageElem = this.document.querySelector('img#page-' + this.pageNum);
if(!this.currentPageElem)
return;
let images = Array.from(document.querySelectorAll('img[id^="page-"]')) as HTMLImageElement[];
images.forEach((img) => {
this.renderer.setStyle(img, "width", val);
});
this.widthOverride$ = this.widthSliderValue$;
this.prevScrollPosition = this.currentPageElem.getBoundingClientRect().top;
this.currentPageElem.scrollIntoView();
this.cdRef.markForCheck();
});
if (this.goToPage) {
this.goToPage.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(page => {
const isSamePage = this.pageNum === page;
@ -381,7 +413,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy,
this.cdRef.markForCheck();
}
if (totalScroll === totalHeight && !this.atBottom) {
if (totalHeight != 0 && totalScroll >= totalHeight && !this.atBottom) {
this.atBottom = true;
this.cdRef.markForCheck();
this.setPageNum(this.totalPages);
@ -392,6 +424,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy,
document.body.scrollTop = this.previousScrollHeightMinusTop + (SPACER_SCROLL_INTO_PX / 2);
this.cdRef.markForCheck();
});
this.checkIfShouldTriggerContinuousReader()
} else if (totalScroll >= totalHeight + SPACER_SCROLL_INTO_PX && this.atBottom) {
// This if statement will fire once we scroll into the spacer at all
this.loadNextChapter.emit();

View file

@ -276,6 +276,18 @@
min="10" max="100" step="1" formControlName="darkness">
</div>
<div class="col-md-6 col-sm-12">
<label for="width-override-slider" class="form-label">{{t('width-override-label')}}:
@if (widthOverrideLabel$ | async; as widthOverrideLabel) {
{{ widthOverrideLabel ? widthOverrideLabel : t('off') }}
}
@else {
{{t('off')}}
}
</label>
<input id="width-override-slider" type="range" min="0" max="100" class="form-range" formControlName="widthSlider">
</div>
<div class="col-md-6 col-sm-12">
<button class="btn btn-primary" (click)="savePref()">{{t('save-globally')}}</button>

View file

@ -19,6 +19,7 @@ import {
BehaviorSubject,
debounceTime,
distinctUntilChanged,
filter,
forkJoin,
fromEvent,
map,
@ -398,6 +399,10 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
* Show and log debug information
*/
debugMode: boolean = false;
/**
* Width override label for manual width control
*/
widthOverrideLabel$ : Observable<string> = new Observable<string>();
// Renderer interaction
readerSettings$!: Observable<ReaderSetting>;
@ -513,6 +518,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
autoCloseMenu: new FormControl(this.autoCloseMenu),
pageSplitOption: new FormControl(this.pageSplitOption),
fittingOption: new FormControl(this.mangaReaderService.translateScalingOption(this.scalingOption)),
widthSlider: new FormControl('none'),
layoutMode: new FormControl(this.layoutMode),
darkness: new FormControl(100),
emulateBook: new FormControl(this.user.preferences.emulateBook),
@ -549,8 +555,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
takeUntilDestroyed(this.destroyRef)
).subscribe(() => {});
this.setupWidthOverrideTriggers();
this.generalSettingsForm.get('layoutMode')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => {
@ -560,11 +565,13 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
if (this.layoutMode === LayoutMode.Single) {
this.generalSettingsForm.get('pageSplitOption')?.setValue(this.user.preferences.pageSplitOption);
this.generalSettingsForm.get('pageSplitOption')?.enable();
this.generalSettingsForm.get('widthSlider')?.enable();
this.generalSettingsForm.get('fittingOption')?.enable();
this.generalSettingsForm.get('emulateBook')?.enable();
} else {
this.generalSettingsForm.get('pageSplitOption')?.setValue(PageSplitOption.NoSplit);
this.generalSettingsForm.get('pageSplitOption')?.disable();
this.generalSettingsForm.get('widthSlider')?.disable();
this.generalSettingsForm.get('fittingOption')?.setValue(this.mangaReaderService.translateScalingOption(ScalingOption.FitToHeight));
this.generalSettingsForm.get('fittingOption')?.disable();
this.generalSettingsForm.get('emulateBook')?.enable();
@ -692,10 +699,73 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}
}
/**
* Width override is only valid under the following conditions:
* Image Scaling is Width
* Reader Mode is Webtoon
*
* In all other cases, the form will be disabled and set to 0 which indicates default/off state.
*/
setupWidthOverrideTriggers() {
const widthOverrideControl = this.generalSettingsForm.get('widthSlider')!;
const enableWidthOverride = () => {
widthOverrideControl.enable();
};
const disableWidthOverride = () => {
widthOverrideControl.setValue(0);
widthOverrideControl.disable();
};
const handleControlChanges = () => {
const fitting = this.generalSettingsForm.get('fittingOption')?.value;
const splitting = this.generalSettingsForm.get('pageSplitOption')?.value;
if ((PageSplitOption.FitSplit == splitting && FITTING_OPTION.WIDTH == fitting) || this.readerMode === ReaderMode.Webtoon) {
enableWidthOverride();
} else {
disableWidthOverride();
}
};
// Reader mode changes
this.readerModeSubject.asObservable()
.pipe(
filter(v => v === ReaderMode.Webtoon),
tap(enableWidthOverride),
takeUntilDestroyed(this.destroyRef)
)
.subscribe();
// Page split option changes
this.generalSettingsForm.get('pageSplitOption')?.valueChanges.pipe(
distinctUntilChanged(),
tap(handleControlChanges),
takeUntilDestroyed(this.destroyRef)
).subscribe();
// Fitting option changes
this.generalSettingsForm.get('fittingOption')?.valueChanges.pipe(
tap(handleControlChanges),
takeUntilDestroyed(this.destroyRef)
).subscribe();
// Set the default override to 0
widthOverrideControl.setValue(0);
//send the current width override value to the label
this.widthOverrideLabel$ = this.readerSettings$?.pipe(
map(values => (parseInt(values.widthSlider) <= 0) ? '' : values.widthSlider + '%'),
takeUntilDestroyed(this.destroyRef)
);
}
createReaderSettingsUpdate() {
return {
pageSplit: parseInt(this.generalSettingsForm.get('pageSplitOption')?.value, 10),
fitting: (this.generalSettingsForm.get('fittingOption')?.value as FITTING_OPTION),
widthSlider: this.generalSettingsForm.get('widthSlider')?.value,
layoutMode: this.layoutMode,
darkness: parseInt(this.generalSettingsForm.get('darkness')?.value + '', 10) || 100,
pagingDirection: this.pagingDirection,

View file

@ -3,6 +3,7 @@
[style.filter]="(darkness$ | async) ?? '' | safeStyle" [style.height]="(imageContainerHeight$ | async) ?? '' | safeStyle">
@if(currentImage) {
<img alt=" "
style="width: {{widthOverride$ | async}}"
#image
[src]="currentImage.src"
id="image-1"

View file

@ -53,6 +53,11 @@ export class SingleRendererComponent implements OnInit, ImageRenderer {
pageNum: number = 0;
maxPages: number = 1;
/**
* Width override for maunal width control
*/
widthOverride$ : Observable<string> = new Observable<string>();
get ReaderMode() {return ReaderMode;}
get LayoutMode() {return LayoutMode;}
@ -67,6 +72,13 @@ export class SingleRendererComponent implements OnInit, ImageRenderer {
takeUntilDestroyed(this.destroyRef)
);
//handle manual width
this.widthOverride$ = this.readerSettings$.pipe(
map(values => (parseInt(values.widthSlider) <= 0) ? '' : values.widthSlider + '%'),
takeUntilDestroyed(this.destroyRef)
);
this.emulateBookClass$ = this.readerSettings$.pipe(
map(data => data.emulateBook),
map(enabled => enabled ? 'book-shadow' : ''),

View file

@ -1,7 +1,7 @@
export enum FITTING_OPTION {
HEIGHT = 'full-height',
WIDTH = 'full-width',
ORIGINAL = 'original'
ORIGINAL = 'original',
}
/**
@ -12,9 +12,9 @@ export enum SPLIT_PAGE_PART {
LEFT_PART = 'left',
RIGHT_PART = 'right'
}
export enum PAGING_DIRECTION {
FORWARD = 1,
BACKWARDS = -1,
}

View file

@ -6,9 +6,10 @@ import { FITTING_OPTION, PAGING_DIRECTION } from "./reader-enums";
export interface ReaderSetting {
pageSplit: PageSplitOption;
fitting: FITTING_OPTION;
widthSlider: string;
layoutMode: LayoutMode;
darkness: number;
pagingDirection: PAGING_DIRECTION;
readerMode: ReaderMode;
emulateBook: boolean;
}
}

View file

@ -1,139 +1,118 @@
<ng-container *transloco="let t; read: 'events-widget'">
<ng-container *ngIf="isAdmin$ | async">
<ng-container *ngIf="downloadService.activeDownloads$ | async as activeDownloads">
<ng-container *ngIf="errors$ | async as errors">
<ng-container *ngIf="infos$ | async as infos">
<button type="button" class="btn btn-icon" [ngClass]="{'colored': activeEvents > 0 || activeDownloads.length > 0, 'colored-error': errors.length > 0,
'colored-info': infos.length > 0 && errors.length === 0}"
[ngbPopover]="popContent" [title]="t('title-alt')" placement="bottom" [popoverClass]="'nav-events'" [autoClose]="'outside'">
<i aria-hidden="true" class="fa fa-wave-square nav"></i>
</button>
</ng-container>
</ng-container>
</ng-container>
@if (isAdmin$ | async) {
@if (downloadService.activeDownloads$ | async; as activeDownloads) {
@if (errors$ | async; as errors) {
@if (infos$ | async; as infos) {
@if (messageHub.onlineUsers$ | async; as onlineUsers) {
<button type="button" class="btn btn-icon"
[ngbPopover]="popContent" [title]="t('title-alt')"
placement="bottom" [popoverClass]="'nav-events'"
[autoClose]="'outside'">
@if (onlineUsers.length > 1) {
<span class="me-2" [ngClass]="{'colored': activeEvents > 0 || activeDownloads.length > 0 || updateAvailable}">{{onlineUsers.length}}</span>
}
<i aria-hidden="true" class="fa fa-wave-square nav" [ngClass]="{'colored': activeEvents > 0 || activeDownloads.length > 0 || updateAvailable}"></i>
@if (errors.length > 0) {
<i aria-hidden="true" class="fa fa-circle-exclamation nav widget-button--indicator error"></i>
} @else if (infos.length > 0) {
<i aria-hidden="true" class="fa fa-circle-info nav widget-button--indicator info"></i>
} @else if (activeEvents > 0 || activeDownloads.length > 0) {
<div class="nav widget-button--indicator spinner-border spinner-border-sm"></div>
} @else if (updateAvailable) {
<i aria-hidden="true" class="fa fa-circle-arrow-up nav widget-button--indicator update"></i>
}
</button>
}
}
}
}
<ng-template #popContent>
<ul class="list-group list-group-flush dark-menu">
<ng-container *ngIf="errors$ | async as errors">
<ng-container *ngIf="infos$ | async as infos">
<li class="list-group-item dark-menu-item clickable" *ngIf="errors.length > 0 || infos.length > 0" (click)="clearAllErrorOrInfos()">
{{t('dismiss-all')}}
</li>
</ng-container>
</ng-container>
@if (debugMode) {
<ng-container>
<li class="list-group-item dark-menu-item">
<div class="h6 mb-1">Title goes here</div>
<div class="accent-text mb-1">Subtitle goes here</div>
<div class="progress-container row g-0 align-items-center">
<div class="progress" style="height: 5px;">
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 100%" [attr.aria-valuenow]="100" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
</li>
<li class="list-group-item dark-menu-item">
<div class="h6 mb-1">Title goes here</div>
<div class="accent-text mb-1">Subtitle goes here</div>
</li>
<li class="list-group-item dark-menu-item">
<div>
<div class="h6 mb-1">Scanning Books</div>
<div class="accent-text mb-1">E:\\Books\\Demon King Daimaou\\Demon King Daimaou - Volume 11.epub</div>
<div class="progress-container row g-0 align-items-center">
<div class="col-2">{{prettyPrintProgress(0.1)}}%</div>
<div class="col-10 progress" style="height: 5px;">
<div class="progress-bar" role="progressbar" [ngStyle]="{'width': 0.1 * 100 + '%'}" [attr.aria-valuenow]="0.1 * 100" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
</div>
</li>
<li class="list-group-item dark-menu-item error">
<div>
<div class="h6 mb-1"><i class="fa-solid fa-triangle-exclamation me-2"></i>There was some library scan error</div>
<div class="accent-text mb-1">Click for more information</div>
</div>
<button type="button" class="btn-close float-end" aria-label="close" ></button>
</li>
<li class="list-group-item dark-menu-item info">
<div>
<div class="h6 mb-1"><i class="fa-solid fa-circle-info me-2"></i>Scan didn't run becasuse nothing to do</div>
<div class="accent-text mb-1">Click for more information</div>
</div>
<button type="button" class="btn-close float-end" aria-label="close" ></button>
</li>
<li class="list-group-item dark-menu-item">
<div class="d-inline-flex">
<span class="download">
<app-circular-loader [currentValue]="25" fontSize="16px" [showIcon]="true" width="25px" height="unset" [center]="false"></app-circular-loader>
<span class="visually-hidden" role="status">
10% downloaded
</span>
</span>
<span class="h6 mb-1">Downloading {{'series' | sentenceCase}}</span>
</div>
<div class="accent-text">PDFs</div>
</li>
</ng-container>
@if(errors$ | async; as errors) {
@if(infos$ | async; as infos) {
@if (errors.length > 0 || infos.length > 0) {
<li class="list-group-item dark-menu-item clickable" (click)="clearAllErrorOrInfos()">
{{t('dismiss-all')}}
</li>
}
}
}
<!-- Progress Events-->
<ng-container *ngIf="progressEvents$ | async as progressUpdates">
<ng-container *ngFor="let message of progressUpdates">
<li class="list-group-item dark-menu-item" *ngIf="message.progress === 'indeterminate' || message.progress === 'none'; else progressEvent">
<div class="h6 mb-1">{{message.title}}</div>
@if (message.subTitle !== '') {
<div class="accent-text mb-1" [title]="message.subTitle">{{message.subTitle}}</div>
}
@if (message.name === EVENTS.ScanProgress && message.body.leftToProcess > 0) {
<div class="accent-text mb-1" [title]="t('left-to-process', {leftToProcess: message.body.leftToProcess})">{{t('left-to-process', {leftToProcess: message.body.leftToProcess})}}</div>
}
<div class="progress-container row g-0 align-items-center">
@if(message.progress === 'indeterminate') {
<div class="progress" style="height: 5px;">
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 100%" [attr.aria-valuenow]="100" aria-valuemin="0" aria-valuemax="100"></div>
</div>
}
</div>
</li>
<ng-template #progressEvent>
@if (progressEvents$ | async; as progressUpdates) {
@for (message of progressUpdates; track message) {
@if (message.progress === 'indeterminate' || message.progress === 'none') {
<li class="list-group-item dark-menu-item">
<div class="h6 mb-1">{{message.title}}</div>
<div class="accent-text mb-1" *ngIf="message.subTitle !== ''" [title]="message.subTitle">{{message.subTitle}}</div>
@if (message.subTitle !== '') {
<div class="accent-text mb-1" [title]="message.subTitle">{{message.subTitle}}</div>
}
@if (message.name === EVENTS.ScanProgress && message.body.leftToProcess > 0) {
<div class="accent-text mb-1" [title]="t('left-to-process', {leftToProcess: message.body.leftToProcess})">
{{t('left-to-process', {leftToProcess: message.body.leftToProcess})}}
</div>
}
<div class="progress-container row g-0 align-items-center">
@if(message.progress === 'indeterminate') {
<div class="progress" style="height: 5px;">
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 100%" [attr.aria-valuenow]="100" aria-valuemin="0" aria-valuemax="100"></div>
</div>
}
</div>
</li>
} @else {
<li class="list-group-item dark-menu-item">
<div class="h6 mb-1">{{message.title}}</div>
@if (message.subTitle !== '') {
<div class="accent-text mb-1" [title]="message.subTitle">{{message.subTitle}}</div>
}
<div class="progress-container row g-0 align-items-center">
<div class="col-2">{{prettyPrintProgress(message.body.progress) + '%'}}</div>
<div class="col-10 progress" style="height: 5px;">
<div class="progress-bar" role="progressbar" [ngStyle]="{'width': message.body.progress * 100 + '%'}" [attr.aria-valuenow]="message.body.progress * 100" aria-valuemin="0" aria-valuemax="100"></div>
<div class="progress-bar" role="progressbar"
[ngStyle]="{'width': message.body.progress * 100 + '%'}"
[attr.aria-valuenow]="message.body.progress * 100"
aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
</li>
</ng-template>
</ng-container>
</ng-container>
}
}
}
<!-- Single updates (Informational/Update available)-->
<ng-container *ngIf="singleUpdates$ | async as singleUpdates">
<ng-container *ngFor="let singleUpdate of singleUpdates">
<li class="list-group-item dark-menu-item update-available" *ngIf="singleUpdate.name === EVENTS.UpdateAvailable" (click)="handleUpdateAvailableClick(singleUpdate)">
<i class="fa fa-chevron-circle-up me-1" aria-hidden="true"></i>{{t('update-available')}}
</li>
<li class="list-group-item dark-menu-item update-available" *ngIf="singleUpdate.name !== EVENTS.UpdateAvailable">
<div>{{singleUpdate.title}}</div>
<div class="accent-text" *ngIf="singleUpdate.subTitle !== ''">{{singleUpdate.subTitle}}</div>
</li>
</ng-container>
</ng-container>
@if (singleUpdates$ | async; as singleUpdates) {
@for(singleUpdate of singleUpdates; track singleUpdate) {
@if (singleUpdate.name === EVENTS.UpdateAvailable) {
<li class="list-group-item dark-menu-item update-available" (click)="handleUpdateAvailableClick(singleUpdate)">
<i class="fa fa-chevron-circle-up me-1" aria-hidden="true"></i>{{t('update-available')}}
</li>
} @else {
<li class="list-group-item dark-menu-item update-available">
<div>{{singleUpdate.title}}</div>
@if (singleUpdate.subTitle !== '') {
<div class="accent-text">{{singleUpdate.subTitle}}</div>
}
</li>
}
}
}
<!-- Active Downloads by the user-->
<ng-container *ngIf="downloadService.activeDownloads$ | async as activeDownloads">
<ng-container *ngFor="let download of activeDownloads">
@if (downloadService.activeDownloads$ | async; as activeDownloads) {
@for(download of activeDownloads; track download) {
<li class="list-group-item dark-menu-item">
<div class="h6 mb-1">{{t('downloading-item', {item: download.entityType | sentenceCase})}}</div>
<div class="accent-text mb-1" *ngIf="download.subTitle !== ''" [title]="download.subTitle">{{download.subTitle}}</div>
@if (download.subTitle !== '') {
<div class="accent-text mb-1" [title]="download.subTitle">{{download.subTitle}}</div>
}
<div class="progress-container row g-0 align-items-center">
<div class="col-2">{{download.progress}}%</div>
<div class="col-10 progress" style="height: 5px;">
@ -141,57 +120,49 @@
</div>
</div>
</li>
</ng-container>
@if(activeDownloads.length > 1) {
<li class="list-group-item dark-menu-item">{{activeDownloads.length}} downloads in Queue</li>
}
</ng-container>
@if(activeDownloads.length > 1) {
<li class="list-group-item dark-menu-item">{{t('download-in-queue', {num: activeDownloads.length})}}</li>
}
}
<!-- Errors -->
<ng-container *ngIf="errors$ | async as errors">
<ng-container *ngFor="let error of errors">
@if (errors$ | async; as errors) {
@for (error of errors; track error) {
<li class="list-group-item dark-menu-item error" role="alert" (click)="seeMore(error)">
<div>
<div class="h6 mb-1"><i class="fa-solid fa-triangle-exclamation me-2"></i>{{error.title}}</div>
<div class="h6 mb-1"><i class="fa-solid fa-triangle-exclamation me-2" aria-hidden="true"></i>{{error.title}}</div>
<div class="accent-text mb-1">{{t('more-info')}}</div>
</div>
<button type="button" class="btn-close float-end" [attr.aria-label]="t('close')" (click)="removeErrorOrInfo(error, $event)"></button>
</li>
</ng-container>
</ng-container>
}
}
<!-- Infos -->
<ng-container *ngIf="infos$ | async as infos">
<ng-container *ngFor="let info of infos">
@if (infos$ | async; as infos) {
@for (info of infos; track info) {
<li class="list-group-item dark-menu-item info" role="alert" (click)="seeMore(info)">
<div>
<div class="h6 mb-1"><i class="fa-solid fa-circle-info me-2"></i>{{info.title}}</div>
<div class="h6 mb-1"><i class="fa-solid fa-circle-info me-2" aria-hidden="true"></i>{{info.title}}</div>
<div class="accent-text mb-1">{{t('more-info')}}</div>
</div>
<button type="button" class="btn-close float-end" [attr.aria-label]="t('close')" (click)="removeErrorOrInfo(info, $event)"></button>
</li>
</ng-container>
</ng-container>
<!-- Online Users -->
@if (messageHub.onlineUsers$ | async; as onlineUsers) {
@if (onlineUsers.length > 1) {
<li class="list-group-item dark-menu-item">
<div>{{t('users-online-count', {num: onlineUsers.length})}}</div>
</li>
}
@if (debugMode) {
<li class="list-group-item dark-menu-item">{{t('active-events-title')}} {{activeEvents}}</li>
}
}
<ng-container *ngIf="downloadService.activeDownloads$ | async as activeDownloads">
<li class="list-group-item dark-menu-item" *ngIf="activeEvents === 0 && activeDownloads.length === 0">{{t('no-data')}}</li>
</ng-container>
@if (downloadService.activeDownloads$ | async; as activeDownloads) {
@if (errors$ | async; as errors) {
@if (infos$ | async; as infos) {
@if (infos.length === 0 && errors.length === 0 && activeDownloads.length === 0 && activeEvents === 0) {
<li class="list-group-item dark-menu-item">{{t('no-data')}}</li>
}
}
}
}
</ul>
</ng-template>
</ng-container>
}
</ng-container>

View file

@ -14,6 +14,26 @@
border-bottom-color: transparent;
}
.colored {
color: var(--event-widget-activity-bg-color) !important;
}
.widget-button--indicator {
position: absolute;
top: 30px;
color: var(--event-widget-activity-bg-color);
&.error {
color: var(--event-widget-error-bg-color) !important;
}
&.info {
color: var(--event-widget-info-bg-color) !important;
}
&.update {
color: var(--event-widget-update-bg-color) !important;
}
}
::ng-deep .nav-events {
.popover-body {
@ -56,67 +76,56 @@
.btn-icon {
color: white;
color: var(--event-widget-text-color);
}
.colored {
background-color: var(--primary-color);
border-radius: 60px;
}
.colored-error {
background-color: var(--error-color) !important;
border-radius: 60px;
}
.colored-info {
background-color: var(--event-widget-info-bg-color) !important;
border-radius: 60px;
}
.update-available {
.dark-menu-item {
&.update-available {
cursor: pointer;
i.fa {
color: var(--primary-color) !important;
color: var(--primary-color) !important;
}
color: var(--primary-color);
}
}
.error {
&.error {
cursor: pointer;
position: relative;
.h6 {
color: var(--error-color);
color: var(--event-widget-error-bg-color);
}
i.fa {
color: var(--primary-color) !important;
color: var(--primary-color) !important;
}
.btn-close {
top: 5px;
right: 10px;
font-size: 11px;
position: absolute;
top: 5px;
right: 10px;
font-size: 11px;
position: absolute;
}
}
}
.info {
&.info {
cursor: pointer;
position: relative;
.h6 {
color: var(--event-widget-info-bg-color);
color: var(--event-widget-info-bg-color);
}
i.fa {
color: var(--primary-color) !important;
color: var(--primary-color) !important;
}
.btn-close {
top: 10px;
right: 10px;
font-size: 11px;
position: absolute;
top: 10px;
right: 10px;
font-size: 11px;
position: absolute;
}
}
}

View file

@ -25,7 +25,7 @@ import { EVENTS, Message, MessageHubService } from 'src/app/_services/message-hu
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { SentenceCasePipe } from '../../../_pipes/sentence-case.pipe';
import { CircularLoaderComponent } from '../../../shared/circular-loader/circular-loader.component';
import { NgIf, NgClass, NgStyle, NgFor, AsyncPipe } from '@angular/common';
import { NgClass, NgStyle, AsyncPipe } from '@angular/common';
import {TranslocoDirective} from "@ngneat/transloco";
@Component({
@ -34,12 +34,20 @@ import {TranslocoDirective} from "@ngneat/transloco";
styleUrls: ['./events-widget.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [NgIf, NgClass, NgbPopover, NgStyle, CircularLoaderComponent, NgFor, AsyncPipe, SentenceCasePipe, TranslocoDirective]
imports: [NgClass, NgbPopover, NgStyle, CircularLoaderComponent, AsyncPipe, SentenceCasePipe, TranslocoDirective]
})
export class EventsWidgetComponent implements OnInit, OnDestroy {
@Input({required: true}) user!: User;
public readonly downloadService = inject(DownloadService);
public readonly messageHub = inject(MessageHubService);
private readonly modalService = inject(NgbModal);
private readonly accountService = inject(AccountService);
private readonly confirmService = inject(ConfirmService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
@Input({required: true}) user!: User;
isAdmin$: Observable<boolean> = of(false);
/**
@ -60,17 +68,15 @@ export class EventsWidgetComponent implements OnInit, OnDestroy {
private updateNotificationModalRef: NgbModalRef | null = null;
activeEvents: number = 0;
/**
* Intercepts from Single Updates to show an extra indicator to the user
*/
updateAvailable: boolean = false;
debugMode: boolean = false;
protected readonly EVENTS = EVENTS;
public readonly downloadService = inject(DownloadService);
constructor(public messageHub: MessageHubService, private modalService: NgbModal,
private accountService: AccountService, private confirmService: ConfirmService,
private readonly cdRef: ChangeDetectorRef) {
}
ngOnDestroy(): void {
this.progressEventsSource.complete();
@ -115,6 +121,9 @@ export class EventsWidgetComponent implements OnInit, OnDestroy {
values.push(message);
this.singleUpdateSource.next(values);
this.activeEvents += 1;
if (event.payload.name === EVENTS.UpdateAvailable) {
this.updateAvailable = true;
}
this.cdRef.markForCheck();
break;
case 'started':

View file

@ -57,7 +57,7 @@
<div style="min-height: 36px" id="toolbarViewer" [ngStyle]="{'background-color': backgroundColor, 'color': fontColor}">
<div id="toolbarViewerLeft">
<pdf-toggle-sidebar></pdf-toggle-sidebar>
<pdf-find-button></pdf-find-button>
<pdf-find-button [textLayer]='true'></pdf-find-button>
<pdf-paging-area></pdf-paging-area>
@if (utilityService.getActiveBreakpoint() > Breakpoint.Mobile) {

View file

@ -4,8 +4,12 @@
<app-card-actionables [actions]="globalActions" (actionHandler)="performGlobalAction($event)"></app-card-actionables>
<span>{{t('title')}}</span>
</h2>
<h6 subtitle class="subtitle-with-actionables" *ngIf="pagination">{{t('item-count', {num: pagination.totalItems | number})}}</h6>
@if (pagination) {
<h6 subtitle class="subtitle-with-actionables">{{t('item-count', {num: pagination.totalItems | number})}}</h6>
}
</app-side-nav-companion-bar>
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
<app-card-detail-layout
[isLoading]="loadingLists"
@ -18,7 +22,9 @@
<ng-template #cardItem let-item let-position="idx" >
<app-card-item [title]="item.title" [entity]="item" [actions]="actions[item.id]"
[suppressLibraryLink]="true" [imageUrl]="imageService.getReadingListCoverImage(item.id)"
(clicked)="handleClick(item)"></app-card-item>
(clicked)="handleClick(item)"
(selection)="bulkSelectionService.handleCardSelection('readingList', position, lists.length, $event)"
[selected]="bulkSelectionService.isCardSelected('readingList', position)" [allowSelection]="true"></app-card-item>
</ng-template>
<ng-template #noData>

View file

@ -1,4 +1,4 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core';
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, HostListener, inject, OnInit} from '@angular/core';
import { Router } from '@angular/router';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
@ -21,6 +21,12 @@ import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco
import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component";
import {Title} from "@angular/platform-browser";
import {WikiLink} from "../../../_models/wiki";
import {BulkSelectionService} from "../../../cards/bulk-selection.service";
import {BulkOperationsComponent} from "../../../cards/bulk-operations/bulk-operations.component";
import {KEY_CODES} from "../../../shared/_services/utility.service";
import {UserCollection} from "../../../_models/collection-tag";
import {User} from "../../../_models/user";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
@Component({
selector: 'app-reading-lists',
@ -28,30 +34,51 @@ import {WikiLink} from "../../../_models/wiki";
styleUrls: ['./reading-lists.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [SideNavCompanionBarComponent, CardActionablesComponent, NgIf, CardDetailLayoutComponent, CardItemComponent, DecimalPipe, TranslocoDirective]
imports: [SideNavCompanionBarComponent, CardActionablesComponent, NgIf, CardDetailLayoutComponent, CardItemComponent, DecimalPipe, TranslocoDirective, BulkOperationsComponent]
})
export class ReadingListsComponent implements OnInit {
public readonly bulkSelectionService = inject(BulkSelectionService);
public readonly actionService = inject(ActionService);
protected readonly WikiLink = WikiLink;
lists: ReadingList[] = [];
loadingLists = false;
pagination!: Pagination;
isAdmin: boolean = false;
hasPromote: boolean = false;
jumpbarKeys: Array<JumpKey> = [];
actions: {[key: number]: Array<ActionItem<ReadingList>>} = {};
globalActions: Array<ActionItem<any>> = [{action: Action.Import, title: 'import-cbl', children: [], requiresAdmin: true, callback: this.importCbl.bind(this)}];
trackByIdentity = (index: number, item: ReadingList) => `${item.id}_${item.title}`;
translocoService = inject(TranslocoService);
@HostListener('document:keydown.shift', ['$event'])
handleKeypress(event: KeyboardEvent) {
if (event.key === KEY_CODES.SHIFT) {
this.bulkSelectionService.isShiftDown = true;
}
}
@HostListener('document:keyup.shift', ['$event'])
handleKeyUp(event: KeyboardEvent) {
if (event.key === KEY_CODES.SHIFT) {
this.bulkSelectionService.isShiftDown = false;
}
}
constructor(private readingListService: ReadingListService, public imageService: ImageService, private actionFactoryService: ActionFactoryService,
private accountService: AccountService, private toastr: ToastrService, private router: Router, private actionService: ActionService,
private accountService: AccountService, private toastr: ToastrService, private router: Router,
private jumpbarService: JumpbarService, private readonly cdRef: ChangeDetectorRef, private ngbModal: NgbModal, private titleService: Title) { }
ngOnInit(): void {
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (user) {
this.isAdmin = this.accountService.hasAdminRole(user);
this.hasPromote = this.accountService.hasPromoteRole(user);
this.cdRef.markForCheck();
this.loadPage();
this.titleService.setTitle('Kavita - ' + translate('side-nav.reading-lists'));
}
@ -59,8 +86,10 @@ export class ReadingListsComponent implements OnInit {
}
getActions(readingList: ReadingList) {
const d = this.actionFactoryService.getReadingListActions(this.handleReadingListActionCallback.bind(this))
.filter(action => this.readingListService.actionListFilter(action, readingList, this.isAdmin || this.hasPromote));
return this.actionFactoryService.getReadingListActions(this.handleReadingListActionCallback.bind(this))
.filter(action => this.readingListService.actionListFilter(action, readingList, this.isAdmin));
.filter(action => this.readingListService.actionListFilter(action, readingList, this.isAdmin || this.hasPromote));
}
performAction(action: ActionItem<ReadingList>, readingList: ReadingList) {
@ -85,7 +114,7 @@ export class ReadingListsComponent implements OnInit {
switch(action.action) {
case Action.Delete:
this.readingListService.delete(readingList.id).subscribe(() => {
this.toastr.success(this.translocoService.translate('toasts.reading-list-deleted'));
this.toastr.success(translate('toasts.reading-list-deleted'));
this.loadPage();
});
break;
@ -126,4 +155,33 @@ export class ReadingListsComponent implements OnInit {
handleClick(list: ReadingList) {
this.router.navigateByUrl('lists/' + list.id);
}
bulkActionCallback = (action: ActionItem<any>, data: any) => {
const selectedReadingListIndexies = this.bulkSelectionService.getSelectedCardsForSource('readingList');
const selectedReadingLists = this.lists.filter((col, index: number) => selectedReadingListIndexies.includes(index + ''));
switch (action.action) {
case Action.Promote:
this.actionService.promoteMultipleReadingLists(selectedReadingLists, true, (success) => {
if (!success) return;
this.bulkSelectionService.deselectAll();
this.loadPage();
});
break;
case Action.UnPromote:
this.actionService.promoteMultipleReadingLists(selectedReadingLists, false, (success) => {
if (!success) return;
this.bulkSelectionService.deselectAll();
this.loadPage();
});
break;
case Action.Delete:
this.actionService.deleteMultipleReadingLists(selectedReadingLists, (successful) => {
if (!successful) return;
this.loadPage();
this.bulkSelectionService.deselectAll();
});
break;
}
}
}

View file

@ -1,21 +1,23 @@
<div class="row g-0 mb-1" *ngIf="tags && tags.length > 0">
<div class="col-lg-3 col-md-4 col-sm-12">
<h5>{{heading}}</h5>
</div>
<div class="col-lg-9 col-md-8 col-sm-12">
<app-badge-expander [items]="tags" [itemsTillExpander]="utilityService.getActiveBreakpoint() >= Breakpoint.Desktop ? 30 : 4">
<ng-template #badgeExpanderItem let-item let-position="idx">
<ng-container *ngIf="itemTemplate; else useTitle">
<span (click)="goTo(queryParam, item.id)">
@if (tags && tags.length > 0) {
<div class="row g-0 mb-1">
<div class="col-lg-3 col-md-4 col-sm-12">
<h5>{{heading}}</h5>
</div>
<div class="col-lg-9 col-md-8 col-sm-12">
<app-badge-expander [items]="tags" [itemsTillExpander]="utilityService.getActiveBreakpoint() >= Breakpoint.Desktop ? 30 : 4">
<ng-template #badgeExpanderItem let-item let-position="idx">
@if(itemTemplate) {
<span (click)="goTo(queryParam, item.id)">
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: position }"></ng-container>
</span>
</ng-container>
<ng-template #useTitle>
<app-tag-badge a11y-click="13,32" class="col-auto" (click)="goTo(queryParam, item.id)" [selectionMode]="TagBadgeCursor.Clickable">
<ng-container [ngTemplateOutlet]="titleTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: position }"></ng-container>
</app-tag-badge>
} @else {
<app-tag-badge a11y-click="13,32" class="col-auto" (click)="goTo(queryParam, item.id)" [selectionMode]="TagBadgeCursor.Clickable">
<ng-container [ngTemplateOutlet]="titleTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: position }"></ng-container>
</app-tag-badge>
}
</ng-template>
</ng-template>
</app-badge-expander>
</app-badge-expander>
</div>
</div>
</div>
}

View file

@ -1,5 +1,5 @@
import {ChangeDetectionStrategy, Component, ContentChild, inject, Input, TemplateRef} from '@angular/core';
import {CommonModule} from '@angular/common';
import {CommonModule, NgTemplateOutlet} from '@angular/common';
import {A11yClickDirective} from "../../../shared/a11y-click.directive";
import {BadgeExpanderComponent} from "../../../shared/badge-expander/badge-expander.component";
import {TagBadgeComponent, TagBadgeCursor} from "../../../shared/tag-badge/tag-badge.component";
@ -11,7 +11,7 @@ import {Breakpoint, UtilityService} from "../../../shared/_services/utility.serv
@Component({
selector: 'app-metadata-detail',
standalone: true,
imports: [CommonModule, A11yClickDirective, BadgeExpanderComponent, TagBadgeComponent],
imports: [A11yClickDirective, BadgeExpanderComponent, TagBadgeComponent, NgTemplateOutlet],
templateUrl: './metadata-detail.component.html',
styleUrls: ['./metadata-detail.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush

View file

@ -1,346 +1,393 @@
<ng-container *transloco="let t; read: 'series-detail'">
<div #companionBar>
<app-side-nav-companion-bar *ngIf="series !== undefined" [hasExtras]="true" [extraDrawer]="extrasDrawer">
<ng-container title>
<h2 class="title text-break">
<app-card-actionables (actionHandler)="performAction($event)" [actions]="seriesActions" [labelBy]="series.name" iconClass="fa-ellipsis-v"></app-card-actionables>
<span>{{series.name}}
@if(isLoadingExtra || isLoading) {
<div class="spinner-border spinner-border-sm text-primary" role="status">
@if (series) {
<app-side-nav-companion-bar [hasExtras]="true" [extraDrawer]="extrasDrawer">
<ng-container title>
<h2 class="title text-break">
<app-card-actionables (actionHandler)="performAction($event)" [actions]="seriesActions" [labelBy]="series.name" iconClass="fa-ellipsis-v"></app-card-actionables>
<span>{{series.name}}
@if(isLoadingExtra || isLoading) {
<div class="spinner-border spinner-border-sm text-primary" role="status">
<span class="visually-hidden">loading...</span>
</div>
}
}
</span>
</h2>
</ng-container>
<ng-container subtitle *ngIf="series.localizedName !== series.name">
<h6 class="subtitle-with-actionables text-break" title="Localized Name">{{series.localizedName}}</h6>
</ng-container>
</h2>
</ng-container>
@if (series.localizedName !== series.name) {
<ng-container subtitle>
<h6 class="subtitle-with-actionables text-break" title="Localized Name">{{series.localizedName}}</h6>
</ng-container>
}
<ng-template #extrasDrawer let-offcanvas>
<div style="margin-top: 56px">
<div class="offcanvas-header">
<h4 class="offcanvas-title" id="offcanvas-basic-title">{{t('page-settings-title')}}</h4>
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="offcanvas.dismiss()"></button>
</div>
<div class="offcanvas-body">
<form [formGroup]="pageExtrasGroup">
<div class="row g-0">
<div class="col-md-12 col-sm-12 pe-2 mb-3">
<label id="list-layout-mode-label" class="form-label">{{t('layout-mode-label')}}</label>
<br/>
<div class="btn-group d-flex justify-content-center" role="group" [attr.aria-label]="t('page-settings-title')">
<input type="radio" formControlName="renderMode" [value]="PageLayoutMode.Cards" class="btn-check" id="layout-mode-default" autocomplete="off">
<label class="btn btn-outline-primary" for="layout-mode-default">{{t('layout-mode-option-card')}}</label>
<ng-template #extrasDrawer let-offcanvas>
<div style="margin-top: 56px">
<div class="offcanvas-header">
<h4 class="offcanvas-title" id="offcanvas-basic-title">{{t('page-settings-title')}}</h4>
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="offcanvas.dismiss()"></button>
</div>
<div class="offcanvas-body">
<form [formGroup]="pageExtrasGroup">
<div class="row g-0">
<div class="col-md-12 col-sm-12 pe-2 mb-3">
<label id="list-layout-mode-label" class="form-label">{{t('layout-mode-label')}}</label>
<br/>
<div class="btn-group d-flex justify-content-center" role="group" [attr.aria-label]="t('page-settings-title')">
<input type="radio" formControlName="renderMode" [value]="PageLayoutMode.Cards" class="btn-check" id="layout-mode-default" autocomplete="off">
<label class="btn btn-outline-primary" for="layout-mode-default">{{t('layout-mode-option-card')}}</label>
<input type="radio" formControlName="renderMode" [value]="PageLayoutMode.List" class="btn-check" id="layout-mode-col1" autocomplete="off">
<label class="btn btn-outline-primary" for="layout-mode-col1">{{t('layout-mode-option-list')}}</label>
<input type="radio" formControlName="renderMode" [value]="PageLayoutMode.List" class="btn-check" id="layout-mode-col1" autocomplete="off">
<label class="btn btn-outline-primary" for="layout-mode-col1">{{t('layout-mode-option-list')}}</label>
</div>
</div>
</div>
</div>
</form>
</form>
</div>
</div>
</div>
</ng-template>
</ng-template>
</app-side-nav-companion-bar>
</app-side-nav-companion-bar>
}
</div>
<app-bulk-operations [actionCallback]="bulkActionCallback" [topOffset]="56"></app-bulk-operations>
@if (series) {
<div [ngStyle]="{'height': ScrollingBlockHeight}" class="main-container container-fluid pt-2" #scrollingBlock>
<div class="row mb-0 mb-xl-3 info-container">
<div class="image-container col-4 col-sm-6 col-md-4 col-lg-4 col-xl-2 col-xxl-2 d-none d-sm-block mt-2">
@if (unreadCount > 0 && unreadCount !== totalCount) {
<div class="to-read-counter">
<app-tag-badge [selectionMode]="TagBadgeCursor.NotAllowed" fillStyle="filled">{{unreadCount}}</app-tag-badge>
</div>
}
<div [ngStyle]="{'height': ScrollingBlockHeight}" class="main-container container-fluid pt-2" *ngIf="series !== undefined" #scrollingBlock>
<div class="row mb-0 mb-xl-3 info-container">
<div class="image-container col-4 col-sm-6 col-md-4 col-lg-4 col-xl-2 col-xxl-2 d-none d-sm-block mt-2">
<div class="to-read-counter" *ngIf="unreadCount > 0 && unreadCount !== totalCount">
<app-tag-badge [selectionMode]="TagBadgeCursor.NotAllowed" fillStyle="filled">{{unreadCount}}</app-tag-badge>
<app-image [styles]="{'object-fit': 'contain', 'background': 'none', 'max-height': '400px', 'height': '100%'}" [imageUrl]="seriesImage"></app-image>
@if (series.pagesRead < series.pages && hasReadingProgress && currentlyReadingChapter && !currentlyReadingChapter.isSpecial) {
<div class="progress-banner" ngbTooltip="{{(series.pagesRead / series.pages) * 100 | number:'1.0-1'}}% Read">
<ngb-progressbar type="primary" height="5px" [value]="series.pagesRead" [max]="series.pages"></ngb-progressbar>
</div>
<div class="under-image">
{{t('continue-from', {title: ContinuePointTitle})}}
</div>
}
</div>
<app-image [styles]="{'object-fit': 'contain', 'background': 'none', 'max-height': '400px', 'height': '100%'}" [imageUrl]="seriesImage"></app-image>
<ng-container *ngIf="series.pagesRead < series.pages && hasReadingProgress && currentlyReadingChapter && !currentlyReadingChapter.isSpecial">
<div class="progress-banner" ngbTooltip="{{(series.pagesRead / series.pages) * 100 | number:'1.0-1'}}% Read">
<ngb-progressbar type="primary" height="5px" [value]="series.pagesRead" [max]="series.pages"></ngb-progressbar>
</div>
<div class="under-image">
{{t('continue-from', {title: ContinuePointTitle})}}
</div>
</ng-container>
</div>
<div class="col-xlg-10 col-lg-8 col-md-8 col-xs-8 col-sm-6 mt-2">
<div class="row g-0">
<div class="col-auto">
<div class="btn-group">
<button type="button" class="btn btn-primary" (click)="read()">
<div class="col-xlg-10 col-lg-8 col-md-8 col-xs-8 col-sm-6 mt-2">
<div class="row g-0">
<div class="col-auto">
<div class="btn-group">
<button type="button" class="btn btn-primary" (click)="read()">
<span>
<i class="fa {{showBook ? 'fa-book-open' : 'fa-book'}}" aria-hidden="true"></i>
<span class="read-btn--text">&nbsp;{{(hasReadingProgress) ? t('continue') : t('read')}}</span>
</span>
</button>
<div class="btn-group" ngbDropdown role="group" display="dynamic" [attr.aria-label]="t('read-options-alt')">
<button type="button" class="btn btn-primary dropdown-toggle-split" ngbDropdownToggle></button>
<div class="dropdown-menu" ngbDropdownMenu>
<button ngbDropdownItem (click)="read(true)">
</button>
<div class="btn-group" ngbDropdown role="group" display="dynamic" [attr.aria-label]="t('read-options-alt')">
<button type="button" class="btn btn-primary dropdown-toggle-split" ngbDropdownToggle></button>
<div class="dropdown-menu" ngbDropdownMenu>
<button ngbDropdownItem (click)="read(true)">
<span>
<i class="fa fa-glasses" aria-hidden="true"></i>
<span class="read-btn--text">&nbsp;{{(hasReadingProgress) ? t('continue-incognito') : t('read-incognito')}}</span>
</span>
</button>
</button>
</div>
</div>
</div>
</div>
</div>
<div class="col-auto ms-2">
<button class="btn btn-secondary" (click)="toggleWantToRead()" title="{{isWantToRead ? t('remove-from-want-to-read') : t('add-to-want-to-read')}}">
<div class="col-auto ms-2">
<button class="btn btn-secondary" (click)="toggleWantToRead()" title="{{isWantToRead ? t('remove-from-want-to-read') : t('add-to-want-to-read')}}">
<span>
<i class="{{isWantToRead ? 'fa-solid' : 'fa-regular'}} fa-star" aria-hidden="true"></i>
</span>
</button>
</div>
<div class="col-auto ms-2" *ngIf="isAdmin">
<button class="btn btn-secondary" id="edit-btn--komf" (click)="openEditSeriesModal()" [title]="t('edit-series-alt')">
<span><i class="fa fa-pen" aria-hidden="true"></i></span>
</button>
</div>
<div class="col-auto ms-2 d-none d-md-block">
<div class="card-actions">
<app-card-actionables (actionHandler)="performAction($event)" [actions]="seriesActions" [labelBy]="series.name" iconClass="fa-ellipsis-h" btnClass="btn-secondary"></app-card-actionables>
</button>
</div>
@if (isAdmin) {
<div class="col-auto ms-2">
<button class="btn btn-secondary" id="edit-btn--komf" (click)="openEditSeriesModal()" [title]="t('edit-series-alt')">
<span><i class="fa fa-pen" aria-hidden="true"></i></span>
</button>
</div>
}
<div class="col-auto ms-2 d-none d-md-block">
<div class="card-actions">
<app-card-actionables (actionHandler)="performAction($event)" [actions]="seriesActions" [labelBy]="series.name" iconClass="fa-ellipsis-h" btnClass="btn-secondary"></app-card-actionables>
</div>
</div>
@if (isAdmin || hasDownloadingRole) {
<div class="col-auto ms-2 d-none d-md-block">
@if (download$ | async; as download) {
<button class="btn btn-secondary" (click)="downloadSeries()" [title]="t('download-series--tooltip')" [disabled]="download !== null">
@if (download !== null) {
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span class="visually-hidden">{{t('downloading-status')}}</span>
} @else {
<i class="fa fa-arrow-alt-circle-down" aria-hidden="true"></i>
}
</button>
} @else {
<button class="btn btn-secondary" (click)="downloadSeries()" [title]="t('download-series--tooltip')">
<i class="fa fa-arrow-alt-circle-down" aria-hidden="true"></i>
</button>
}
</div>
}
</div>
<div class="col-auto ms-2 d-none d-md-block" *ngIf="isAdmin || hasDownloadingRole">
@if (download$ | async; as download) {
<button class="btn btn-secondary" (click)="downloadSeries()" [title]="t('download-series--tooltip')" [disabled]="download !== null">
<ng-container *ngIf="download !== null; else notDownloading">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span class="visually-hidden">{{t('downloading-status')}}</span>
</ng-container>
<ng-template #notDownloading>
<i class="fa fa-arrow-alt-circle-down" aria-hidden="true"></i>
</ng-template>
</button>
} @else {
<button class="btn btn-secondary" (click)="downloadSeries()" [title]="t('download-series--tooltip')">
<i class="fa fa-arrow-alt-circle-down" aria-hidden="true"></i>
</button>
}
</div>
@if (seriesMetadata) {
<div class="mt-2">
<app-series-metadata-detail [seriesMetadata]="seriesMetadata" [readingLists]="readingLists" [series]="series"
[libraryType]="libraryType" [ratings]="ratings"
[hasReadingProgress]="hasReadingProgress"></app-series-metadata-detail>
</div>
}
</div>
@if (seriesMetadata) {
<div class="mt-2">
<app-series-metadata-detail [seriesMetadata]="seriesMetadata" [readingLists]="readingLists" [series]="series"
[libraryType]="libraryType" [ratings]="ratings"
[hasReadingProgress]="hasReadingProgress"></app-series-metadata-detail>
</div>
}
<div class="row" [ngClass]="{'pt-3': !seriesMetadata || seriesMetadata.summary.length === 0}">
<app-carousel-reel [items]="reviews" [alwaysShow]="true" [title]="t('user-reviews-alt')"
iconClasses="fa-solid fa-{{getUserReview().length > 0 ? 'pen' : 'plus'}}"
[clickableTitle]="true" (sectionClick)="openReviewModal()">
<ng-template #carouselItem let-item let-position="idx">
<app-review-card [review]="item" (refresh)="updateOrDeleteReview($event)"></app-review-card>
</ng-template>
</app-carousel-reel>
</div>
</div>
<div class="row" [ngClass]="{'pt-3': !seriesMetadata || seriesMetadata.summary.length === 0}">
<app-carousel-reel [items]="reviews" [alwaysShow]="true" [title]="t('user-reviews-alt')"
iconClasses="fa-solid fa-{{getUserReview().length > 0 ? 'pen' : 'plus'}}"
[clickableTitle]="true" (sectionClick)="openReviewModal()">
<ng-template #carouselItem let-item let-position="idx">
<app-review-card [review]="item" (refresh)="updateOrDeleteReview($event)"></app-review-card>
</ng-template>
</app-carousel-reel>
</div>
</div>
@if (series) {
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTabId" class="nav nav-tabs mb-2" [destroyOnHide]="true" (navChange)="onNavChange($event)">
<ng-container *ngIf="series">
@if (showStorylineTab) {
<li [ngbNavItem]="TabID.Storyline">
<a ngbNavLink>{{t('storyline-tab')}}</a>
<ng-template ngbNavContent>
<virtual-scroller #scroll [items]="storylineItems" [bufferAmount]="1" [parentScroll]="scrollingBlock" [childHeight]="1">
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTabId" class="nav nav-tabs mb-2" [destroyOnHide]="false" (navChange)="onNavChange($event)">
<li [ngbNavItem]="TabID.Storyline" *ngIf="ShowStorylineTab">
<a ngbNavLink>{{t('storyline-tab')}}</a>
<ng-template ngbNavContent>
<virtual-scroller #scroll [items]="storylineItems" [bufferAmount]="1" [parentScroll]="scrollingBlock" [childHeight]="1">
<ng-container *ngIf="renderMode === PageLayoutMode.Cards; else storylineListLayout">
<div class="card-container row g-0" #container>
<ng-container *ngFor="let item of scroll.viewPortItems; let idx = index; trackBy: trackByStoryLineIdentity">
{{item.id}}
<ng-container [ngSwitch]="item.isChapter">
<ng-container *ngSwitchCase="false" [ngTemplateOutlet]="nonChapterVolumeCard" [ngTemplateOutletContext]="{$implicit: item.volume, scroll: scroll, idx: idx, volumesLength: volumes.length}"></ng-container>
<ng-container *ngSwitchCase="true" [ngTemplateOutlet]="nonSpecialChapterCard" [ngTemplateOutletContext]="{$implicit: item.chapter, scroll: scroll, idx: idx, chaptersLength: storyChapters.length}"></ng-container>
</ng-container>
</ng-container>
<ng-container [ngTemplateOutlet]="estimatedNextCard" [ngTemplateOutletContext]="{tabId: TabID.Storyline}"></ng-container>
</div>
</ng-container>
<ng-template #storylineListLayout>
<ng-container *ngFor="let item of scroll.viewPortItems; let idx = index; trackBy: trackByStoryLineIdentity">
<ng-container [ngSwitch]="item.isChapter">
<ng-container *ngSwitchCase="false" [ngTemplateOutlet]="nonSpecialVolumeListItem" [ngTemplateOutletContext]="{$implicit: item.volume}"></ng-container>
<ng-container *ngSwitchCase="true" [ngTemplateOutlet]="nonSpecialChapterListItem" [ngTemplateOutletContext]="{$implicit: item.chapter}"></ng-container>
</ng-container>
</ng-container>
@switch (renderMode) {
@case (PageLayoutMode.Cards) {
<div class="card-container row g-0" #container>
@for(item of scroll.viewPortItems; let idx = $index; track item.id + '_' + item.pagesRead) {
@if (item.isChapter) {
<ng-container [ngTemplateOutlet]="nonSpecialChapterCard" [ngTemplateOutletContext]="{$implicit: item.chapter, scroll: scroll, idx: idx, chaptersLength: storyChapters.length}"></ng-container>
} @else {
<ng-container [ngTemplateOutlet]="nonChapterVolumeCard" [ngTemplateOutletContext]="{$implicit: item.volume, scroll: scroll, idx: idx, volumesLength: volumes.length}"></ng-container>
}
}
<ng-container [ngTemplateOutlet]="estimatedNextCard" [ngTemplateOutletContext]="{tabId: TabID.Storyline}"></ng-container>
</div>
}
@case (PageLayoutMode.List) {
@for(item of scroll.viewPortItems; let idx = $index; track item.id + '_' + item.pagesRead) {
@if (item.isChapter) {
<ng-container [ngTemplateOutlet]="nonSpecialChapterListItem" [ngTemplateOutletContext]="{$implicit: item.chapter}"></ng-container>
} @else {
<ng-container [ngTemplateOutlet]="nonSpecialVolumeListItem" [ngTemplateOutletContext]="{$implicit: item.volume}"></ng-container>
}
}
}
}
</virtual-scroller>
</ng-template>
</virtual-scroller>
</ng-template>
</li>
</li>
}
<li [ngbNavItem]="TabID.Volumes" *ngIf="ShowVolumeTab">
<a ngbNavLink>{{UseBookLogic ? t('books-tab') : t('volumes-tab')}}</a>
<ng-template ngbNavContent>
<virtual-scroller #scroll [items]="volumes" [parentScroll]="scrollingBlock" [childHeight]="1">
<ng-container *ngIf="renderMode === PageLayoutMode.Cards; else volumeListLayout">
<div class="card-container row g-0" #container>
<ng-container *ngFor="let item of scroll.viewPortItems; let idx = index; trackBy: trackByVolumeIdentity">
<ng-container [ngTemplateOutlet]="nonChapterVolumeCard" [ngTemplateOutletContext]="{$implicit: item, scroll: scroll, idx: idx, totalLength: volumes.length}"></ng-container>
</ng-container>
<ng-container [ngTemplateOutlet]="estimatedNextCard" [ngTemplateOutletContext]="{tabId: TabID.Volumes}"></ng-container>
</div>
</ng-container>
<ng-template #volumeListLayout>
<ng-container *ngFor="let volume of scroll.viewPortItems; let idx = index; trackBy: trackByVolumeIdentity">
<ng-container [ngTemplateOutlet]="nonSpecialVolumeListItem" [ngTemplateOutletContext]="{$implicit: volume}"></ng-container>
</ng-container>
@if (showVolumeTab) {
<li [ngbNavItem]="TabID.Volumes">
<a ngbNavLink>{{UseBookLogic ? t('books-tab') : t('volumes-tab')}}</a>
<ng-template ngbNavContent>
<virtual-scroller #scroll [items]="volumes" [parentScroll]="scrollingBlock" [childHeight]="1">
@switch (renderMode) {
@case (PageLayoutMode.Cards) {
<div class="card-container row g-0" #container>
@for (item of scroll.viewPortItems; let idx = $index; track item.id + '_' + item.pagesRead) {
<ng-container [ngTemplateOutlet]="nonChapterVolumeCard" [ngTemplateOutletContext]="{$implicit: item, scroll: scroll, idx: idx, totalLength: volumes.length}"></ng-container>
}
<ng-container [ngTemplateOutlet]="estimatedNextCard" [ngTemplateOutletContext]="{tabId: TabID.Volumes}"></ng-container>
</div>
}
@case (PageLayoutMode.List) {
@for (item of scroll.viewPortItems; let idx = $index; track item.id + '_' + item.pagesRead) {
<ng-container [ngTemplateOutlet]="nonSpecialVolumeListItem" [ngTemplateOutletContext]="{$implicit: item}"></ng-container>
}
}
}
</virtual-scroller>
</ng-template>
</virtual-scroller>
</ng-template>
</li>
</li>
}
<li [ngbNavItem]="TabID.Chapters" *ngIf="ShowChaptersTab">
<a ngbNavLink>{{utilityService.formatChapterName(libraryType) + 's'}}</a>
<ng-template ngbNavContent>
<virtual-scroller #scroll [items]="chapters" [parentScroll]="scrollingBlock" [childHeight]="1">
<ng-container *ngIf="renderMode === PageLayoutMode.Cards; else chapterListLayout">
<div class="card-container row g-0" #container>
<div *ngFor="let item of scroll.viewPortItems; let idx = index; trackBy: trackByChapterIdentity">
<ng-container [ngTemplateOutlet]="nonSpecialChapterCard" [ngTemplateOutletContext]="{$implicit: item, scroll: scroll, idx: idx, totalLength: chapters.length}"></ng-container>
@if (showChapterTab) {
<li [ngbNavItem]="TabID.Chapters">
<a ngbNavLink>{{utilityService.formatChapterName(libraryType) + 's'}}</a>
<ng-template ngbNavContent>
<virtual-scroller #scroll [items]="chapters" [parentScroll]="scrollingBlock" [childHeight]="1">
@switch (renderMode) {
@case (PageLayoutMode.Cards) {
<div class="card-container row g-0" #container>
@for (item of scroll.viewPortItems; let idx = $index; track item.id + '_' + item.pagesRead) {
<ng-container [ngTemplateOutlet]="nonSpecialChapterCard" [ngTemplateOutletContext]="{$implicit: item, scroll: scroll, idx: idx, totalLength: chapters.length}"></ng-container>
}
<ng-container [ngTemplateOutlet]="estimatedNextCard" [ngTemplateOutletContext]="{tabId: TabID.Chapters}"></ng-container>
</div>
}
@case (PageLayoutMode.List) {
@for (item of scroll.viewPortItems; let idx = $index; track item.id + '_' + item.pagesRead) {
<ng-container [ngTemplateOutlet]="nonSpecialChapterListItem" [ngTemplateOutletContext]="{$implicit: item}"></ng-container>
}
}
}
</virtual-scroller>
</ng-template>
</li>
}
@if (hasSpecials) {
<li [ngbNavItem]="TabID.Specials">
<a ngbNavLink>{{t('specials-tab')}}</a>
<ng-template ngbNavContent>
<virtual-scroller #scroll [items]="specials" [parentScroll]="scrollingBlock" [childHeight]="1">
@switch (renderMode) {
@case (PageLayoutMode.Cards) {
<div class="card-container row g-0" #container>
@for(item of scroll.viewPortItems; let idx = $index; track item.id + '_' + item.pagesRead) {
<ng-container [ngTemplateOutlet]="specialChapterCard" [ngTemplateOutletContext]="{$implicit: item, scroll: scroll, idx: idx, chaptersLength: chapters.length}"></ng-container>
}
</div>
}
@case (PageLayoutMode.List) {
@for(item of scroll.viewPortItems; let idx = $index; track item.id + '_' + item.pagesRead) {
<ng-container [ngTemplateOutlet]="specialChapterListItem" [ngTemplateOutletContext]="{$implicit: item}"></ng-container>
}
}
}
</virtual-scroller>
</ng-template>
</li>
}
@if (hasRelations) {
<li [ngbNavItem]="TabID.Related">
<a ngbNavLink>{{t('related-tab')}}</a>
<ng-template ngbNavContent>
<virtual-scroller #scroll [items]="relations" [parentScroll]="scrollingBlock" [childHeight]="1">
<div class="card-container row g-0" #container>
@for(item of scroll.viewPortItems; let idx = $index; track item.id) {
<app-series-card class="col-auto mt-2 mb-2" [data]="item.series" [libraryId]="item.series.libraryId" [relation]="item.relation"></app-series-card>
}
</div>
<ng-container [ngTemplateOutlet]="estimatedNextCard" [ngTemplateOutletContext]="{tabId: TabID.Chapters}"></ng-container>
</div>
</ng-container>
<ng-template #chapterListLayout>
<div *ngFor="let chapter of scroll.viewPortItems; let idx = index; trackBy: trackByChapterIdentity">
<ng-container [ngTemplateOutlet]="nonSpecialChapterListItem" [ngTemplateOutletContext]="{$implicit: chapter}"></ng-container>
</div>
</virtual-scroller>
</ng-template>
</virtual-scroller>
</ng-template>
</li>
</li>
}
<li [ngbNavItem]="TabID.Specials" *ngIf="hasSpecials">
<a ngbNavLink>{{t('specials-tab')}}</a>
<ng-template ngbNavContent>
<virtual-scroller #scroll [items]="specials" [parentScroll]="scrollingBlock" [childHeight]="1">
<ng-container *ngIf="renderMode === PageLayoutMode.Cards; else specialListLayout">
<div class="card-container row g-0" #container>
<ng-container *ngFor="let item of scroll.viewPortItems; let idx = index; trackBy: trackByChapterIdentity">
<ng-container [ngTemplateOutlet]="specialChapterCard" [ngTemplateOutletContext]="{$implicit: item, scroll: scroll, idx: idx, chaptersLength: chapters.length}"></ng-container>
</ng-container>
</div>
</ng-container>
<ng-template #specialListLayout>
<ng-container *ngFor="let chapter of scroll.viewPortItems; let idx = index; trackBy: trackByChapterIdentity">
<ng-container [ngTemplateOutlet]="specialChapterListItem" [ngTemplateOutletContext]="{$implicit: chapter}"></ng-container>
</ng-container>
@if (hasRecommendations) {
<li [ngbNavItem]="TabID.Recommendations">
<a ngbNavLink>{{t('recommendations-tab')}}</a>
<ng-template ngbNavContent>
<virtual-scroller #scroll [items]="combinedRecs" [parentScroll]="scrollingBlock" [childHeight]="1">
@switch (renderMode) {
@case (PageLayoutMode.Cards) {
<div class="card-container row g-0" #container>
@for(item of scroll.viewPortItems; let idx = $index; track idx) {
@if (!item.hasOwnProperty('coverUrl')) {
<app-series-card class="col-auto mt-2 mb-2" [data]="item" [previewOnClick]="true" [libraryId]="item.libraryId"></app-series-card>
} @else {
<app-external-series-card class="col-auto mt-2 mb-2" [previewOnClick]="true" [data]="item"></app-external-series-card>
}
}
</div>
}
@case (PageLayoutMode.List) {
@for(item of scroll.viewPortItems; let idx = $index; track idx) {
@if (!item.hasOwnProperty('coverUrl')) {
<app-external-list-item [imageUrl]="item.coverUrl" imageWidth="130px" imageHeight="" [summary]="item.summary">
<ng-container title>
<span (click)="previewSeries(item, true); $event.stopPropagation(); $event.preventDefault();">
<a [href]="item.url" target="_blank" rel="noreferrer nofollow">{{item.name}}</a>
</span>
</ng-container>
</app-external-list-item>
} @else {
<app-external-list-item [imageUrl]="item.coverUrl" imageWidth="130px" imageHeight="" [summary]="item.summary">
<ng-container title>
<span (click)="previewSeries(item, true); $event.stopPropagation(); $event.preventDefault();">
<a [href]="item.url" target="_blank" rel="noreferrer nofollow">{{item.name}}</a>
</span>
</ng-container>
</app-external-list-item>
}
}
}
}
</virtual-scroller>
</ng-template>
</virtual-scroller>
</ng-template>
</li>
</li>
}
<li [ngbNavItem]="TabID.Related" *ngIf="hasRelations">
<a ngbNavLink>{{t('related-tab')}}</a>
<ng-template ngbNavContent>
<virtual-scroller #scroll [items]="relations" [parentScroll]="scrollingBlock" [childHeight]="1">
<div class="card-container row g-0" #container>
<ng-container *ngFor="let item of scroll.viewPortItems let idx = index; trackBy: trackByRelatedSeriesIdentify">
<app-series-card class="col-auto mt-2 mb-2" [data]="item.series" [libraryId]="item.series.libraryId" [relation]="item.relation"></app-series-card>
</ng-container>
</div>
</virtual-scroller>
</ng-template>
</li>
<li [ngbNavItem]="TabID.Recommendations" *ngIf="hasRecommendations">
<a ngbNavLink>{{t('recommendations-tab')}}</a>
<ng-template ngbNavContent>
<virtual-scroller #scroll [items]="combinedRecs" [parentScroll]="scrollingBlock" [childHeight]="1">
<ng-container *ngIf="renderMode === PageLayoutMode.Cards; else recListLayout">
<div class="card-container row g-0" #container>
<ng-container *ngFor="let item of scroll.viewPortItems; let idx = index; trackBy: trackBySeriesIdentify">
<ng-container *ngIf="!item.hasOwnProperty('coverUrl'); else externalRec">
<app-series-card class="col-auto mt-2 mb-2" [data]="item" [previewOnClick]="true" [libraryId]="item.libraryId"></app-series-card>
</ng-container>
<ng-template #externalRec>
<app-external-series-card class="col-auto mt-2 mb-2" [previewOnClick]="true" [data]="item"></app-external-series-card>
</ng-template>
</ng-container>
</div>
</ng-container>
<ng-template #recListLayout>
<ng-container *ngFor="let item of scroll.viewPortItems; let idx = index; trackBy: trackBySeriesIdentify">
<ng-container *ngIf="!item.hasOwnProperty('coverUrl'); else externalRec">
<app-external-list-item [imageUrl]="imageService.getSeriesCoverImage(item.id)" imageWidth="130px" imageHeight="" [summary]="item.summary">
<ng-container title>
<span (click)="previewSeries(item, false); $event.stopPropagation(); $event.preventDefault();">
<a href="/library/{{item.libraryId}}/series/{{item.id}}">{{item.name}}</a>
</span>
</ng-container>
</app-external-list-item>
</ng-container>
<ng-template #externalRec>
<app-external-list-item [imageUrl]="item.coverUrl" imageWidth="130px" imageHeight="" [summary]="item.summary">
<ng-container title>
<span (click)="previewSeries(item, true); $event.stopPropagation(); $event.preventDefault();">
<a [href]="item.url" target="_blank" rel="noreferrer nofollow">{{item.name}}</a>
</span>
</ng-container>
</app-external-list-item>
</ng-template>
</ng-container>
</ng-template>
</ul>
<div [ngbNavOutlet]="nav"></div>
}
</virtual-scroller>
</ng-template>
</li>
</ul>
<div [ngbNavOutlet]="nav"></div>
</ng-container>
<app-loading [loading]="isLoading"></app-loading>
</div>
<ng-template #estimatedNextCard let-tabId="tabId">
@if (nextExpectedChapter) {
@switch (tabId) {
@case (TabID.Volumes) {
@if (nextExpectedChapter.volumeNumber !== SpecialVolumeNumber && nextExpectedChapter.chapterNumber === LooseLeafOrSpecialNumber) {
<app-next-expected-card class="col-auto mt-2 mb-2" [entity]="nextExpectedChapter"
[imageUrl]="imageService.getSeriesCoverImage(series.id)"></app-next-expected-card>
}
}
@case (TabID.Chapters) {
<app-next-expected-card class="col-auto mt-2 mb-2" [entity]="nextExpectedChapter" [imageUrl]="imageService.getSeriesCoverImage(series.id)"></app-next-expected-card>
}
@case (TabID.Storyline) {
<app-next-expected-card class="col-auto mt-2 mb-2" [entity]="nextExpectedChapter" [imageUrl]="imageService.getSeriesCoverImage(series.id)"></app-next-expected-card>
}
}
}
</ng-template>
}
<app-loading [loading]="isLoading"></app-loading>
</div>
<ng-template #estimatedNextCard let-tabId="tabId">
<ng-container *ngIf="nextExpectedChapter">
<ng-container [ngSwitch]="tabId">
<ng-container *ngSwitchCase="TabID.Volumes">
<app-next-expected-card *ngIf="nextExpectedChapter.volumeNumber !== SpecialVolumeNumber && nextExpectedChapter.chapterNumber === LooseLeafOrSpecialNumber"
class="col-auto mt-2 mb-2" [entity]="nextExpectedChapter"
[imageUrl]="imageService.getSeriesCoverImage(series.id)"></app-next-expected-card>
</ng-container>
<ng-container *ngSwitchCase="TabID.Chapters">
<app-next-expected-card class="col-auto mt-2 mb-2" [entity]="nextExpectedChapter" [imageUrl]="imageService.getSeriesCoverImage(series.id)"></app-next-expected-card>
</ng-container>
<ng-container *ngSwitchCase="TabID.Storyline">
<app-next-expected-card class="col-auto mt-2 mb-2" [entity]="nextExpectedChapter" [imageUrl]="imageService.getSeriesCoverImage(series.id)"></app-next-expected-card>
</ng-container>
</ng-container>
</ng-container>
</ng-template>
</ng-container>
<ng-template #nonSpecialChapterCard let-item let-scroll="scroll" let-idx="idx" let-totalLength="totalLength">
<app-card-item class="col-auto mt-2 mb-2" *ngIf="!item.isSpecial" [entity]="item" [title]="item.title" (click)="openChapter(item)"
[imageUrl]="imageService.getChapterCoverImage(item.id)"
[read]="item.pagesRead" [total]="item.pages" [actions]="chapterActions"
[count]="item.files.length"
(selection)="bulkSelectionService.handleCardSelection('chapter', scroll.viewPortInfo.startIndexWithBuffer + idx, totalLength, $event)"
[selected]="bulkSelectionService.isCardSelected('chapter', scroll.viewPortInfo.startIndexWithBuffer + idx)" [allowSelection]="true">
</app-card-item>
@if (!item.isSpecial) {
<app-card-item class="col-auto mt-2 mb-2" [entity]="item" [title]="item.title" (click)="openChapter(item)"
[imageUrl]="imageService.getChapterCoverImage(item.id)"
[read]="item.pagesRead" [total]="item.pages" [actions]="chapterActions"
[count]="item.files.length"
(selection)="bulkSelectionService.handleCardSelection('chapter', scroll.viewPortInfo.startIndexWithBuffer + idx, totalLength, $event)"
[selected]="bulkSelectionService.isCardSelected('chapter', scroll.viewPortInfo.startIndexWithBuffer + idx)" [allowSelection]="true">
</app-card-item>
}
</ng-template>
<ng-template #nonChapterVolumeCard let-item let-scroll="scroll" let-idx="idx" let-totalLength="totalLength">
<app-card-item *ngIf="item.number !== LooseLeafOrSpecialNumber" class="col-auto mt-2 mb-2" [entity]="item" [title]="item.name" (click)="openVolume(item)"
[imageUrl]="imageService.getVolumeCoverImage(item.id)"
[read]="item.pagesRead" [total]="item.pages" [actions]="volumeActions"
(selection)="bulkSelectionService.handleCardSelection('volume', scroll.viewPortInfo.startIndexWithBuffer + idx, totalLength, $event)"
[selected]="bulkSelectionService.isCardSelected('volume', scroll.viewPortInfo.startIndexWithBuffer + idx)" [allowSelection]="true">
</app-card-item>
@if (item.number !== LooseLeafOrSpecialNumber) {
<app-card-item class="col-auto mt-2 mb-2" [entity]="item" [title]="item.name" (click)="openVolume(item)"
[imageUrl]="imageService.getVolumeCoverImage(item.id)"
[read]="item.pagesRead" [total]="item.pages" [actions]="volumeActions"
(selection)="bulkSelectionService.handleCardSelection('volume', scroll.viewPortInfo.startIndexWithBuffer + idx, totalLength, $event)"
[selected]="bulkSelectionService.isCardSelected('volume', scroll.viewPortInfo.startIndexWithBuffer + idx)" [allowSelection]="true">
</app-card-item>
}
</ng-template>
<ng-template #specialChapterCard let-item let-scroll="scroll" let-idx="idx" let-totalLength="totalLength">
@ -354,27 +401,32 @@
</ng-template>
<ng-template #nonSpecialChapterListItem let-item>
<app-list-item [imageUrl]="imageService.getChapterCoverImage(item.id)" [libraryId]="libraryId"
[seriesName]="series.name" [entity]="item" *ngIf="!item.isSpecial"
[actions]="chapterActions" [libraryType]="libraryType" imageWidth="130px" imageHeight=""
[pagesRead]="item.pagesRead" [totalPages]="item.pages" (read)="openChapter(item)"
[blur]="user?.preferences?.blurUnreadSummaries || false">
<ng-container title>
<app-entity-title [libraryType]="libraryType" [entity]="item" [seriesName]="series.name" [prioritizeTitleName]="false"></app-entity-title>
</ng-container>
</app-list-item>
@if (!item.isSpecial) {
<app-list-item [imageUrl]="imageService.getChapterCoverImage(item.id)" [libraryId]="libraryId"
[seriesName]="series.name" [entity]="item"
[actions]="chapterActions" [libraryType]="libraryType" imageWidth="130px" imageHeight=""
[pagesRead]="item.pagesRead" [totalPages]="item.pages" (read)="openChapter(item)"
[blur]="user?.preferences?.blurUnreadSummaries || false">
<ng-container title>
<app-entity-title [libraryType]="libraryType" [entity]="item" [seriesName]="series.name" [prioritizeTitleName]="false"></app-entity-title>
</ng-container>
</app-list-item>
}
</ng-template>
<ng-template #nonSpecialVolumeListItem let-item>
<app-list-item [imageUrl]="imageService.getVolumeCoverImage(item.id)" [libraryId]="libraryId"
[seriesName]="series.name" [entity]="item" *ngIf="item.number !== LooseLeafOrSpecialNumber"
[actions]="volumeActions" [libraryType]="libraryType" imageWidth="130px" imageHeight=""
[pagesRead]="item.pagesRead" [totalPages]="item.pages" (read)="openVolume(item)"
[blur]="user?.preferences?.blurUnreadSummaries || false">
<ng-container title>
<app-entity-title [libraryType]="libraryType" [entity]="item" [seriesName]="series.name" [prioritizeTitleName]="false"></app-entity-title>
</ng-container>
</app-list-item>
@if (item.number !== LooseLeafOrSpecialNumber) {
<app-list-item [imageUrl]="imageService.getVolumeCoverImage(item.id)" [libraryId]="libraryId"
[seriesName]="series.name" [entity]="item"
[actions]="volumeActions" [libraryType]="libraryType" imageWidth="130px" imageHeight=""
[pagesRead]="item.pagesRead" [totalPages]="item.pages" (read)="openVolume(item)"
[blur]="user?.preferences?.blurUnreadSummaries || false">
<ng-container title>
<app-entity-title [libraryType]="libraryType" [entity]="item" [seriesName]="series.name" [prioritizeTitleName]="false"></app-entity-title>
</ng-container>
</app-list-item>
}
</ng-template><ng-template #specialChapterListItem let-item>
<app-list-item [imageUrl]="imageService.getChapterCoverImage(item.id)" [libraryId]="libraryId"

View file

@ -104,7 +104,7 @@ import {TagBadgeComponent} from '../../../shared/tag-badge/tag-badge.component';
import {
SideNavCompanionBarComponent
} from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
import {TranslocoDirective, TranslocoService} from "@ngneat/transloco";
import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco";
import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component";
import {ExternalSeries} from "../../../_models/series-detail/external-series";
import {
@ -282,6 +282,9 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
});
user: User | undefined;
showVolumeTab = true;
showStorylineTab = true;
showChapterTab = true;
/**
* This is the download we get from download service.
@ -337,28 +340,6 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
}
}
get ShowStorylineTab() {
if (this.libraryType === LibraryType.ComicVine) return false;
// Edge case for bad pdf parse
if (this.libraryType === LibraryType.Book && (this.volumes.length === 0 && this.chapters.length === 0 && this.storyChapters.length > 0)) return true;
return (this.libraryType !== LibraryType.Book && this.libraryType !== LibraryType.LightNovel && this.libraryType !== LibraryType.Comic)
&& (this.volumes.length > 0 || this.chapters.length > 0);
}
get ShowVolumeTab() {
if (this.libraryType === LibraryType.ComicVine) {
if (this.volumes.length > 1) return true;
if (this.specials.length === 0 && this.chapters.length === 0) return true;
return false;
}
return this.volumes.length > 0;
}
get ShowChaptersTab() {
return this.chapters.length > 0;
}
get UseBookLogic() {
return this.libraryType === LibraryType.Book || this.libraryType === LibraryType.LightNovel;
}
@ -380,26 +361,43 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
if (!this.currentlyReadingChapter.isSpecial) {
const vol = this.volumes.filter(v => v.id === this.currentlyReadingChapter?.volumeId);
let chapterLocaleKey = 'common.chapter-num-shorthand';
let volumeLocaleKey = 'common.volume-num-shorthand';
switch (this.libraryType) {
case LibraryType.ComicVine:
case LibraryType.Comic:
chapterLocaleKey = 'common.issue-num-shorthand';
break;
case LibraryType.Book:
case LibraryType.Manga:
case LibraryType.LightNovel:
case LibraryType.Images:
chapterLocaleKey = 'common.chapter-num-shorthand';
break;
}
// This is a lone chapter
if (vol.length === 0) {
if (this.currentlyReadingChapter.minNumber === LooseLeafOrDefaultNumber) {
return this.currentlyReadingChapter.titleName;
}
return 'Ch ' + this.currentlyReadingChapter.minNumber; // TODO: Refactor this to use DisplayTitle (or Range) and Localize it
return translate(chapterLocaleKey, {num: this.currentlyReadingChapter.minNumber});
}
if (this.currentlyReadingChapter.minNumber === LooseLeafOrDefaultNumber) {
return 'Vol ' + vol[0].minNumber;
return translate(chapterLocaleKey, {num: vol[0].minNumber});
}
return 'Vol ' + vol[0].minNumber + ' Ch ' + this.currentlyReadingChapter.minNumber;
return translate(volumeLocaleKey, {num: vol[0].minNumber})
+ ' ' + translate(chapterLocaleKey, {num: this.currentlyReadingChapter.minNumber});
}
return this.currentlyReadingChapter.title;
}
constructor(@Inject(DOCUMENT) private document: Document) {
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
this.accountService.currentUser$.subscribe(user => {
if (user) {
this.user = user;
this.isAdmin = this.accountService.hasAdminRole(user);
@ -415,6 +413,10 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
this.scrollService.setScrollContainer(this.scrollingBlock);
}
debugLog(message: string) {
console.log(message);
}
ngOnInit(): void {
const routeId = this.route.snapshot.paramMap.get('seriesId');
@ -424,7 +426,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
return;
}
// Setup the download in progress
// Set up the download in progress
this.download$ = this.downloadService.activeDownloads$.pipe(takeUntilDestroyed(this.destroyRef), map((events) => {
return this.downloadService.mapToEntityType(events, this.series);
}));
@ -652,12 +654,10 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
this.titleService.setTitle('Kavita - ' + this.series.name + ' Details');
this.seriesActions = this.actionFactoryService.getSeriesActions(this.handleSeriesActionCallback.bind(this))
.filter(action => action.action !== Action.Edit);
this.volumeActions = this.actionFactoryService.getVolumeActions(this.handleVolumeActionCallback.bind(this));
this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this));
this.seriesActions = this.actionFactoryService.getSeriesActions(this.handleSeriesActionCallback.bind(this))
.filter(action => action.action !== Action.Edit);
this.seriesService.getRelatedForSeries(this.seriesId).subscribe((relations: RelatedSeries) => {
@ -677,6 +677,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
...relations.editions.map(item => this.createRelatedSeries(item, RelationKind.Edition)),
...relations.annuals.map(item => this.createRelatedSeries(item, RelationKind.Annual)),
];
if (this.relations.length > 0) {
this.hasRelations = true;
this.cdRef.markForCheck();
@ -690,7 +691,11 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
this.router.navigateByUrl('/home');
return of(null);
})).subscribe(detail => {
if (detail == null) return;
if (detail == null) {
this.router.navigateByUrl('/home');
return;
}
this.unreadCount = detail.unreadCount;
this.totalCount = detail.totalCount;
@ -700,6 +705,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
this.chapters = detail.chapters;
this.volumes = detail.volumes;
this.storyChapters = detail.storylineChapters;
this.storylineItems = [];
const v = this.volumes.map(v => {
return {volume: v, chapter: undefined, isChapter: false} as StoryLineItem;
@ -710,10 +716,13 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
});
this.storylineItems.push(...c);
this.updateWhichTabsToShow();
this.updateSelectedTab();
this.isLoading = false;
this.cdRef.markForCheck();
console.log('isLoading is now false')
});
}, err => {
this.router.navigateByUrl('/home');
@ -724,6 +733,35 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
return {series, relation} as RelatedSeriesPair;
}
shouldShowStorylineTab() {
if (this.libraryType === LibraryType.ComicVine) return false;
// Edge case for bad pdf parse
if (this.libraryType === LibraryType.Book && (this.volumes.length === 0 && this.chapters.length === 0 && this.storyChapters.length > 0)) return true;
return (this.libraryType !== LibraryType.Book && this.libraryType !== LibraryType.LightNovel && this.libraryType !== LibraryType.Comic)
&& (this.volumes.length > 0 || this.chapters.length > 0);
}
shouldShowVolumeTab() {
if (this.libraryType === LibraryType.ComicVine) {
if (this.volumes.length > 1) return true;
if (this.specials.length === 0 && this.chapters.length === 0) return true;
return false;
}
return this.volumes.length > 0;
}
shouldShowChaptersTab() {
return this.chapters.length > 0;
}
updateWhichTabsToShow() {
this.showVolumeTab = this.shouldShowVolumeTab();
this.showStorylineTab = this.shouldShowStorylineTab();
this.showChapterTab = this.shouldShowChaptersTab();
this.cdRef.markForCheck();
}
/**
* This will update the selected tab
*
@ -771,10 +809,14 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
loadPlusMetadata(seriesId: number, libraryType: LibraryType) {
this.isLoadingExtra = true;
this.cdRef.markForCheck();
this.metadataService.getSeriesMetadataFromPlus(seriesId, libraryType).subscribe(data => {
this.isLoadingExtra = false;
this.cdRef.markForCheck();
if (data === null) return;
if (data === null) {
this.isLoadingExtra = false;
this.cdRef.markForCheck();
console.log('isLoadingExtra is false')
return;
}
// Reviews
this.reviews = [...data.reviews];
@ -790,7 +832,9 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
this.hasRecommendations = this.combinedRecs.length > 0;
this.isLoadingExtra = false;
this.cdRef.markForCheck();
console.log('isLoadingExtra is false')
});
}
@ -970,11 +1014,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
downloadSeries() {
this.downloadService.download('series', this.series, (d) => {
if (d) {
this.downloadInProgress = true;
} else {
this.downloadInProgress = false;
}
this.downloadInProgress = !!d;
this.cdRef.markForCheck();
});
}

View file

@ -123,14 +123,14 @@
</app-metadata-detail>
<app-metadata-detail [tags]="seriesMetadata.teams" [libraryId]="series.libraryId" [queryParam]="FilterField.Team" [heading]="t('teams-title')">
<ng-template #itemTemplate let-item>
<app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge>
<ng-template #titleTemplate let-item>
{{item.name}}
</ng-template>
</app-metadata-detail>
<app-metadata-detail [tags]="seriesMetadata.locations" [libraryId]="series.libraryId" [queryParam]="FilterField.Location" [heading]="t('locations-title')">
<ng-template #itemTemplate let-item>
<app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge>
<ng-template #titleTemplate let-item>
{{item.name}}
</ng-template>
</app-metadata-detail>
@ -141,8 +141,8 @@
</app-metadata-detail>
<app-metadata-detail [tags]="seriesMetadata.imprints" [libraryId]="series.libraryId" [queryParam]="FilterField.Imprint" [heading]="t('imprints-title')">
<ng-template #itemTemplate let-item>
<app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge>
<ng-template #titleTemplate let-item>
{{item.name}}
</ng-template>
</app-metadata-detail>

View file

@ -220,6 +220,7 @@ export class DownloadService {
);
}
private getIdKey(entity: Chapter | Volume) {
if (this.utilityService.isVolume(entity)) return 'volumeId';
if (this.utilityService.isChapter(entity)) return 'chapterId';

View file

@ -1,27 +1,30 @@
<ng-container *ngIf="currentValue > 0">
<div [ngClass]="{'number': center}" class="indicator" *ngIf="showIcon">
@if (currentValue > 0) {
@if (showIcon) {
<div [ngClass]="{'number': center}" class="indicator">
<i class="fa fa-angle-double-down" [ngStyle]="{'font-size': fontSize}" aria-hidden="true"></i>
</div>
<div [ngStyle]="{'width': width, 'height': height}">
<circle-progress
[percent]="currentValue"
[radius]="100"
[outerStrokeWidth]="15"
[innerStrokeWidth]="0"
[space] = "0"
[backgroundPadding]="0"
outerStrokeLinecap="butt"
[outerStrokeColor]="outerStrokeColor"
[innerStrokeColor]="innerStrokeColor"
titleFontSize= "24"
unitsFontSize= "24"
[showSubtitle] = "false"
[animation]="animation"
[animationDuration]="300"
[startFromZero]="false"
[responsive]="true"
[backgroundOpacity]="0.5"
[backgroundColor]="backgroundColor"
></circle-progress>
</div>
</ng-container>
</div>
}
<div [ngStyle]="{'width': width, 'height': height}">
<circle-progress
[percent]="currentValue"
[radius]="100"
[outerStrokeWidth]="15"
[innerStrokeWidth]="0"
[space] = "0"
[backgroundPadding]="0"
outerStrokeLinecap="butt"
[outerStrokeColor]="outerStrokeColor"
[innerStrokeColor]="innerStrokeColor"
titleFontSize= "24"
unitsFontSize= "24"
[showSubtitle] = "false"
[animation]="animation"
[animationDuration]="300"
[startFromZero]="false"
[responsive]="true"
[backgroundOpacity]="0.5"
[backgroundColor]="backgroundColor"
></circle-progress>
</div>
}

View file

@ -1,14 +1,11 @@
import {ChangeDetectionStrategy, Component, Input} from '@angular/core';
import {CommonModule} from "@angular/common";
import {CommonModule, NgClass, NgStyle} from "@angular/common";
import {NgCircleProgressModule } from "ng-circle-progress";
@Component({
selector: 'app-circular-loader',
standalone: true,
imports: [CommonModule, NgCircleProgressModule],
// providers: [
// importProvidersFrom(NgCircleProgressModule),
// ],
imports: [NgCircleProgressModule, NgStyle, NgClass],
templateUrl: './circular-loader.component.html',
styleUrls: ['./circular-loader.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush

View file

@ -5,21 +5,28 @@
</div>
<div class="modal-body">
<form style="width: 100%" [formGroup]="listForm">
<div class="mb-3" *ngIf="items.length >= 5">
<label for="filter" class="form-label">{{t('filter')}}</label>
<div class="input-group">
<input id="filter" autocomplete="off" class="form-control" formControlName="filterQuery" type="text" aria-describedby="reset-input">
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="listForm.get('filterQuery')?.setValue('');">{{t('clear')}}</button>
@if (items.length >= 5) {
<div class="mb-3">
<label for="filter" class="form-label">{{t('filter')}}</label>
<div class="input-group">
<input id="filter" autocomplete="off" class="form-control" formControlName="filterQuery" type="text" aria-describedby="reset-input">
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="listForm.get('filterQuery')?.setValue('');">{{t('clear')}}</button>
</div>
</div>
</div>
}
<ul class="list-group">
<li class="list-group-item d-flex justify-content-between align-items-center clickable" *ngFor="let item of items | filter: filterList; let i = index">
{{item}}
<button class="btn btn-primary" *ngIf="clicked !== undefined" (click)="handleClick(item)">
<i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i>
<span class="visually-hidden">{{t('open-filtered-search',{item: item})}}</span>
</button>
</li>
@for(item of items | filter: filterList; track item; let i = $index) {
<li class="list-group-item d-flex justify-content-between align-items-center clickable">
{{item}}
@if (clicked !== undefined) {
<button class="btn btn-primary" (click)="handleClick(item)">
<i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i>
<span class="visually-hidden">{{t('open-filtered-search',{item: item})}}</span>
</button>
}
</li>
}
</ul>
</form>
</div>

View file

@ -1,8 +1,7 @@
import { Component, Input } from '@angular/core';
import {Component, inject, Input} from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { FilterPipe } from '../../../../_pipes/filter.pipe';
import { NgIf, NgFor } from '@angular/common';
import {TranslocoDirective} from "@ngneat/transloco";
@Component({
@ -10,9 +9,11 @@ import {TranslocoDirective} from "@ngneat/transloco";
templateUrl: './generic-list-modal.component.html',
styleUrls: ['./generic-list-modal.component.scss'],
standalone: true,
imports: [ReactiveFormsModule, NgIf, NgFor, FilterPipe, TranslocoDirective]
imports: [ReactiveFormsModule, FilterPipe, TranslocoDirective]
})
export class GenericListModalComponent {
private readonly modal = inject(NgbActiveModal);
@Input() items: Array<string> = [];
@Input() title: string = '';
@Input() clicked: ((item: string) => void) | undefined = undefined;
@ -25,8 +26,6 @@ export class GenericListModalComponent {
return listItem.toLowerCase().indexOf((this.listForm.value.filterQuery || '').toLowerCase()) >= 0;
}
constructor(private modal: NgbActiveModal) {}
close() {
this.modal.close();
}

View file

@ -28,7 +28,7 @@
<table class="table table-striped table-striped table-hover table-sm scrollable">
<thead>
<tr>
<th scope="col" sortable="extension" (sort)="onSort($event)">
<th scope="col" sortable="extension" direction="asc" (sort)="onSort($event)">
{{t('extension-header')}}
</th>
<th scope="col" sortable="format" (sort)="onSort($event)">
@ -40,6 +40,7 @@
<th scope="col" sortable="totalFiles" (sort)="onSort($event)">
{{t('total-files-header')}}
</th>
<th scope="col">{{t('download-file-for-extension-header')}}</th>
</tr>
</thead>
<tbody>
@ -56,6 +57,16 @@
<td>
{{item.totalFiles | number:'1.0-0'}}
</td>
<td>
<button class="btn btn-icon" style="color: var(--primary-color)" (click)="export(item.extension)" [disabled]="downloadInProgress[item.extension]">
@if (downloadInProgress[item.extension]) {
<div class="spinner-border spinner-border-sm" aria-hidden="true"></div>
} @else {
<i class="fa-solid fa-file-arrow-down" aria-hidden="true"></i>
}
<span class="visually-hidden">{{t('download-file-for-extension-alt"', {extension: item.extension})}}</span>
</button>
</td>
</tr>
</tbody>
<tfoot>
@ -73,6 +84,8 @@
</div>
<ng-template #modalTable>
</ng-template>
</ng-container>

View file

@ -16,12 +16,11 @@ import { PieDataItem } from '../../_models/pie-data-item';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { MangaFormatPipe } from '../../../_pipes/manga-format.pipe';
import { BytesPipe } from '../../../_pipes/bytes.pipe';
import { SortableHeader as SortableHeader_1 } from '../../../_single-module/table/_directives/sortable-header.directive';
import { NgIf, NgFor, AsyncPipe, DecimalPipe } from '@angular/common';
import {NgbModal, NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco";
import {filter, tap} from "rxjs/operators";
import {GenericTableModalComponent} from "../_modals/generic-table-modal/generic-table-modal.component";
import {Pagination} from "../../../_models/pagination";
import {DownloadService} from "../../../shared/_services/download.service";
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
export interface StackedBarChartDataItem {
name: string,
@ -34,7 +33,7 @@ export interface StackedBarChartDataItem {
styleUrls: ['./file-breakdown-stats.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [NgbTooltip, ReactiveFormsModule, NgIf, PieChartModule, SortableHeader_1, NgFor, AsyncPipe, DecimalPipe, BytesPipe, MangaFormatPipe, TranslocoDirective]
imports: [NgbTooltip, ReactiveFormsModule, NgIf, PieChartModule, NgFor, AsyncPipe, DecimalPipe, BytesPipe, MangaFormatPipe, TranslocoDirective, SortableHeader]
})
export class FileBreakdownStatsComponent {
@ -42,7 +41,7 @@ export class FileBreakdownStatsComponent {
private readonly cdRef = inject(ChangeDetectorRef);
@ViewChildren(SortableHeader<PieDataItem>) headers!: QueryList<SortableHeader<PieDataItem>>;
@ViewChild('tablelayout') tableTemplate!: TemplateRef<any>;
@ViewChild('modalTable') modalTable!: TemplateRef<any>;
rawData$!: Observable<FileExtensionBreakdown>;
files$!: Observable<Array<FileExtension>>;
@ -55,9 +54,10 @@ export class FileBreakdownStatsComponent {
formControl: FormControl = new FormControl(true, []);
downloadInProgress: {[key: string]: boolean} = {};
private readonly statService = inject(StatisticsService);
private readonly translocoService = inject(TranslocoService);
private readonly ngbModal = inject(NgbModal);
constructor() {
this.rawData$ = this.statService.getFileBreakdown().pipe(takeUntilDestroyed(this.destroyRef), shareReplay());
@ -80,17 +80,6 @@ export class FileBreakdownStatsComponent {
this.vizData2$ = this.files$.pipe(takeUntilDestroyed(this.destroyRef), map(data => data.map(d => {
return {name: d.extension || this.translocoService.translate('file-breakdown-stats.not-classified'), value: d.totalFiles, extra: d.totalSize};
})));
// TODO: See if you can figure this out
// this.formControl.valueChanges.pipe(filter(v => !v), takeUntilDestroyed(this.destroyRef), switchMap(_ => {
// const ref = this.ngbModal.open(GenericTableModalComponent);
// ref.componentInstance.title = translate('file-breakdown-stats.format-title');
// ref.componentInstance.bodyTemplate = this.tableTemplate;
// return ref.dismissed;
// }, tap(_ => {
// this.formControl.setValue(true);
// this.cdRef.markForCheck();
// }))).subscribe();
}
onSort(evt: SortEvent<FileExtension>) {
@ -104,4 +93,15 @@ export class FileBreakdownStatsComponent {
});
}
export(format: string) {
this.downloadInProgress[format] = true;
this.cdRef.markForCheck();
this.statService.downloadFileBreakdown(format)
.subscribe(() => {
this.downloadInProgress[format] = false;
this.cdRef.markForCheck();
});
}
}

View file

@ -0,0 +1 @@
{}

View file

@ -45,7 +45,8 @@
"not-processed": "Nicht verarbeitet",
"chapter-num": "Kapitel {{num}}",
"volume-num": "Band {{num}}",
"special": "{{entity-title.special}}"
"special": "{{entity-title.special}}",
"not-read-warning": "Die Upstream-Anbieter behalten immer die höchste Zahl"
},
"scrobble-event-type-pipe": {
"chapter-read": "Lesefortschritt",
@ -161,7 +162,7 @@
},
"user-holds": {
"title": "Scrobble pausiert",
"description": "Dies ist eine vom Benutzer verwaltete Liste von Serien, die nicht an Upstream-Anbieter übertragen wird. Sie können eine Serie jederzeit entfernen und das nächste scrobble-fähige Ereignis (Lesefortschritt, Bewertung, Lesewunschstatus) wird Ereignisse auslösen."
"description": "Dies ist eine vom Benutzer verwaltete Liste von Serien, die nicht an Upstream-Anbieter übertragen wird. Sie können eine Serie jederzeit entfernen und das nächste Scrobble-fähige Ereignis (Lesefortschritt, Bewertung, Lesewunschstatus) wird Ereignisse auslösen."
},
"theme-manager": {
"title": "Motiv-Manager",
@ -172,7 +173,16 @@
"updated-toastr": "Der Standardwert der Seite wurde auf {{name}} aktualisiert",
"scan-queued": "Eine Website-Motivsuche wurde in die Warteschlange gestellt",
"preview-default-admin": "Wähle ein Design aus oder lade eines hoch",
"preview-title": "Vorschau"
"preview-title": "Vorschau",
"active-theme": "Aktiv",
"upload-continued": "eine CSS Datei",
"description": "Kavita ist in meinen Farben erhältlich. Finde ein Farbschema, das deinen Bedürfnissen entspricht, oder erstellen selbst eines und teile dieses. Die Themen können für dein Konto oder für alle Konten angewendet werden.",
"preview-default": "Wähle zunächst ein Theme aus",
"default-theme": "Standard-Theme",
"drag-n-drop": "{{cover-image-chooser.drag-n-drop}}",
"upload": "{{cover-image-chooser.upload}}",
"delete": "{{common.delete}}",
"download": "{{changelog.donload}}"
},
"theme": {
"theme-dark": "dunkel",
@ -190,7 +200,8 @@
"include-unknowns-tooltip": "Falls wahr, werden unbekannte Medien mit Altersbeschränkung zugelassen. Dies könnte dazu führen, dass nicht gekennzeichnete Medien an Benutzer mit Altersbeschränkungen durchsickern."
},
"site-theme-provider-pipe": {
"system": "System"
"system": "System",
"custom": "{{device-platform-pipe.custom}}"
},
"manage-devices": {
"title": "Gerätemanager",
@ -202,7 +213,7 @@
"add": "{{common.add}}",
"delete": "{{common.delete}}",
"edit": "{{common.edit}}",
"email-setup-alert": "Du möchtest Dateien an deine Geräte senden? Lasse vom Administrator die E-Mail-Einstellungen konfigurieren!"
"email-setup-alert": "Möchten Sie Dateien an Ihre Geräte senden? Lassen Sie zuerst die E-Mail-Einstellungen von Ihrem Administrator einrichten!"
},
"edit-device": {
"device-name-label": "Gerätename",
@ -243,7 +254,7 @@
"save": "{{common.save}}",
"has-invalid-email": "Es scheint Sie haben keine gültige E-Mail angegeben. Ändern Sie die E-Mail, so muss Ihnen der Admin zur Bestätigung einen Link zusenden.",
"valid-email": "{{validation.valid-email}}",
"email-confirmed": "Diese E-Mail Adresse wurde bestätigt",
"email-confirmed": "Diese E-Mail Adresse ist bestätigt",
"email-title": "E-Mail"
},
"change-age-restriction": {
@ -275,11 +286,13 @@
"cancel": "{{common.cancel}}",
"save": "{{common.save}}",
"token-set": "Token festgelegt",
"token-valid": "Der Token ist gültig",
"token-valid": "Token gültig",
"generic-instructions": "Geben Sie Informationen zu den Externen Diensten ein, damit Kavita+ mit diesen interagieren kann.",
"mal-username-input-label": "MAL Benutzername",
"loading": "{{common.loading}}",
"scrobbling-applicable-label": "Scrobbling ist anwendbar"
"scrobbling-applicable-label": "Scrobbling ist anwendbar",
"mal-instructions": "Kavita benutzt eine MAL Client ID für die Authentifizierung. Erstelle einen neuen Client für Kavita und trage die Client ID und ihren Username ein.",
"mal-token-input-label": "MAL Client ID"
},
"typeahead": {
"locked-field": "Feld ist gesperrt",
@ -421,21 +434,24 @@
"doujinshi": "Doujinshi",
"other": "Andere",
"edition": "Ausgabe",
"parent": "Übergeordnet"
"parent": "Übergeordnet",
"annual": "Jährlich"
},
"reset-password": {
"email-label": "{{common.email}}",
"required-field": "{{validation.required-field}}",
"valid-email": "{{validation.valid-email}}",
"submit": "{{common.submit}}",
"title": "Password-Zurücksetzung"
"title": "Passwort zurücksetzen",
"description": "Geben Sie die E-Mail-Adresse Ihres Kontos ein. Kavita sendet Ihnen eine E-Mail, wenn diese vorhanden ist, andernfalls fragen Sie den Administrator nach dem Link aus den Logs."
},
"reset-password-modal": {
"close": "{{common.close}}",
"cancel": "{{common.cancel}}",
"save": "{{common.save}}",
"new-password-label": "Neues Passwort",
"title": "Setze {{username}}s Passwort zurück"
"title": "Setze {{username}}s Passwort zurück",
"error-label": "Fehler: "
},
"all-series": {
"series-count": "{{common.series-count}}",
@ -445,14 +461,18 @@
"close": "{{common.close}}",
"email": "{{common.email}}",
"required-field": "{{common.required-field}}",
"setup-user-description": "Sie können den folgenden Link verwenden, um das Konto für Ihren Benutzer einzurichten, oder die Taste \"Kopieren\" verwenden. Möglicherweise müssen Sie sich erst abmelden, bevor Sie den Link zur Registrierung eines neuen Benutzers verwenden können. Wenn Ihr Server von außen erreichbar ist, wurde eine E-Mail an den Benutzer gesendet und die Links können von diesem verwendet werden, um die Einrichtung des Kontos abzuschließen.",
"setup-user-description": "Sie können den folgenden Link verwenden, um das Konto für Ihren Benutzer einzurichten, oder verwenden Sie die Taste \"Kopieren\". Möglicherweise müssen Sie sich erst abmelden, bevor Sie den Link zur Registrierung eines neuen Benutzers verwenden können. Wenn Ihr Server von außen erreichbar ist, wurde eine E-Mail an den Benutzer gesendet und die Links können von diesem verwendet werden, um die Einrichtung des Kontos abzuschließen.",
"cancel": "{{common.cancel}}",
"setup-user-title": "Nutzer wurde eingeladen",
"invite": "Einladung",
"setup-user-account-tooltip": "Kopiere dies und füge es in einem neuen Tab ein. Du musst dich vielleicht abmelden.",
"title": "Lade einen Nutzer ein",
"invite-url-label": "Einladungs-URL",
"inviting": "Lade ein…"
"inviting": "Lade ein…",
"description": "Lad einen Nutzer auf deinen Server ein. Gib hierzu seine E-Mail ein. Der Nutzer erhält eine E-Mail mit einem Link zum erstellen eines neuen Accounts. \nDamit die Einladungsmail funktioniert, musst du einen E-Mail Server konfigurieren. <a href=\"/admin/dashboard#email\" rel=\"noopener noreferrer\" target=\"_blank\">Email-Server konfigurieren.</a>\nAnsonsten wird der Einladungslink auch hier dargestellt und kann genutzt werden um den neuen Benutzer manuell zu erstellen.<br/><br/>Es ist nicht notwendig das die E-Mail Adresse gültig ist.",
"email-not-sent": "{{toasts.email-not-sent}}",
"notice": "{{manage-settings.notice}}",
"setup-user-account": "Erstelle Benutzerkonto"
},
"library-selector": {
"title": "Bibliotheken",
@ -471,7 +491,7 @@
"activate-description": "Geben Sie den Lizenzschlüssel und die E-Mail-Adresse ein, die Sie bei der Registrierung bei Stripe verwendet haben",
"activate-license-label": "Lizenzschlüssel",
"activate-email-label": "{{common.email}}",
"activate-delete": "Löschen",
"activate-delete": "{{common.delete}}",
"activate-save": "{{common.save}}",
"license-not-valid": "Lizenz ist nicht valide",
"activate-discordId-tooltip": "Verbinde deinen Discord-Account mit Kavita+. Dies gewährt dir Zugriff auf versteckte Kanäle, um Kavita mitzugestalten.",
@ -481,7 +501,9 @@
"license-valid": "Lizenz ist valide",
"manage": "Verwalten",
"discord-validation": "Dies ist keine valide Discord-User-ID. Deine User ID ist nicht dein Discord-Benutzername.",
"invalid-license-tooltip": "Wenn dein Abonnement beendet ist, musst du den Support per E-Mail kontaktieren um ein neues Abonnement abzuschließen"
"invalid-license-tooltip": "Wenn dein Abonnement beendet ist, musst du den Support per E-Mail kontaktieren um ein neues Abonnement abzuschließen",
"activate-reset": "{{common.reset}}",
"activate-reset-tooltip": "Löse deine Kavita+ Lizenz ein. Es wird der Lizenzschlüssel und deine E-Mail benötigt."
},
"book-line-overlay": {
"copy": "Kopieren",
@ -498,11 +520,23 @@
"skip-header": "Zum Hauptinhalt springen",
"settings-header": "Einstellungen",
"table-of-contents-header": "Inhaltsverzeichnis",
"bookmarks-header": "Lesezeichen",
"bookmarks-header": "{{side-nav.bookmarks}}",
"loading-book": "Buch wird geladen…",
"next": "Nächste",
"previous": "Vorherige",
"go-to-page-prompt": "Es gibt {{totalPages}} Seiten. Auf welche Seite möchten sie gehen?"
"go-to-page-prompt": "Es gibt {{totalPages}} Seiten. Auf welche Seite möchten sie gehen?",
"prev-page": "Vorherige Seite",
"incognito-mode-label": "Unsichtbarkeits Modus",
"go-to-page": "Gehe zur Seite",
"title": "Buch Einstellungen",
"toc-header": "ToC",
"pagination-header": "Abschnitt",
"go-back": "Gehe Zurück",
"incognito-mode-alt": "Unsichtbarkeitsmodus ist aktiv. Betätigen zum ausschalten.",
"page-label": "Seite",
"go-to-last-page": "Gehe zur letzten Seite",
"close-reader": "Schließe den Reader",
"virtual-pages": "Virtuelle Seiten"
},
"personal-table-of-contents": {
"page": "Seite {{value}}",
@ -517,12 +551,15 @@
"email-label": "{{common.email}}",
"required-field": "{{common.required-field}}",
"valid-email": "{{common.valid-email}}",
"password-validation": "{{validation.password-validation}}"
"password-validation": "{{validation.password-validation}}",
"register": "Registrieren",
"error-label": "Fehler: "
},
"confirm-email-change": {
"title": "E-Mail-Änderung bestätigen",
"non-confirm-description": "Bitte warten Sie, während Ihre E-Mail-Aktualisierung bestätigt wird.",
"confirm-description": "Ihre E-Mail wurde bestätigt und ist nun in Kavita geändert. Sie werden nun zur Anmeldung weitergeleitet."
"confirm-description": "Ihre E-Mail wurde bestätigt und ist nun in Kavita geändert. Sie werden nun zur Anmeldung weitergeleitet.",
"success": "Erfolgreich!"
},
"confirm-reset-password": {
"title": "Passwort Zurücksetzen",
@ -537,11 +574,12 @@
"description": "Füllen Sie das Formular aus, um ein Administratorkonto zu registrieren",
"username-label": "{{common.username}}",
"email-label": "{{common.email}}",
"email-tooltip": "Die E-Mail muss keine echte Adresse sein, sondern ermöglicht den Zugriff auf vergesse Passwörter. Sie wird nicht außerhalb des Servers versendet, es sei denn, Passwort vergessen wird ohne einen benutzerdefinierten E-Mail-Service-Host verwendet.",
"email-tooltip": "Es gültige E-Mail Adresse ermöglicht die Wiederherstellung des Passworts, wenn dieses vergessen wurde.",
"password-label": "{{common.password}}",
"required-field": "{{validation.required-field}}",
"valid-email": "{{validation.valid-email}}",
"password-validation": "{{validation.password-validation}}"
"password-validation": "{{validation.password-validation}}",
"register": "Registrieren"
},
"series-detail": {
"page-settings-title": "Seiteneinstellungen",
@ -559,7 +597,19 @@
"no-pages": "{{toasts.no-pages}}",
"no-chapters": "Zu diesem Band gibt es keine Kapitel. Kann nicht gelesen werden.",
"cover-change": "Es kann bis zu einer Minute dauern, bis Ihr Browser das Bild aktualisiert hat. Bis dahin kann auf einigen Seiten noch das alte Bild angezeigt werden.",
"read-options-alt": "Leseeinstellungen"
"read-options-alt": "Leseeinstellungen",
"layout-mode-option-card": "Karte",
"add-to-want-to-read": "{{actionable.add-to-want-to-read}}",
"download-series--tooltip": "Serie herunterladen",
"storyline-tab": "Storyline",
"continue-from": "Fortsetzen: {{title}}",
"read-incognito": "Lies Unsichtbar",
"send-to": "Datei wurde an {{deviceName}} gesendet",
"layout-mode-option-list": "Liste",
"continue-incognito": "Unsichtbar fortsetzen",
"incognito": "Unsichtbar",
"remove-from-want-to-read": "{{actionable.remove-from-want-to-read}}",
"edit-series-alt": "Bearbeite die Serieninformationen"
},
"series-metadata-detail": {
"links-title": "Links",
@ -567,7 +617,21 @@
"tags-title": "Tags",
"collections-title": "{{side-nav.collections}}",
"reading-lists-title": "{{side-nav.reading-lists}}",
"promoted": "{{common.promoted}}"
"promoted": "{{common.promoted}}",
"writers-title": "Autor",
"rating-title": "Bewertungen",
"characters-title": "Charaktere",
"publishers-title": "Publisher",
"translators-title": "Übersetzer",
"see-more": "Mehr anzeigen",
"see-less": "Weniger anzeigen",
"letterers-title": "Letterers",
"teams-title": "Team",
"colorists-title": "Koloristen",
"editors-title": "Lektoren",
"cover-artists-title": "Coverdesigner",
"pencillers-title": "Zeichner",
"locations-title": "Standorte"
},
"read-more": {
"read-more": "Mehr lesen",
@ -575,10 +639,15 @@
},
"update-notification-modal": {
"title": "Neue Version verfügbar!",
"close": "{{common.close}}"
"close": "{{common.close}}",
"help": "Wie man updatet",
"download": "Herunterladen"
},
"side-nav-companion-bar": {
"page-settings-title": "{{series-detail.page-settings-title}}"
"page-settings-title": "{{series-detail.page-settings-title}}",
"open-filter-and-sort": "Öffne Filter und Sortieren",
"filter-and-sort-alt": "Sortieren / Filtern",
"close-filter-and-sort": "Schließe Filter und Sortieren"
},
"side-nav": {
"bookmarks": "Lesezeichen",
@ -589,7 +658,10 @@
"collections": "Sammlungen",
"want-to-read": "Favoriten",
"home": "Startseite",
"all-series": "Alle Serien"
"all-series": "Alle Serien",
"more": "Mehr",
"donate-tooltip": "Abonniere Kavita+ um dies zu entfernen",
"back": "Zurück"
},
"library-settings-modal": {
"close": "{{common.close}}",
@ -600,7 +672,17 @@
"cancel": "{{common.cancel}}",
"next": "Nächste",
"save": "{{common.save}}",
"required-field": "{{validation.required-field}}"
"required-field": "{{validation.required-field}}",
"advanced-tab": "Erweitert",
"edit-title": "Bearbeite {{name}}",
"folder-tab": "Ordner",
"cover-tab": "Cover",
"name-label": "Name",
"library-name-unique": "Bibliothek Name muss einmalig sein",
"last-scanned-label": "Zuletzt gescannt:",
"add-title": "Bibliothek hinzufügen",
"folder-description": "Ordner zur Bibliothek hinzufügen",
"general-tab": "Allgemein"
},
"reader-settings": {
"font-family-label": "{{user-preferences.font-family-label}}",
@ -710,7 +792,7 @@
},
"entity-title": {
"special": "Spezial",
"issue-num": "Issue #",
"issue-num": "Problem #",
"chapter": "Kapitel"
},
"external-series-card": {
@ -731,7 +813,7 @@
},
"manage-email-settings": {
"title": "E-Mail-Dienste (SMTP)",
"description": "Kavita wird mit einem E-Mail-Service ausgeliefert, der Aufgaben wie das Einladen von Benutzern, das Zurücksetzen von Passwörtern usw. ermöglicht. E-Mails, die über unseren Dienst gesendet werden, werden sofort gelöscht. Sie können Ihren eigenen E-Mail-Dienst verwenden, indem Sie den {{link}} Dienst einrichten. Geben Sie die URL des E-Mail-Dienstes an und verwenden Sie die Taste Testen, um sicherzustellen, dass es funktioniert. Sie können diese Einstellungen jederzeit auf die Standardwerte zurücksetzen. Es gibt keine Möglichkeit, E-Mails für die Authentifizierung zu deaktivieren, wobei Sie nicht verpflichtet sind, eine gültige E-Mail-Adresse für Benutzer zu verwenden. Bestätigungslinks werden immer in Protokollen gespeichert und in der Benutzeroberfläche angezeigt. Registrierungs-/Bestätigungs-E-Mails werden nicht versendet, wenn Sie nicht über eine öffentlich erreichbare URL auf Kavita zugreifen oder wenn die Funktion Hostname nicht konfiguriert ist.",
"description": "Um Funktionen wie \"Passwort vergessen\" oder \"An Gerät senden\" zu verwenden, muss ein E-Mail Server konfiguriert werden. \nOhne E-Mail Server wird die Funktion \"Passwort ändern\" weniger sicher!",
"reset": "{{common.reset}}",
"test": "Test",
"host-name-label": "Host Name",
@ -879,12 +961,27 @@
"library-type-pipe": {
"manga": "Manga",
"comic": "Comic",
"book": "Buch"
"book": "Buch",
"image": "Bild",
"lightNovel": "Light Novel"
},
"age-rating-pipe": {
"unknown": "Unbekannt",
"early-childhood": "Frühe Kindheit",
"everyone": "Jeder"
"everyone": "Jeder",
"mature": "Ältere",
"teen": "Teenager",
"adults-only": "Nur für Erwachsene",
"everyone-10-plus": "ab 10",
"kids-to-adults": "Kinder zu Erwachsenen",
"rating-pending": "Bewertung ausstehend",
"not-applicable": "Nicht anwendbar",
"pg": "PG",
"g": "G",
"ma15-plus": "MA15+",
"mature-17-plus": "Ab 17+",
"x18-plus": "X18+",
"r18-plus": "R18+"
},
"manga-format-pipe": {
"unknown": "Unbekannt",
@ -898,32 +995,60 @@
},
"out-of-date-modal": {
"description-1": "Bitte erwäge ein Upgrade, sodass du die neueste Version von Kavita verwendest.",
"description-2": "Wird einen Blick auf <a href='https://wiki.kavitareader.com/guides/updating/' target='_blank' rel='noreferrer noopener'>wiki</a> , um Anweisungen zu erhalten, wie du ein Update ausführen kannst.",
"description-2": "Schauen Sie auf <a href='https://wiki.kavitareader.com/guides/updating/' target='_blank' rel='noreferrer noopener'>wiki</a> , um Anweisungen zu erhalten, wie Sie ein Update ausführen können.",
"description-3": "Gibt es einen spezifischen Grund, warum du noch kein Update ausgeführt hast? Wir würden uns freuen, herauszufinden, was dich dazu bringt, eine veraltete Version zu verwenden! Schau in unserem Discord vorbei und teile uns mit, was deinen Upgrade-Pfad blockiert.",
"close": "{{common.close}}",
"subtitle": "Es sieht so aus, als sei deine Installation mehr als {{count}} Versionen im Rückstand!"
"subtitle": "Es sieht so aus, als sei Ihre Installation mehr als {{count}} Versionen im Rückstand!",
"title": "Fall nicht zurück!"
},
"changelog": {
"installed": "Installiert",
"download": "Heruntergeladen",
"nightly": "Nightly: {{version}}",
"nightly": "Beta: {{version}}",
"available": "Verfügbar",
"description-continued": "Tag, nutzt du einen Nighty-Release. Nur Major-Versionen werden als verfügbar angezeigt.",
"description": "Wenn du kein {{installed}} siehst"
"description-continued": "Du nutzt eine BETA-Version. Nur Major-Versionen werden als verfügbar angezeigt.",
"description": "Wenn du kein {{installed}} siehst",
"published-label": "Veröffentlicht: "
},
"all-filters": {
"title": "Alle Smart-Filter",
"count": "{{count}} {{customize-dashboard-modal.title-smart-filters}}"
"count": "{{count}} {{customize-dashboard-modal.title-smart-filters}}",
"create": "{{common.create}}"
},
"publication-status-pipe": {
"cancelled": "Abgebrochen",
"cancelled": "abgebrochen",
"ongoing": "Fortlaufend",
"completed": "Beendet"
"completed": "abgeschlossen",
"ended": "beendet",
"hiatus": "Unterbrechung"
},
"person-role-pipe": {
"artist": "Künstler",
"character": "Charakter",
"editor": "Bearbeiter",
"publisher": "Herausgeber"
"editor": "Editor",
"publisher": "Herausgeber",
"cover-artist": "Cover Künstler",
"translator": "Übersetzer",
"colorist": "Kolorist",
"imprint": "Impressum",
"inker": "Einfärber",
"team": "{{filter-field-pipe.team}}",
"location": "{{filter-field-pipe.location}}",
"other": "Andere",
"penciller": "Zeichner",
"writer": "Autor"
},
"badge-expander": {
"more-items": "und {{count}} mehr"
},
"metadata-filter": {
"reset": "{{common.reset}}"
},
"manage-tasks-settings": {
"cleanup-label": "Bereinigung",
"reset": "{{common.reset}}"
},
"manage-media-settings": {
"reset": "{{common.reset}}"
}
}

View file

@ -1513,7 +1513,8 @@
"users-online-count": "{{num}} Users online",
"active-events-title": "Active Events:",
"no-data": "Not much going on here",
"left-to-process": "Left to Process: {{leftToProcess}}"
"left-to-process": "Left to Process: {{leftToProcess}}",
"download-in-queue": "{{num}} downloads in Queue"
},
"shortcuts-modal": {
@ -1698,6 +1699,8 @@
"image-scaling-label": "Image Scaling",
"height": "Height",
"width": "Width",
"width-override-label": "Width Override",
"off": "Off",
"original": "Original",
"auto-close-menu-label": "{{user-preferences.auto-close-menu-label}}",
"swipe-enabled-label": "Swipe Enabled",
@ -1868,7 +1871,9 @@
"total-size-header": "Total Size",
"total-files-header": "Total Files",
"not-classified": "Not Classified",
"total-file-size-title": "Total File Size:"
"total-file-size-title": "Total File Size:",
"download-file-for-extension-header": "Download Report",
"download-file-for-extension-alt": "Download files Report for {{extension}}"
},
"reading-activity": {
@ -2129,8 +2134,10 @@
"no-series-collection-warning": "Warning! No series are selected, saving will delete the Collection. Are you sure you want to continue?",
"collection-updated": "Collection updated",
"reading-list-deleted": "Reading list deleted",
"reading-lists-deleted": "Reading lists deleted",
"reading-list-updated": "Reading list updated",
"confirm-delete-reading-list": "Are you sure you want to delete the reading list? This cannot be undone.",
"confirm-delete-reading-lists": "Are you sure you want to delete the reading lists? This cannot be undone.",
"item-removed": "Item removed",
"nothing-to-remove": "Nothing to remove",
"series-added-to-reading-list": "Series added to reading list",
@ -2204,6 +2211,8 @@
"collection-not-owned": "You do not own this collection",
"collections-promoted": "Collections promoted",
"collections-unpromoted": "Collections un-promoted",
"reading-lists-promoted": "Reading Lists promoted",
"reading-lists-unpromoted": "Reading Lists un-promoted",
"confirm-delete-collections": "Are you sure you want to delete multiple collections?",
"collections-deleted": "Collections deleted",
"pdf-book-mode-screen-size": "Screen too small for Book mode",
@ -2326,7 +2335,10 @@
"issue-hash-num": "Issue #",
"issue-num": "Issue",
"chapter-num": "Chapter",
"volume-num": "Volume"
"volume-num": "Volume",
"chapter-num-shorthand": "Ch {{num}}",
"issue-num-shorthand": "#{{num}}",
"volume-num-shorthand": "Vol {{num}}"
}
}

View file

@ -142,7 +142,9 @@
"auto-close-menu-label": "{{user-preferences.auto-close-menu-label}}",
"bookmark-page-tooltip": "Marcar página",
"series-progress": "Progreso de la serie: {{porcentage}}",
"bookmarks-title": "Marcadores"
"bookmarks-title": "Marcadores",
"width-override-label": "Anulación de ancho",
"off": "Apagado"
},
"import-cbl-modal": {
"import": "Importar",
@ -207,7 +209,10 @@
"issue-num": "Número",
"clear": "Limpiar",
"filter": "Filtrar",
"remove": "Eliminar"
"remove": "Eliminar",
"issue-num-shorthand": "#{{num}}",
"chapter-num-shorthand": "Ch {{num}}",
"volume-num-shorthand": "Vol {{num}}"
},
"user-preferences": {
"share-series-reviews-label": "Compartir reseñas de series",
@ -1114,7 +1119,11 @@
"pdf-book-mode-screen-size": "Pantalla demasiado pequeña para el modo Libro",
"stack-imported": "Pila importada",
"confirm-delete-theme": "Al eliminar este tema, se borrará del disco. Puedes cogerlo del directorio temporal antes de eliminarlo",
"mal-token-required": "Se requiere un token MAL, establecido en la configuración del usuario"
"mal-token-required": "Se requiere un token MAL, establecido en la configuración del usuario",
"reading-lists-deleted": "Listas de lectura eliminadas",
"confirm-delete-reading-lists": "¿Estás seguro de que deseas eliminar las listas de lectura? Esta acción no se puede deshacer.",
"reading-lists-promoted": "Listas de lectura promovidas",
"reading-lists-unpromoted": "Listas de lectura no promovidas"
},
"library-selector": {
"title": "Bibliotecas",
@ -1281,7 +1290,8 @@
"users-online-count": "{{num}} Usuarios en línea",
"active-events-title": "Eventos activos:",
"no-data": "No hay mucha actividad por aquí",
"left-to-process": "Queda por procesar: {{leftToProcess}}"
"left-to-process": "Queda por procesar: {{leftToProcess}}",
"download-in-queue": "{{num}} descargas en cola"
},
"reader-settings": {
"reset-to-defaults": "Restablecer los valores predeterminados",
@ -1659,7 +1669,9 @@
"total-file-size-title": "Tamaño del fichero:",
"extension-header": "Extensión",
"data-table-label": "Tabla de datos",
"format-tooltip": "No clasificado significa que Kavita no ha escaneado algunos archivos. Esto ocurre en archivos antiguos anteriores a la v0.7. Es posible que tenga que ejecutar una exploración forzada a través de la configuración de la biblioteca."
"format-tooltip": "No clasificado significa que Kavita no ha escaneado algunos archivos. Esto ocurre en archivos antiguos anteriores a la v0.7. Es posible que tenga que ejecutar una exploración forzada a través de la configuración de la biblioteca.",
"download-file-for-extension-alt": "Descargar informe de archivo para {{extension}}",
"download-file-for-extension-header": "Descargar informe"
},
"filter-field-pipe": {
"colorist": "Colorista",
@ -1725,7 +1737,9 @@
"home-page-title": "Página de inicio:",
"title": "Sobre el sistema",
"localization-title": "Ubicaciones:",
"updates-title": "Historial de actualizaciones"
"updates-title": "Historial de actualizaciones",
"first-install-version-title": "Primera versión de instalación",
"first-install-date-title": "Fecha de la primera instalación"
},
"manage-tasks-settings": {
"action-header": "Acción",
@ -2023,7 +2037,8 @@
"delete": "{{common.delete}}",
"no-data": "No se han creado filtros inteligentes",
"filter": "{{common.filter}}",
"clear": "{{common.clear}}"
"clear": "{{common.clear}}",
"errored": "Hay un error de codificación en el filtro. Debes volver a crearlo."
},
"stream-pipe": {
"recently-updated": "{{dashboard.recently-updated-title}}",
@ -2060,7 +2075,8 @@
},
"all-filters": {
"title": "Todos los filtros inteligentes",
"count": "{{count}} {{customize-dashboard-modal.title-smart-filters}}"
"count": "{{count}} {{customize-dashboard-modal.title-smart-filters}}",
"create": "{{common.create}}"
},
"out-of-date-modal": {
"close": "{{common.close}}",

View file

@ -1 +1,78 @@
{}
{
"login": {
"forgot-password": "Unustasid parooli?",
"title": "Meldimine"
},
"dashboard": {
"no-libraries": "Siin pole veel ühtegi raamatukogumit üles seatud. Seadista mõned",
"not-granted": "Sulle ei ole antud juurdepääsu ühelegi kogule.",
"recently-updated-title": "Hiljuti uuendatud sarjad",
"recently-added-title": "Viimati lisatud sarjad",
"more-in-genre-title": "Rohkem žanris {{genre}}",
"server-settings-link": "Serveri seaded",
"on-deck-title": "Laual"
},
"edit-user": {
"saving": "Salvestan…",
"update": "Uuendus"
},
"user-scrobble-history": {
"title": "Scrobble ajalugu",
"description": "Siit leiad oma kontoga seotud scrobble juhtumid. Selleks, et juhtumid tekiks, pead seadistama scrobble serveri seaded. Kõik scrobble juhtumid, mis on serverisse ära saadetud, kustuvad peale ühte kuud. Kui on üleslaadimata juhtumeid, on tõenäoline, et need ei klapi üleslaetud scrobble juhtumitega - palun võta ühendust enda administraatoriga, et need korda saada.",
"is-processed-header": "On töödeldud",
"data-header": "Andmed",
"series-header": "Sari",
"type-header": "Tüüp",
"last-modified-header": "Viimati muudetud",
"not-read-warning": "Allavoolu teenusepakkujad hoiavad alati alles kõrgeima arvu",
"created-header": "Loodud",
"no-data": "Andmed puuduvad",
"volume-and-chapter-num": "Raamat {{v}} peatükk {{n}}",
"volume-num": "Raamat {{num}}",
"chapter-num": "peatükk {{num}}",
"not-processed": "Töötlemata",
"rating": "Hinnang {{r}}",
"not-applicable": "Ei rakendu",
"processed": "Töödeldud"
},
"scrobble-event-type-pipe": {
"want-to-read-add": "Lugemissoov: lisa",
"chapter-read": "Järg",
"score-updated": "Hinnangu uuendus",
"want-to-read-remove": "Lugemissoov: eemalda",
"review": "Ülevaate uuendus"
},
"spoiler": {
"click-to-show": "Potentsiaalselt mittesoovitav eelinfo sisu kohta - kliki seda, et näidata"
},
"review-series-modal": {
"title": "Muuda ülevaade",
"review-label": "Ülevaade",
"min-length": "Ülevaade peab olema vähemalt {{count}} märki pikk"
},
"review-card": {
"local-review": "Süsteemisisene (kohalik) ülevaade",
"your-review": "See on sinu ülevaade",
"external-review": "Süsteemiväline ülevaade",
"rating-percentage": "Hinnang {{r}}%"
},
"want-to-read": {
"title": "Lugemissoov",
"no-items": "Siin ei ole midagi. Proovi sari lisada.",
"no-items-filtered": "Hetkefiltrile ei vasta ühtegi eset."
},
"user-preferences": {
"title": "Kasutaja kiirlinkide töölaud",
"3rd-party-clients-tab": "Kolmanda osapoole klientprogrammid",
"theme-tab": "Teema",
"devices-tab": "Seaded",
"pref-description": "Need on süsteemiülesed seaded, mis rakenduvad sinu kontole.",
"account-tab": "Konto",
"preferences-tab": "Eelistused"
},
"review-card-modal": {
"user-review": "{{username}} ülevaade",
"external-mod": "(väline)",
"go-to-review": "Ülevaate juurde"
}
}

View file

@ -45,7 +45,7 @@
"not-processed": "Non Traité",
"chapter-num": "Chapitre {{num}}",
"volume-num": "Volume {{num}}",
"not-read-warning": "Les fournisseurs upstream conserveront toujours le nombre le plus élevé",
"not-read-warning": "Les fournisseurs en amont conserveront toujours le nombre le plus élevé",
"special": "{{entity-title.special}}"
},
"scrobble-event-type-pipe": {
@ -76,7 +76,7 @@
"review-card": {
"your-review": "Voici votre critique",
"external-review": "Critique externe",
"local-review": "Critique locale",
"local-review": "Avis locale",
"rating-percentage": "Évaluation {{r}}%"
},
"want-to-read": {
@ -634,7 +634,10 @@
"promoted": "Promu",
"clear": "Effacer",
"filter": "Filtre",
"remove": "Retirer"
"remove": "Retirer",
"chapter-num-shorthand": "Ch {{num}}",
"issue-num-shorthand": "#{{num}}",
"volume-num-shorthand": "Vol {{num}}"
},
"filter-comparison-pipe": {
"must-contains": "Doit contenir",
@ -746,7 +749,7 @@
"rating-pending": "Classification en attente",
"everyone": "Tous publics",
"ma15-plus": "15+",
"mature-17-plus": "Mature 17+"
"mature-17-plus": "Jeunes adultes 17+"
},
"server-stats": {
"tags": "Étiquettes",
@ -1094,7 +1097,8 @@
},
"all-filters": {
"title": "Tous les filtres intelligents",
"count": "{{count}} {{customize-dashboard-modal.title-smart-filters}}"
"count": "{{count}} {{customize-dashboard-modal.title-smart-filters}}",
"create": "{{common.create}}"
},
"personal-table-of-contents": {
"delete": "Supprimer {{bookmarkName}}",
@ -1445,7 +1449,9 @@
"wiki-title": "Wiki:",
"installId-title": "ID d'installation",
"localization-title": "Localisations:",
"updates-title": "Historique des mises à jour"
"updates-title": "Historique des mises à jour",
"first-install-version-title": "Version de première installation",
"first-install-date-title": "Date de la première installation"
},
"manage-tasks-settings": {
"title": "Tâches récurrentes",
@ -1675,7 +1681,9 @@
"incognito-title": "Mode incognito:",
"bookmark-page-tooltip": "Page des marque-pages",
"emulate-comic-book-label": "{{user-preferences.emulate-comic-book-label}}",
"reading-direction-tooltip": "Sens de lecture: "
"reading-direction-tooltip": "Sens de lecture: ",
"off": "Désactivé",
"width-override-label": "Remplacement de la largeur"
},
"events-widget": {
"active-events-title": "Événements actifs:",
@ -1687,7 +1695,8 @@
"more-info": "Cliquez pour plus d'informations",
"downloading-item": "Téléchargement {{item}}",
"title-alt": "Activité",
"left-to-process": "Reste à traiter: {{leftToProcess}}"
"left-to-process": "Reste à traiter: {{leftToProcess}}",
"download-in-queue": "{{num}} féléchargements dans la file d'attente"
},
"library-recommended": {
"on-deck": "{{dashboard.on-deck-title}}",
@ -1819,7 +1828,9 @@
"data-table-label": "Tableau de données",
"total-file-size-title": "Taille totale du fichier:",
"extension-header": "Extension",
"format-tooltip": "Non classé signifie que Kavita n'a pas analysé certains fichiers. Cela se produit pour les anciens fichiers antérieurs à la version 0.7. Il se peut que vous deviez lancer une analyse forcée via la fenêtre modale des paramètres de la bibliothèque."
"format-tooltip": "Non classé signifie que Kavita n'a pas analysé certains fichiers. Cela se produit pour les anciens fichiers antérieurs à la version 0.7. Il se peut que vous deviez lancer une analyse forcée via la fenêtre modale des paramètres de la bibliothèque.",
"download-file-for-extension-alt": "Télécharger les fichiers Rapport pour {{extension}}",
"download-file-for-extension-header": "Rapport de téléchargement"
},
"stream-pipe": {
"recently-updated": "{{dashboard.recently-updated-title}}",
@ -1930,7 +1941,11 @@
"collections-promoted": "Promotion des collections",
"stack-imported": "Pile importée",
"confirm-delete-theme": "Supprimer ce thème l'effacera du disque. Vous pouvez le récupérer du dossier temp avant suppression",
"mal-token-required": "Le jeton MAL est requis, il est défini dans les paramètres de l'utilisateur"
"mal-token-required": "Le jeton MAL est requis, il est défini dans les paramètres de l'utilisateur",
"reading-lists-unpromoted": "Listes de lecture non promues",
"reading-lists-deleted": "Listes de lecture supprimées",
"confirm-delete-reading-lists": "Êtes-vous sûr de vouloir supprimer les listes de lecture? Cela ne peut pas être annulé.",
"reading-lists-promoted": "Listes de lecture promues"
},
"manga-format-stats": {
"title": "Format",
@ -2051,7 +2066,8 @@
"no-data": "Aucun filtre intelligent n'a été créé",
"filter": "{{common.filter}}",
"delete": "{{common.delete}}",
"clear": "{{common.clear}}"
"clear": "{{common.clear}}",
"errored": "Il y a une erreur d'encodage dans le filtre. Vous devez le recréer."
},
"day-breakdown": {
"x-axis-label": "Jour de la semaine",

View file

@ -11,7 +11,7 @@
"server-settings-link": "サーバー設定",
"recently-updated-title": "最近更新されたシリーズ",
"recently-added-title": "最近追加されたシリーズ",
"on-deck-title": "最近読ん本",
"on-deck-title": "最近読んでいる本",
"more-in-genre-title": "ジャンルを追加する {{genre}}",
"no-libraries": "まだライブラリが設定されていません。設定するには",
"not-granted": "すべてのライブラリーにアクセス権が与えられていません。"
@ -246,7 +246,7 @@
"delete": "削除",
"download": "ダウンロード",
"clear": "{{common.clear}}",
"remove-from-on-deck": "最近読ん本から削除",
"remove-from-on-deck": "最近読んでいる本から削除",
"refresh-covers": "カバー画像の更新",
"scan-library": "ライブラリのスキャン",
"add-to-collection": "コレクションに追加",
@ -527,7 +527,7 @@
"add-title": "ライブラリを追加",
"include-in-search-tooltip": "ライブラリから派生した情報(ジャンル、人、ファイル)を検索結果に含めます。",
"folder-watching-tooltip": "このライブラリのフォルダー監視設定を変更します。オフにすると、このライブラリが含むフォルダーに対する監視が停止します。ライブラリがフォルダーを共有している場合、それらのフォルダーは引き続き監視の対象となります。スキャンをトリガーする前には、必ず10分待機します。",
"include-in-dashboard-tooltip": "ライブラリのシリーズをダッシュボードに表示しますか?これにより、最近読ん本、最近更新されたシリーズ、最近追加されたシリーズなどのすべての表示が影響を受けます。",
"include-in-dashboard-tooltip": "ライブラリのシリーズをダッシュボードに表示しますか?これにより、最近読んでいる本、最近更新されたシリーズ、最近追加されたシリーズなどのすべての表示が影響を受けます。",
"include-in-recommendation-label": "おすすめに含める",
"naming-conventions-part-2": "フォルダーの要件",
"manage-reading-list-tooltip": "ComicInfo.xml/opfファイル内のStoryArc/StoryArcNumberおよびAlternativeSeries/AlternativeCountタグからReading Listsを自動的に作成",
@ -1720,15 +1720,15 @@
"opds-label": "OPDS",
"min-backup-validation": "バックアップが1つ以上ある必要があります",
"log-label": "ログの日数",
"on-deck-last-progress-tooltip": "「最近読ん本」に含める、最後の進捗からの日数です。",
"on-deck-last-progress-tooltip": "「最近読んでいる本」に含める、最後の進捗からの日数です。",
"ip-address-validation": "IP アドレスは、有効な IPv4 または IPv6 アドレスのみを含むことができます",
"base-url-validation": "ベースURLは、/で始まり、/で終わる必要があります。",
"ip-address-label": "IPアドレス",
"ip-address-tooltip": "サーバーがリッスンするIPアドレスのカンマ区切りのリストです。Dockerで実行している場合は固定されています。有効にするには再起動が必要です。",
"log-tooltip": "維持するログの数。 デフォルトは30、最小は1、最大は30です。",
"on-deck-last-progress-label": "「最近読ん本」最後の進捗からの日数",
"on-deck-last-progress-label": "「最近読んでいる本」最後の進捗からの日数",
"allow-stats-label": "匿名利用コレクションを許可",
"on-deck-last-chapter-add-tooltip": "「最近読ん本」に含める、最後に章が追加されてからの日数です。",
"on-deck-last-chapter-add-tooltip": "「最近読んでいる本」に含める、最後に章が追加されてからの日数です。",
"folder-watching-tooltip": "Kavitaにライブラリフォルダーを監視し、変更を検知してこれに対してスキャンを実行する機能を提供します。これにより、手動でスキャンを呼び出すか、夜間のスキャンを待つ必要なく、コンテンツを更新できます。スキャンをトリガーする前に常に10分間待機します。",
"min-logs-validation": "少なくとも1つのログを持っている必要があります",
"port-tooltip": "サーバーがリッスンするIPアドレスのカンマ区切りのリストです。Dockerで実行している場合は固定されています。有効にするには再起動が必要です。",
@ -1749,7 +1749,7 @@
"allow-stats-tooltip-part-1": "Kavitaのサーバーに匿名の使用データを送信します。これには使用された特定の機能、ファイルの数、OSバージョン、Kavitaのインストールバージョン、CPU、およびメモリの情報が含まれます。この情報は機能の優先順位付け、バグ修正、およびパフォーマンスの調整に使用されます。有効にするには再起動が必要です。 ",
"allow-stats-tooltip-part-2": "収集するものについて",
"enable-folder-watching": "フォルダー監視を有効にする",
"on-deck-last-chapter-add-label": "「最近読ん本」最終追加日からの日数",
"on-deck-last-chapter-add-label": "「最近読んでいる本」最終追加日からの日数",
"host-name-label": "{{manage-email-settings.host-name-label}}",
"host-name-tooltip": "{{manage-email-settings.host-name-tooltip}}",
"host-name-validation": "{{manage-email-settings.host-name-validation}}"

View file

@ -44,7 +44,9 @@
"is-processed-header": "처리됨",
"title": "스크로블 이력",
"volume-num": "볼륨 {{num}}",
"chapter-num": "챕터 {{num}}"
"chapter-num": "챕터 {{num}}",
"not-read-warning": "업스트림 공급자는 항상 가장 높은 숫자를 유지합니다",
"special": "{{entity-title.special}}"
},
"scrobble-event-type-pipe": {
"chapter-read": "읽기 진행률",
@ -101,7 +103,10 @@
"promoted": "승격",
"clear": "삭제",
"filter": "필터",
"remove": "제거"
"remove": "제거",
"chapter-num-shorthand": "Ch {{num}}",
"issue-num-shorthand": "#{{num}}",
"volume-num-shorthand": "Vol {{num}}"
},
"series-metadata-detail": {
"characters-title": "캐릭터",
@ -285,7 +290,12 @@
"promote-tooltip": "승격은 관리 사용자뿐만 아니라 서버 전체에서 태그를 볼 수 있음을 의미합니다. 이 태그가 있는 모든 시리즈에는 여전히 사용자 액세스 제한이 적용됩니다.",
"summary-label": "요약",
"name-label": "이름",
"filter-label": "{{common.filter}}"
"filter-label": "{{common.filter}}",
"info-tab": "{{edit-series-modal.info-tab}}",
"last-sync-title": "마지막 동기화:",
"source-url-title": "소스 URL:",
"total-series-title": "총 시리즈:",
"missing-series-title": "누락된 시리즈:"
},
"nav-header": {
"promoted": "{{common.promoted}}",
@ -375,7 +385,8 @@
"time-to-read": "완독 예상 시간",
"last-chapter-added": "추가된 항목",
"read-progress": "마지막으로 읽음",
"average-rating": "평균 평점"
"average-rating": "평균 평점",
"random": "랜덤"
},
"manga-format-stats": {
"title": "포맷",
@ -436,7 +447,10 @@
"rejected-cover-upload": "서버가 요청을 거부하여 이미지를 가져올 수 없습니다. 대신 파일에서 다운로드하여 업로드하세요.",
"series-doesnt-exist": "이 시리즈는 더 이상 존재하지 않습니다",
"collection-invalid-access": "이 태그가 속한 라이브러리에 액세스할 수 없거나 이 컬렉션이 유효하지 않습니다",
"user-not-auth": "사용자가 인증되지 않았습니다"
"user-not-auth": "사용자가 인증되지 않았습니다",
"theme-already-in-use": "해당 이름의 테마가 이미 존재합니다",
"theme-manual-upload": "수동 업로드로 테마를 생성하는 중에 문제가 발생했습니다",
"delete-theme-in-use": "현재 최소 한 명의 사용자가 테마를 사용 중이므로 삭제할 수 없습니다"
},
"toasts": {
"no-updates": "사용 가능한 업데이트 없음",
@ -533,7 +547,14 @@
"collections-unpromoted": "컬렉션이 홍보되지 않음",
"confirm-delete-collections": "여러 컬렉션을 삭제하시겠습니까?",
"collections-deleted": "컬렉션이 삭제되었습니다",
"pdf-book-mode-screen-size": "책 모드에 비해 화면이 너무 작음"
"pdf-book-mode-screen-size": "책 모드에 비해 화면이 너무 작음",
"stack-imported": "가져온 스택",
"confirm-delete-theme": "이 테마를 제거하면 디스크에서 삭제됩니다. 제거하기 전에 임시 디렉토리에서 가져올 수 있습니다",
"mal-token-required": "MAL 토큰이 필요합니다. 사용자 설정에서 설정하세요",
"reading-lists-deleted": "읽기 목록 삭제",
"reading-lists-promoted": "관심 목록",
"confirm-delete-reading-lists": "읽기 목록을 삭제하시겠습니까? 이것은 취소가 불가능 합니다.",
"reading-lists-unpromoted": "관심 리스트 등록 해제"
},
"actionable": {
"mark-as-read": "읽은 상태로 표시",
@ -864,7 +885,7 @@
"card-detail-drawer": {
"general-tab": "일반",
"metadata-tab": "메타데이터",
"info-tab": "정보",
"info-tab": "{{edit-series-modal.info-tab}}",
"pages": "페이지:",
"cover-tab": "표지",
"no-summary": "사용 가능한 요약이 없습니다.",
@ -986,7 +1007,9 @@
"sender-address-tooltip": "이는 수신자가 이메일을 받을 때 볼 수 있는 이메일 주소입니다. 일반적으로 계정과 연결된 이메일 주소입니다.",
"sender-displayname-tooltip": "수신자가 이메일을 받을 때 보게 될 이름",
"username-tooltip": "호스트에 대해 인증하는 데 사용되는 사용자 이름",
"host-tooltip": "이메일 서버의 발신/SMTP 주소"
"host-tooltip": "이메일 서버의 발신/SMTP 주소",
"setting-description": "Kavita 내에서 이메일 기반 기능을 사용하려면 호스트 이름과 SMTP 설정을 모두 입력해야 합니다.",
"test-warning": "테스트 버튼을 사용하기 전에 반드시 저장해야 합니다."
},
"manage-scrobble-errors": {
"edit-item-alt": "편집 {{seriesName}}",
@ -1074,7 +1097,9 @@
"no-data": "아무것도 없습니다",
"close": "{{common.close}}",
"users-online-count": "{{num}} 사용자 온라인",
"active-events-title": "활성 이벤트:"
"active-events-title": "활성 이벤트:",
"left-to-process": "처리할 남은 항목: {{leftToProcess}}",
"download-in-queue": "대기열에서 {{num}} 다운로드"
},
"shortcuts-modal": {
"close": "{{common.close}}",
@ -1098,7 +1123,8 @@
"people": "인물",
"tags": "태그",
"genres": "장르",
"bookmarks": "{{side-nav.bookmarks}}"
"bookmarks": "{{side-nav.bookmarks}}",
"include-extras": "챕터 및 파일 포함"
},
"add-to-list-modal": {
"filter-label": "{{common.filter}}",
@ -1191,7 +1217,9 @@
"unbookmark-page-tooltip": "페이지 북마크 해제",
"bookmark-page-tooltip": "페이지 북마크",
"series-progress": "시리즈 진행률: {{percentage}}",
"bookmarks-title": "북마크"
"bookmarks-title": "북마크",
"width-override-label": "너비 재지정",
"off": "끄기"
},
"metadata-filter": {
"penciller-label": "펜슬러",
@ -1348,7 +1376,7 @@
"encode-as-description-part-2": "WebP를 사용할 수 있습니까?",
"encode-as-description-part-3": "AVIF를 사용할 수 있습니까?",
"encode-as-warning": "WebP/AVIF로 이동한 후에는 PNG로 다시 변환할 수 없습니다. 모든 표지를 재생성하려면 라이브러리의 표지를 새로 고쳐야 합니다. 북마크와 파비콘은 변환할 수 없습니다.",
"media-warning": "작업 탭에서 미디어 변환 작업을 트리거해야 합니다.,",
"media-warning": "작업 탭에서 미디어 변환 작업을 트리거해야 합니다.",
"encode-as-label": "미디어를 다른 이름으로 저장",
"encode-as-tooltip": "Kavita가 관리하는 모든 미디어(표지, 북마크, 파비콘)는 이 유형으로 인코딩됩니다.",
"bookmark-dir-label": "북마크 디렉토리",
@ -1369,7 +1397,18 @@
"set-default": "기본값으로 설정",
"apply": "{{common.apply}}",
"applied": "적용됨",
"scan-queued": "사이트 테마 스캔을 대기 중입니다"
"scan-queued": "사이트 테마 스캔을 대기 중입니다",
"default-theme": "기본 테마",
"download": "{{changelog.download}}",
"drag-n-drop": "{{cover-image-chooser.drag-n-drop}}",
"upload": "{{cover-image-chooser.upload}}",
"upload-continued": "CSS 파일",
"description": "Kavita는 내 색상으로 제공되며, 귀하의 필요에 맞는 색상 구성표를 찾거나 직접 만들어 공유할 수 있습니다. 테마는 귀하의 계정에 적용되거나 모든 계정에 적용될 수 있습니다.",
"active-theme": "활성",
"delete": "{{common.delete}}",
"preview-default": "먼저 테마를 선택하세요",
"preview-default-admin": "먼저 테마를 선택하거나 수동으로 업로드하세요",
"preview-title": "미리보기"
},
"change-email": {
"invite-url-tooltip": "복사하여 새 탭에 붙여넣기",
@ -1703,7 +1742,9 @@
"donations-title": "기부:",
"source-title": "소스:",
"localization-title": "현지화:",
"updates-title": "업데이트 내역"
"updates-title": "업데이트 내역",
"first-install-version-title": "최초 설치 버전",
"first-install-date-title": "최초 설치 날짜"
},
"library-detail": {
"library-tab": "라이브러리",
@ -1744,7 +1785,9 @@
"extension-header": "확장자",
"total-size-header": "총 크기",
"total-file-size-title": "총 파일 크기:",
"not-classified": "분류되지 않음"
"not-classified": "분류되지 않음",
"download-file-for-extension-alt": "{{extension}}에 대한 파일 보고서 다운로드",
"download-file-for-extension-header": "보고서 다운로드"
},
"want-to-read": {
"series-count": "{{common.series-count}}",
@ -1759,7 +1802,8 @@
"rating-percentage": "평점 {{r}}%"
},
"site-theme-provider-pipe": {
"system": "시스템"
"system": "시스템",
"custom": "{{device-platform-pipe.custom}}"
},
"manage-devices": {
"title": "장치 관리자",
@ -1835,7 +1879,9 @@
"no-data": "항목이 없습니다. 시리즈를 추가해 보세요.",
"no-data-filtered": "현재 필터와 일치하는 항목이 없습니다.",
"title-alt": "Kavita - {{collectionName}} 컬렉션",
"series-header": "시리즈"
"series-header": "시리즈",
"sync-progress": "수집된 시리즈: {{title}}",
"last-sync": "마지막 동기화: {{date}}"
},
"all-collections": {
"title": "컬렉션",
@ -2015,14 +2061,16 @@
"no-data": "스마트 필터가 생성되지 않았습니다",
"filter": "{{common.filter}}",
"delete": "{{common.delete}}",
"clear": "{{common.clear}}"
"clear": "{{common.clear}}",
"errored": "필터에 인코딩 오류가 있습니다. 다시 생성해야 합니다."
},
"next-expected-card": {
"title": "~{{date}}"
},
"all-filters": {
"title": "모든 스마트 필터",
"count": "{{count}} {{customize-dashboard-modal.title-smart-filters}}"
"count": "{{count}} {{customize-dashboard-modal.title-smart-filters}}",
"create": "{{common.create}}"
},
"file-type-group-pipe": {
"epub": "Epub",
@ -2059,7 +2107,8 @@
"description": "MAL 관심 스택을 가져오고 Kavita 내에서 컬렉션을 생성하세요",
"series-count": "{{common.series-count}}",
"restack-count": "{{num}} 재정렬",
"close": "{{common.close}}"
"close": "{{common.close}}",
"nothing-found": "아무것도 찾을 수 없음"
},
"pdf-layout-mode-pipe": {
"single": "단일 페이지",
@ -2084,5 +2133,8 @@
"collection-owner": {
"collection-via-label": "{{source}}을(를) 통해",
"collection-created-label": "작성자: {{owner}}"
},
"browse-themes-modal": {
"title": "테마 찾아보기"
}
}

View file

@ -44,7 +44,8 @@
"processed": "Processado",
"not-processed": "Não Processado",
"volume-num": "Volume {{num}}",
"chapter-num": "Capítulo {{num}}"
"chapter-num": "Capítulo {{num}}",
"special": "{{entity-title.special}}"
},
"scrobble-event-type-pipe": {
"chapter-read": "Leitura Efetuada",
@ -169,7 +170,18 @@
"apply": "{{common.apply}}",
"applied": "Aplicado",
"updated-toastr": "O tema por defeito do site foi atualizado para {{name}}",
"scan-queued": "Foi agendado um scan the temas do site"
"scan-queued": "Foi agendado um scan the temas do site",
"default-theme": "Tema por defeito",
"drag-n-drop": "{{cover-image-chooser.drag-n-drop}}",
"preview-default": "Selecione um tema primeiro",
"preview-default-admin": "Selecione um tema primeiro ou carregue um manualmente",
"download": "{{changelog.download}}",
"description": "O Kavita usa as minhas cores, encontre uma palete de cores de acordo com as suas necessidades, crie um tema e partilhe-o. Os temas podem ser usados na sua conta ou em todas as contas.",
"active-theme": "Ativo",
"delete": "{{common.delete}}",
"upload": "{{cover-image-chooser.upload}}",
"upload-continued": "um ficheiro css",
"preview-title": "Pré-visualizar"
},
"theme": {
"theme-dark": "Escuro",
@ -187,7 +199,8 @@
"include-unknowns-tooltip": "Quando ativado, os Desconhecidos serão permitidos independentemente da Restrição de Faixa Etária. Com esta opção é possível que elementos que não estejam devidamente categorizados sejam mostrados a utilizadores com restrições etárias definidas."
},
"site-theme-provider-pipe": {
"system": "Sistema"
"system": "Sistema",
"custom": "{{device-platform-pipe.custom}}"
},
"manage-devices": {
"title": "Gestor de Dispositivos",
@ -835,7 +848,7 @@
"general-tab": "Geral",
"metadata-tab": "Metadados",
"cover-tab": "Capa",
"info-tab": "Info",
"info-tab": "{{edit-series-modal.info-tab}}",
"no-summary": "Sumário inexistente.",
"writers-title": "{{series-metadata-detail.writers-title}}",
"genres-title": "{{series-metadata-detail.genres-title}}",
@ -1011,7 +1024,9 @@
"host-tooltip": "Endereço de saída/SMTP do seu servidor de email",
"username-tooltip": "O nome de utilizador usado para autenticar no host",
"outlook-label": "Outlook",
"gmail-label": "Gmail"
"gmail-label": "Gmail",
"test-warning": "Tem de gravar antes de usar o botão Teste.",
"setting-description": "Tem de preencher o nome do Host e as definições de SMTP para poder usar as funcionalidades do Kavita baseadas em email."
},
"manage-library": {
"title": "Bibliotecas",
@ -1032,7 +1047,7 @@
"encode-as-description-part-2": "Posso usar WebP?",
"encode-as-description-part-3": "Posso usar AViF?",
"encode-as-warning": "Não é possível converter de volta para PNG depois da conversão para WebP/AVIF. Seria necessário refrescar as capas nas suas bibliotecas para regenerar as capas. Os marcadores e favicons não podem ser convertidos.",
"media-warning": "Tem de despoletar a tarefa de conversão de ficheiros na Aba Tarefas,",
"media-warning": "Tem de despoletar a tarefa de conversão de ficheiros na Aba Tarefas.",
"encode-as-label": "Guardar Media Como",
"encode-as-tooltip": "Todos os ficheiros geridos pelo Kavita (capas, marcadores, favicons) serão codificados para este tipo.",
"bookmark-dir-label": "Diretoria de Marcadores",
@ -1119,7 +1134,9 @@
"source-title": "Código fonte:",
"feature-request-title": "Pedidos de Funcionalidades:",
"localization-title": "Idiomas:",
"updates-title": "Histórico das Atualizações"
"updates-title": "Histórico das Atualizações",
"first-install-version-title": "Versão da Primeira Instalação",
"first-install-date-title": "Data da Primeira Instalação"
},
"manage-tasks-settings": {
"title": "Tarefas Recorrentes",
@ -1216,7 +1233,12 @@
"summary-label": "Sumário",
"deselect-all": "{{common.deselect-all}}",
"select-all": "{{common.select-all}}",
"filter-label": "{{common.filter}}"
"filter-label": "{{common.filter}}",
"info-tab": "{{edit-series-modal.info-tab}}",
"last-sync-title": "Última Sincronização:",
"source-url-title": "Url de Origem:",
"total-series-title": "Total de Séries:",
"missing-series-title": "Séries em Falta:"
},
"library-detail": {
"library-tab": "Biblioteca",
@ -1253,7 +1275,8 @@
"no-data": "Não existem itens. Tente adicionar uma série.",
"no-data-filtered": "Não existem itens para o filtro atual.",
"title-alt": "Kavita - Coleção {{collectionName}}",
"series-header": "Séries"
"series-header": "Séries",
"last-sync": "Última Sincronização: {{date}}"
},
"all-collections": {
"title": "Coleções",
@ -1304,7 +1327,9 @@
"close": "{{common.close}}",
"users-online-count": "{{num}} Utilizadores ligados",
"active-events-title": "Eventos Ativos:",
"no-data": "Nada para ver aqui"
"no-data": "Nada para ver aqui",
"left-to-process": "Por Processar: {{leftToProcess}}",
"download-in-queue": "{{num}} downloads em espera"
},
"shortcuts-modal": {
"title": "Atalhos de Teclado",
@ -1328,7 +1353,8 @@
"collections": "Coleções",
"close": "{{common.close}}",
"loading": "{{common.loading}}",
"bookmarks": "{{side-nav.bookmarks}}"
"bookmarks": "{{side-nav.bookmarks}}",
"include-extras": "Incluir Capítulos & Ficheiros"
},
"nav-header": {
"skip-alt": "Saltar para o conteúdo principal",
@ -1435,7 +1461,9 @@
"bookmark-page-tooltip": "Marcar Página",
"series-progress": "Progresso da Série: {{percentage}}",
"bookmarks-title": "Marcadores",
"swipe-enabled-label": "Deslizar Ativado"
"swipe-enabled-label": "Deslizar Ativado",
"width-override-label": "Sobrepor Largura",
"off": "Desligado"
},
"metadata-filter": {
"filter-title": "{{common.filter}}",
@ -1484,7 +1512,8 @@
"time-to-read": "Tempo para Ler",
"release-year": "Ano de Lançamento",
"read-progress": "Última Leitura",
"average-rating": "Classificação Média"
"average-rating": "Classificação Média",
"random": "Aleatório"
},
"edit-series-modal": {
"title": "Detalhes de {{seriesName}}",
@ -1568,7 +1597,8 @@
"total-size-header": "Tamanho Total",
"total-files-header": "Ficheiros Totais",
"not-classified": "Não Classificado",
"total-file-size-title": "Tamanho Total dos Ficheiros:"
"total-file-size-title": "Tamanho Total dos Ficheiros:",
"download-file-for-extension-header": "Descarregar Relatório"
},
"reading-activity": {
"legend-label": "Formatos",
@ -1644,7 +1674,10 @@
"invalid-confirmation-url": "Url de confirmação inválido",
"invalid-confirmation-email": "Email de confirmação inválido",
"invalid-password-reset-url": "Url para repor palavra passe inválido",
"rejected-cover-upload": "A imagem não pode ser obtida porque o servidor recusou o pedido. Faça download e upload do ficheiro, por favor."
"rejected-cover-upload": "A imagem não pode ser obtida porque o servidor recusou o pedido. Faça download e upload do ficheiro, por favor.",
"theme-already-in-use": "Já existe um tema com este nome",
"delete-theme-in-use": "O tema está a ser usado por pelo menos um utilizador, não pode ser eliminado",
"theme-manual-upload": "Houve um problema a criar o Tema a partir do upload manual"
},
"toasts": {
"regen-cover": "Foi agendada uma tarefa para gerar novamente a imagem de capa",
@ -1741,7 +1774,13 @@
"collections-unpromoted": "Coleções não promovidas",
"confirm-delete-collections": "Tem certeza de que deseja apagar várias coleções?",
"collections-deleted": "Coleções apagadas",
"pdf-book-mode-screen-size": "Ecrã pequeno demais para o modo Livro"
"pdf-book-mode-screen-size": "Ecrã pequeno demais para o modo Livro",
"reading-lists-deleted": "Lista de leitura eliminada",
"confirm-delete-reading-lists": "Tem a certeza que deseja eliminar as listas de leituras? Este passo não pode ser desfeito.",
"reading-lists-unpromoted": "Listas de Leitura despromovidas",
"reading-lists-promoted": "Listas de Leitura promovidas",
"confirm-delete-theme": "Remover este tema irá eliminá-lo do disco. Pode obtê-lo da diretoria temporária antes de ser removido",
"mal-token-required": "É necessário o Token MAL, defina-o nas Definições de Utilizador"
},
"actionable": {
"scan-library": "Analisar Biblioteca",
@ -1852,7 +1891,10 @@
"filter": "Filtrar",
"reset-to-default": "Repôr Valor por Defeito",
"submit": "Submeter",
"remove": "Remover"
"remove": "Remover",
"chapter-num-shorthand": "Canal {{num}}",
"volume-num-shorthand": "Vol {{num}}",
"issue-num-shorthand": "#{{num}}"
},
"cover-image-size": {
"default": "Padrão (320x455)",
@ -2015,14 +2057,16 @@
"no-data": "Não foram criados Filtros Inteligentes",
"filter": "{{common.filter}}",
"delete": "{{common.delete}}",
"clear": "{{common.clear}}"
"clear": "{{common.clear}}",
"errored": "Existe um erro de encoding no filtro. Tem de ser recriado."
},
"next-expected-card": {
"title": "~{{date}}"
},
"all-filters": {
"count": "{{count}} {{customize-dashboard-modal.title-smart-filters}}",
"title": "Todos os Filtros Inteligentes"
"title": "Todos os Filtros Inteligentes",
"create": "{{common.create}}"
},
"file-type-group-pipe": {
"epub": "Epub",
@ -2070,7 +2114,8 @@
"title": "Importar Pilha de Interesses do Mal",
"description": "Importa as suas pilhas de interesse do Mal e cria Coleções no interior do Kavita",
"series-count": "{{common.series-count}}",
"restack-count": "{{num}} Reempilhar"
"restack-count": "{{num}} Reempilhar",
"nothing-found": "Nada encontrado"
},
"pdf-spread-mode-pipe": {
"off": "Sem Propagação",
@ -2084,5 +2129,8 @@
"errored-series-label": "Séries com erros",
"completed-series-label": "Séries Concluídas",
"complete": "Todas as séries possuem metadados"
},
"browse-themes-modal": {
"title": "Explorar Temas"
}
}

View file

@ -1135,7 +1135,9 @@
"source-title": "Fonte:",
"feature-request-title": "Solicitações de recursos:",
"localization-title": "Localizações:",
"updates-title": "Histórico de Atualizações"
"updates-title": "Histórico de Atualizações",
"first-install-version-title": "Primeira Versão de Instalação",
"first-install-date-title": "Data da Primeira Instalação"
},
"manage-tasks-settings": {
"title": "Tarefas Recorrentes",
@ -1328,7 +1330,8 @@
"users-online-count": "{{num}} Usuários online",
"active-events-title": "Eventos Ativos:",
"no-data": "Não há muita coisa acontecendo aqui",
"left-to-process": "Resta para Processar: {{leftToProcess}}"
"left-to-process": "Resta para Processar: {{leftToProcess}}",
"download-in-queue": "{{num}} downloads na Fila"
},
"shortcuts-modal": {
"title": "Atalhos de Teclado",
@ -1460,7 +1463,9 @@
"unbookmark-page-tooltip": "Desmarcar Página",
"bookmark-page-tooltip": "Marcar Página",
"series-progress": "Progresso das Séries: {{percentage}}",
"bookmarks-title": "Marcadores"
"bookmarks-title": "Marcadores",
"off": "Desligado",
"width-override-label": "Substituição de Largura"
},
"metadata-filter": {
"filter-title": "{{common.filter}}",
@ -1594,7 +1599,9 @@
"total-size-header": "Tamanho Total",
"total-files-header": "Total de Arquivos",
"not-classified": "Não Classificado",
"total-file-size-title": "Tamanho Total do Arquivo:"
"total-file-size-title": "Tamanho Total do Arquivo:",
"download-file-for-extension-header": "Baixar Relatório",
"download-file-for-extension-alt": "Baixe o Relatório de arquivos para {{extension}}"
},
"reading-activity": {
"title": "Atividade de Leitura",
@ -1773,7 +1780,11 @@
"pdf-book-mode-screen-size": "Tela muito pequena para o modo Livro",
"stack-imported": "Pilha Importada",
"confirm-delete-theme": "A remoção deste tema irá excluí-lo do disco. Você pode obtê-lo do diretório temporário antes da remoção",
"mal-token-required": "O token MAL é obrigatório, definido nas Configurações do Usuário"
"mal-token-required": "O token MAL é obrigatório, definido nas Configurações do Usuário",
"reading-lists-deleted": "Listas de leitura excluídas",
"confirm-delete-reading-lists": "Tem certeza de que deseja excluir as listas de leitura? Isto não pode ser desfeito.",
"reading-lists-unpromoted": "Listas de leitura não promovidas",
"reading-lists-promoted": "Listas de leitura promovidas"
},
"actionable": {
"scan-library": "Escanear Biblioteca",
@ -1884,7 +1895,10 @@
"volume-num": "Volume",
"clear": "Limpar",
"filter": "Filtro",
"remove": "Remover"
"remove": "Remover",
"chapter-num-shorthand": "Cap. {{num}}",
"issue-num-shorthand": "#{{num}}",
"volume-num-shorthand": "Vol. {{num}}"
},
"infinite-scroller": {
"continuous-reading-prev-chapter-alt": "Role para cima para ir para o capítulo anterior",
@ -2036,7 +2050,8 @@
"no-data": "Nenhum Filtro Inteligente criado",
"filter": "{{common.filter}}",
"delete": "{{common.delete}}",
"clear": "{{common.clear}}"
"clear": "{{common.clear}}",
"errored": "Há um erro de codificação no filtro. Você precisa recriá-lo."
},
"stream-pipe": {
"collections": "{{side-nav.collections}}",
@ -2060,7 +2075,8 @@
},
"all-filters": {
"title": "Todos os Filtros Inteligentes",
"count": "{{count}} {{customize-dashboard-modal.title-smart-filters}}"
"count": "{{count}} {{customize-dashboard-modal.title-smart-filters}}",
"create": "{{common.create}}"
},
"out-of-date-modal": {
"close": "{{common.close}}",

View file

@ -1135,7 +1135,9 @@
"source-title": "源代码:",
"feature-request-title": "功能请求:",
"localization-title": "本地化:",
"updates-title": "更新历史"
"updates-title": "更新历史",
"first-install-version-title": "首次安装版本",
"first-install-date-title": "首次安装日期"
},
"manage-tasks-settings": {
"title": "周期性任务",
@ -1328,7 +1330,8 @@
"users-online-count": "{{num}}用户在线",
"active-events-title": "活动事件:",
"no-data": "无运行",
"left-to-process": "待处理:{{leftToProcess}}"
"left-to-process": "待处理:{{leftToProcess}}",
"download-in-queue": "{{num}} 队列中的下载"
},
"shortcuts-modal": {
"title": "热键",
@ -1460,7 +1463,9 @@
"unbookmark-page-tooltip": "取消书签页面",
"bookmark-page-tooltip": "书签页面",
"series-progress": "系列进度: {{percentage}}",
"bookmarks-title": "书签"
"bookmarks-title": "书签",
"width-override-label": "宽度覆盖",
"off": "关"
},
"metadata-filter": {
"filter-title": "{{common.filter}}",
@ -1594,7 +1599,9 @@
"total-size-header": "总大小",
"total-files-header": "文件总数",
"not-classified": "未分类",
"total-file-size-title": "总文件大小:"
"total-file-size-title": "总文件大小:",
"download-file-for-extension-header": "下载报告",
"download-file-for-extension-alt": "下载 {{extension}} 的文件报告"
},
"reading-activity": {
"title": "阅读活动",
@ -1766,14 +1773,18 @@
"force-kavita+-refresh-success": "Kavita+ 外部元数据已失效",
"confirm-download-size-ios": "iOS 在下载大于 200MB 的文件时出现问题,此下载可能无法完成。",
"collection-not-owned": "您没有该收藏",
"collections-promoted": "收藏推广",
"collections-promoted": "推广收藏",
"collections-unpromoted": "未推广的收藏",
"confirm-delete-collections": "您确定要删除多个收藏吗?",
"collections-deleted": "收藏已删除",
"pdf-book-mode-screen-size": "屏幕对于书籍模式来说太小",
"stack-imported": "Stack 导入",
"confirm-delete-theme": "删除该主题将从磁盘中删除它。您可以在删除之前从临时目录中获取它",
"mal-token-required": "需要 MAL 令牌,在用户设置中设置"
"mal-token-required": "需要 MAL 令牌,在用户设置中设置",
"reading-lists-deleted": "阅读列表已删除",
"reading-lists-unpromoted": "未推广的阅读清单",
"confirm-delete-reading-lists": "您确定要删除阅读列表吗?此操作无法撤消。",
"reading-lists-promoted": "推广阅读清单"
},
"actionable": {
"scan-library": "扫描资料库",
@ -1884,7 +1895,10 @@
"volume-num": "卷",
"clear": "清空",
"filter": "筛选",
"remove": "删除"
"remove": "删除",
"issue-num-shorthand": "#{{num}}",
"chapter-num-shorthand": "章节 {{num}}",
"volume-num-shorthand": "卷 {{num}}"
},
"infinite-scroller": {
"continuous-reading-prev-chapter-alt": "向上滚动以返回上一章节",
@ -2047,11 +2061,13 @@
"no-data": "未创建智能筛选",
"filter": "{{common.filter}}",
"delete": "{{common.delete}}",
"clear": "{{common.clear}}"
"clear": "{{common.clear}}",
"errored": "筛选器中存在编码错误。您需要重新创建它。"
},
"all-filters": {
"title": "所有智能筛选器",
"count": "{{count}} {{customize-dashboard-modal.title-smart-filters}}"
"count": "{{count}} {{customize-dashboard-modal.title-smart-filters}}",
"create": "{{common.create}}"
},
"next-expected-card": {
"title": "~{{date}}"

View file

@ -13,7 +13,8 @@
"not-granted": "您沒有任何資料庫的訪問權限。",
"on-deck-title": "準備就緒",
"recently-updated-title": "最近更新系列",
"recently-added-title": "新增系列"
"recently-added-title": "新增系列",
"more-in-genre-title": "{{genre}} 更多的內容分類"
},
"edit-user": {
"edit": "{{common.edit}}",
@ -23,13 +24,13 @@
"email": "{{common.email}}",
"not-valid-email": "{{validation.valid-email}}",
"cancel": "{{common.cancel}}",
"saving": "儲存",
"saving": "儲存",
"update": "更新"
},
"user-scrobble-history": {
"title": "Scrobble歷史",
"description": "在這裡您可以找到與您帳號關聯的所有Scrobble事件。為了使事件存在您必須設定scrobble事件提供程式所有已處理的事件將在一個月後清除。如果存在未處理的事件這些事件很可能無法在上游形成匹配。請聯繫您的管理員進行修正。",
"filter-label": "篩選",
"filter-label": "{{common.filter}}",
"created-header": "已創建",
"last-modified-header": "上一次修改",
"type-header": "類型",
@ -41,7 +42,11 @@
"rating": "評分{{r}}",
"not-applicable": "不適用",
"processed": "處理",
"not-processed": "未處理"
"not-processed": "未處理",
"volume-num": "{{num}} 卷",
"chapter-num": "章節 {{num}}",
"not-read-warning": "遠端將始終保持最高數量",
"special": "{{entity-title.special}}"
},
"scrobble-event-type-pipe": {
"chapter-read": "閱讀進度",
@ -57,7 +62,10 @@
"title": "編輯評論",
"review-label": "評論",
"close": "{{common.close}}",
"save": "{{common.save}}"
"save": "{{common.save}}",
"delete": "{{common.delete}}",
"min-length": "評論至少需有 {{count}} 個字元",
"required": "{{validation.required-field}}"
},
"review-card-modal": {
"close": "{{common.close}}",
@ -68,7 +76,7 @@
"review-card": {
"your-review": "這是您的評論",
"external-review": "外部評論",
"local-review": "評論",
"local-review": "本地評論",
"rating-percentage": "評分{{r}}%"
},
"want-to-read": {
@ -143,10 +151,17 @@
"clients-api-key-tooltip": "API 密鑰就像一個密碼。 保守秘密,保證安全。",
"clients-opds-url-tooltip": "OPDS連結",
"reset": "{{common.reset}}",
"save": "{{common.save}}"
"save": "{{common.save}}",
"smart-filters-tab": "智慧篩選",
"pdf-scroll-mode-label": "全螢幕模式",
"pdf-scroll-mode-tooltip": "當您瀏覽頁面時,可以選擇垂直/水平滾動,或者點擊分頁(無需滾動)",
"pdf-spread-mode-label": "傳播模式",
"pdf-spread-mode-tooltip": "頁面應該如何排列。單頁或雙頁(奇數/偶數)",
"pdf-theme-label": "主題",
"pdf-reader-settings-title": "PDF 閱讀器"
},
"user-holds": {
"title": "Scrobble Holds",
"title": "記錄保留",
"description": "這是用戶管理的系列列表,不會被記錄到上游提供商。 您可以隨時刪除系列,下一個可亂碼事件(閱讀進度、評分、想要閱讀狀態)將觸發事件。"
},
"theme-manager": {
@ -156,7 +171,18 @@
"apply": "{{common.apply}}",
"applied": "應用",
"updated-toastr": "網站預設值已更新為{{name}}",
"scan-queued": "掃描網站主題已進入列隊"
"scan-queued": "掃描網站主題已進入列隊",
"default-theme": "預設主題",
"description": "Kavita 支援變更自訂顏色,找到一個符合您需求的配色方案,或者自己創建一個並分享。主題可以應用於您的帳戶,或應用於所有帳戶中。",
"active-theme": "活動",
"upload-continued": "一個 CSS 檔案",
"preview-default": "首先選擇一個主題",
"download": "{{changelog.download}}",
"delete": "{{common.delete}}",
"drag-n-drop": "{{cover-image-chooser.drag-n-drop}}",
"upload": "{{cover-image-chooser.upload}}",
"preview-default-admin": "首先選擇一個主題,或手動上傳一個",
"preview-title": "預覽"
},
"theme": {
"theme-dark": "黑暗",
@ -174,7 +200,8 @@
"include-unknowns-tooltip": "如果屬實,則允許未知人員參加,但有年齡限制。 這可能會導致未標記的媒體洩露給有年齡限制的用戶。"
},
"site-theme-provider-pipe": {
"system": "系統"
"system": "系統",
"custom": "{{device-platform-pipe.custom}}"
},
"manage-devices": {
"title": "裝置管理員",
@ -185,7 +212,8 @@
"email-label": "電子信箱: ",
"add": "{{common.add}}",
"delete": "{{common.delete}}",
"edit": "{{common.edit}}"
"edit": "{{common.edit}}",
"email-setup-alert": "想要將文件傳送至您的設備嗎? 請先進入管理員設置電子郵件設定!"
},
"edit-device": {
"device-name-label": "裝置名稱",
@ -210,7 +238,7 @@
"permission-error": "您無權更改您的密碼。聯繫服務器管理員。"
},
"change-email": {
"email-label": "{{common.email}}",
"email-label": "新電子郵件",
"current-password-label": "當前密碼",
"email-not-confirmed": "此電子信箱未得到確認",
"email-updated-title": "電子信箱已更新",
@ -223,7 +251,11 @@
"reset": "{{common.reset}}",
"edit": "{{common.edit}}",
"cancel": "{{common.cancel}}",
"save": "{{common.save}}"
"save": "{{common.save}}",
"email-title": "電子郵件",
"has-invalid-email": "看起來您沒有設置有效的電子郵件。更改電子郵件將需要管理員向您發送一個連結,以完成此操作。",
"valid-email": "{{validation.valid-email}}",
"email-confirmed": "已確認電子郵件"
},
"change-age-restriction": {
"age-restriction-label": "年齡限制",
@ -239,7 +271,8 @@
"regen-warning": "重新生成 API 密鑰將使現有使用端失效。",
"no-key": "錯誤 - 未設置密鑰",
"confirm-reset": "這將使您設置的任何 OPDS 配置無效。 你確定要繼續嗎?",
"key-reset": "API密鑰重置"
"key-reset": "API密鑰重置",
"hide": "隱藏"
},
"scrobbling-providers": {
"title": "Scrobbling供應商",
@ -252,13 +285,20 @@
"token-input-label": "Token{{service}}",
"edit": "{{common.edit}}",
"cancel": "{{common.cancel}}",
"save": "{{common.save}}"
"save": "{{common.save}}",
"token-valid": "Token 有效",
"generic-instructions": "填寫有關不同外部服務的信息,以便讓 Kavita+ 能夠與它們互動。",
"mal-instructions": "Kavita 使用 MAL 客戶端 ID 進行身份驗證。為 Kavita 創建一個新的客戶端,一旦批准,提供客戶端 ID 和您的使用者名稱。",
"scrobbling-applicable-label": "可以進行 Scrobbling",
"mal-token-input-label": "MAL 客戶端 ID",
"mal-username-input-label": "MAL 使用者名稱",
"loading": "{{common.loading}}"
},
"typeahead": {
"locked-field": "已鎖定",
"close": "{{common.close}}",
"loading": "{{common.loading}}",
"add-item": "新增{{item}}",
"add-item": "新增 {{item}}",
"no-data": "沒有資料",
"add-custom-item": ",輸入以添加自定義項目"
},
@ -391,7 +431,8 @@
"side-story": "番外",
"spin-off": "拆分",
"parent": "家長",
"edition": "編輯"
"edition": "編輯",
"annual": "年度"
},
"publication-status-pipe": {
"ongoing": "進行中",
@ -485,10 +526,20 @@
"remove-from-want-to-read": "{{actionable.remove-from-want-to-read}}"
},
"side-nav": {
"clear": "{{common.clear}}"
"clear": "{{common.clear}}",
"reading-lists": "閱讀清單"
},
"library-settings-modal": {
"save": "{{common.save}}"
"save": "{{common.save}}",
"naming-conventions-part-1": "Kavita 具有 ",
"help-us-part-3": "來命名和組織您的媒體。",
"cover-description": "可自訂 library 縮圖",
"manage-collection-label": "管理收藏",
"naming-conventions-part-2": "需要資料夾。",
"naming-conventions-part-3": "請確認這個連結,否則文件可能無法在掃描中顯示。",
"manage-collection-tooltip": "Kavita是否應該從 ComicInfo.xml/opf 文件中找到的 SeriesGroup 標籤並創建收藏",
"manage-reading-list-label": "管理閱讀清單",
"cover-description-extra": "圖示不應過大。建議使用小文件,大小為 32x32 像素。Kavita 不對大小進行驗證。"
},
"reader-settings": {
"layout-mode-label": "{{user-preferences.layout-mode-book-label}}"
@ -515,5 +566,60 @@
},
"series-preview-drawer": {
"remove-from-want-to-read": "{{actionable.remove-from-want-to-read}}"
},
"reading-activity": {
"all-users": "所有使用者",
"last-30-days": "{{time-periods.last-30-days}}",
"this-week": "{{time-periods.this-week}}",
"last-7-days": "{{time-periods.last-7-days}}",
"no-data": "沒有閱讀進度",
"last-year": "{{time-periods.last-year}}",
"title": "閱讀記錄",
"legend-label": "格式",
"x-axis-label": "時間",
"y-axis-label": "閱讀時數",
"time-frame-label": "時間範圍",
"last-90-days": "{{time-periods.last-90-days}}",
"all-time": "{{time-periods.all-time}}"
},
"customize-dashboard-modal": {
"smart-filters": "智慧篩選",
"title-smart-filters": "智慧篩選"
},
"manga-format-stats": {
"title": "格式",
"data-table-label": "數據表",
"format-header": "格式"
},
"errors": {
"user-not-auth": "使用者未經認證"
},
"filter-field-pipe": {
"want-to-read": "待讀清單"
},
"file-breakdown-stats": {
"total-file-size-title": "總文件大小:",
"not-classified": "未分類"
},
"grouped-typeahead": {
"reading-lists": "閱讀清單"
},
"device-platform-pipe": {
"custom": "自訂"
},
"publication-status-stats": {
"title": "出版狀態"
},
"customize-sidenav-streams": {
"smart-filters-title": "智慧篩選"
},
"common": {
"volume-num": "卷"
},
"nav-header": {
"all-filters": "智慧篩選"
},
"reading-lists": {
"title": "閱讀清單"
}
}

View file

@ -242,6 +242,10 @@
--event-widget-item-border-color: rgba(53, 53, 53, 0.5);
--event-widget-border-color: rgba(1, 4, 9, 0.5);
--event-widget-info-bg-color: #b6d4fe;
--event-widget-error-bg-color: var(--error-color);
--event-widget-update-bg-color: var(--primary-color);
--event-widget-activity-bg-color: var(--primary-color);
/* Search */
--search-result-text-lite-color: initial;

File diff suppressed because it is too large Load diff