Stats Fix & Library Bulk Actions (#3209)

Co-authored-by: Fesaa <77553571+Fesaa@users.noreply.github.com>
Co-authored-by: Weblate (bot) <hosted@weblate.org>
Co-authored-by: Gregory.Open <gregory.open@proton.me>
Co-authored-by: Mateusz <mateuszvx8.96@gmail.com>
Co-authored-by: majora2007 <kavitareader@gmail.com>
Co-authored-by: 無情天 <kofzhanganguo@126.com>
This commit is contained in:
Joe Milazzo 2024-09-23 08:07:37 -05:00 committed by GitHub
parent 894b49bb76
commit 857e419e4e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
77 changed files with 72523 additions and 30914 deletions

View File

@ -101,7 +101,7 @@ jobs:
dotnet-version: 8.0.x dotnet-version: 8.0.x
- name: Install Swashbuckle CLI - name: Install Swashbuckle CLI
run: dotnet tool install -g --version 6.5.0 Swashbuckle.AspNetCore.Cli run: dotnet tool install -g Swashbuckle.AspNetCore.Cli
- run: ./monorepo-build.sh - run: ./monorepo-build.sh

View File

@ -131,7 +131,7 @@ jobs:
dotnet-version: 8.0.x dotnet-version: 8.0.x
- name: Install Swashbuckle CLI - name: Install Swashbuckle CLI
run: dotnet tool install -g --version 6.5.0 Swashbuckle.AspNetCore.Cli run: dotnet tool install -g Swashbuckle.AspNetCore.Cli
- run: ./monorepo-build.sh - run: ./monorepo-build.sh

View File

@ -108,7 +108,7 @@ jobs:
with: with:
dotnet-version: 8.0.x dotnet-version: 8.0.x
- name: Install Swashbuckle CLI - name: Install Swashbuckle CLI
run: dotnet tool install -g --version 6.5.0 Swashbuckle.AspNetCore.Cli run: dotnet tool install -g Swashbuckle.AspNetCore.Cli
- run: ./monorepo-build.sh - run: ./monorepo-build.sh

View File

@ -7,11 +7,11 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.8" /> <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.8" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="NSubstitute" Version="5.1.0" /> <PackageReference Include="NSubstitute" Version="5.1.0" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="21.0.29" /> <PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="21.0.29" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="21.0.29" /> <PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="21.0.29" />
<PackageReference Include="xunit" Version="2.9.0" /> <PackageReference Include="xunit" Version="2.9.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"> <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>

View File

@ -6,6 +6,7 @@ using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Entities; using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
using API.Extensions;
using API.Helpers; using API.Helpers;
using API.Helpers.Builders; using API.Helpers.Builders;
using API.Services; using API.Services;
@ -26,7 +27,7 @@ public class WordCountAnalysisTests : AbstractDbTest
private readonly string _testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/BookService"); private readonly string _testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/BookService");
private const long WordCount = 33608; // 37417 if splitting on space, 33608 if just character count private const long WordCount = 33608; // 37417 if splitting on space, 33608 if just character count
private const long MinHoursToRead = 1; private const long MinHoursToRead = 1;
private const long AvgHoursToRead = 2; private const float AvgHoursToRead = 1.66954792f;
private const long MaxHoursToRead = 3; private const long MaxHoursToRead = 3;
public WordCountAnalysisTests() : base() public WordCountAnalysisTests() : base()
{ {
@ -81,7 +82,7 @@ public class WordCountAnalysisTests : AbstractDbTest
Assert.Equal(WordCount, series.WordCount); Assert.Equal(WordCount, series.WordCount);
Assert.Equal(MinHoursToRead, series.MinHoursToRead); Assert.Equal(MinHoursToRead, series.MinHoursToRead);
Assert.Equal(AvgHoursToRead, series.AvgHoursToRead); Assert.True(series.AvgHoursToRead.Is(AvgHoursToRead));
Assert.Equal(MaxHoursToRead, series.MaxHoursToRead); Assert.Equal(MaxHoursToRead, series.MaxHoursToRead);
// Validate the Chapter gets updated correctly // Validate the Chapter gets updated correctly
@ -148,13 +149,11 @@ public class WordCountAnalysisTests : AbstractDbTest
Assert.Equal(WordCount * 2L, series.WordCount); Assert.Equal(WordCount * 2L, series.WordCount);
Assert.Equal(MinHoursToRead * 2, series.MinHoursToRead); Assert.Equal(MinHoursToRead * 2, series.MinHoursToRead);
//Assert.Equal(AvgHoursToRead * 2, series.AvgHoursToRead);
//Assert.Equal((MaxHoursToRead * 2) - 1, series.MaxHoursToRead); // This is just a rounding issue
var firstVolume = series.Volumes.ElementAt(0); var firstVolume = series.Volumes.ElementAt(0);
Assert.Equal(WordCount, firstVolume.WordCount); Assert.Equal(WordCount, firstVolume.WordCount);
Assert.Equal(MinHoursToRead, firstVolume.MinHoursToRead); Assert.Equal(MinHoursToRead, firstVolume.MinHoursToRead);
Assert.Equal(AvgHoursToRead, firstVolume.AvgHoursToRead); Assert.True(series.AvgHoursToRead.Is(AvgHoursToRead * 2));
Assert.Equal(MaxHoursToRead, firstVolume.MaxHoursToRead); Assert.Equal(MaxHoursToRead, firstVolume.MaxHoursToRead);
var secondVolume = series.Volumes.ElementAt(1); var secondVolume = series.Volumes.ElementAt(1);

View File

@ -12,10 +12,10 @@
<LangVersion>latestmajor</LangVersion> <LangVersion>latestmajor</LangVersion>
</PropertyGroup> </PropertyGroup>
<Target Name="PostBuild" AfterTargets="Build" Condition=" '$(Configuration)' == 'Debug' "> <!-- <Target Name="PostBuild" AfterTargets="Build" Condition=" '$(Configuration)' == 'Debug' ">-->
<Delete Files="../openapi.json" /> <!-- <Delete Files="../openapi.json" />-->
<Exec Command="swagger tofile --output ../openapi.json bin/$(Configuration)/$(TargetFramework)/$(AssemblyName).dll v1" /> <!-- <Exec Command="swagger tofile &#45;&#45;output ../openapi.json bin/$(Configuration)/$(TargetFramework)/$(AssemblyName).dll v1" />-->
</Target> <!-- </Target>-->
<PropertyGroup Condition=" '$(Configuration)' == 'Release' "> <PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<DebugSymbols>false</DebugSymbols> <DebugSymbols>false</DebugSymbols>
@ -67,10 +67,10 @@
<PackageReference Include="Flurl" Version="3.0.7" /> <PackageReference Include="Flurl" Version="3.0.7" />
<PackageReference Include="Flurl.Http" Version="3.2.4" /> <PackageReference Include="Flurl.Http" Version="3.2.4" />
<PackageReference Include="Hangfire" Version="1.8.14" /> <PackageReference Include="Hangfire" Version="1.8.14" />
<PackageReference Include="Hangfire.InMemory" Version="0.10.3" /> <PackageReference Include="Hangfire.InMemory" Version="1.0.0" />
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" /> <PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
<PackageReference Include="Hangfire.Storage.SQLite" Version="0.4.2" /> <PackageReference Include="Hangfire.Storage.SQLite" Version="0.4.2" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.64" /> <PackageReference Include="HtmlAgilityPack" Version="1.11.66" />
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" /> <PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.14" /> <PackageReference Include="Hangfire.AspNetCore" Version="1.8.14" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" /> <PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" />
@ -94,7 +94,7 @@
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" /> <PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" 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="Serilog.Sinks.SignalR.Core" Version="0.1.2" />
<PackageReference Include="SharpCompress" Version="0.37.2" /> <PackageReference Include="SharpCompress" Version="0.38.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.5" /> <PackageReference Include="SixLabors.ImageSharp" Version="3.1.5" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.32.0.97167"> <PackageReference Include="SonarAnalyzer.CSharp" Version="9.32.0.97167">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>

View File

@ -78,7 +78,6 @@ public class LibraryController : BaseApiController
.WithFolders(dto.Folders.Select(x => new FolderPath {Path = x}).Distinct().ToList()) .WithFolders(dto.Folders.Select(x => new FolderPath {Path = x}).Distinct().ToList())
.WithFolderWatching(dto.FolderWatching) .WithFolderWatching(dto.FolderWatching)
.WithIncludeInDashboard(dto.IncludeInDashboard) .WithIncludeInDashboard(dto.IncludeInDashboard)
.WithIncludeInRecommended(dto.IncludeInRecommended)
.WithManageCollections(dto.ManageCollections) .WithManageCollections(dto.ManageCollections)
.WithManageReadingLists(dto.ManageReadingLists) .WithManageReadingLists(dto.ManageReadingLists)
.WIthAllowScrobbling(dto.AllowScrobbling) .WIthAllowScrobbling(dto.AllowScrobbling)
@ -302,6 +301,22 @@ public class LibraryController : BaseApiController
return Ok(); return Ok();
} }
/// <summary>
/// Enqueues a bunch of library scans
/// </summary>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("scan-multiple")]
public async Task<ActionResult> ScanMultiple(BulkActionDto dto)
{
foreach (var libraryId in dto.Ids)
{
await _taskScheduler.ScanLibrary(libraryId, dto.Force ?? false);
}
return Ok();
}
/// <summary> /// <summary>
/// Scans a given library for file changes. If another scan task is in progress, will reschedule the invocation for 3 hours in future. /// Scans a given library for file changes. If another scan task is in progress, will reschedule the invocation for 3 hours in future.
/// </summary> /// </summary>
@ -323,6 +338,18 @@ public class LibraryController : BaseApiController
return Ok(); return Ok();
} }
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("refresh-metadata-multiple")]
public ActionResult RefreshMetadataMultiple(BulkActionDto dto, bool forceColorscape = true)
{
foreach (var libraryId in dto.Ids)
{
_taskScheduler.RefreshMetadata(libraryId, dto.Force ?? false, forceColorscape);
}
return Ok();
}
[Authorize(Policy = "RequireAdminRole")] [Authorize(Policy = "RequireAdminRole")]
[HttpPost("analyze")] [HttpPost("analyze")]
public ActionResult Analyze(int libraryId) public ActionResult Analyze(int libraryId)
@ -331,6 +358,61 @@ public class LibraryController : BaseApiController
return Ok(); return Ok();
} }
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("analyze-multiple")]
public ActionResult AnalyzeMultiple(BulkActionDto dto)
{
foreach (var libraryId in dto.Ids)
{
_taskScheduler.AnalyzeFilesForLibrary(libraryId, dto.Force ?? false);
}
return Ok();
}
/// <summary>
/// Copy the library settings (adv tab + optional type) to a set of other libraries.
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("copy-settings-from")]
public async Task<ActionResult> CopySettingsFromLibraryToLibraries(CopySettingsFromLibraryDto dto)
{
var sourceLibrary = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(dto.SourceLibraryId, LibraryIncludes.ExcludePatterns | LibraryIncludes.FileTypes);
if (sourceLibrary == null) return BadRequest("SourceLibraryId must exist");
var libraries = await _unitOfWork.LibraryRepository.GetLibraryForIdsAsync(dto.TargetLibraryIds, LibraryIncludes.ExcludePatterns | LibraryIncludes.FileTypes | LibraryIncludes.Folders);
foreach (var targetLibrary in libraries)
{
UpdateLibrarySettings(new UpdateLibraryDto()
{
Folders = targetLibrary.Folders.Select(s => s.Path),
Name = targetLibrary.Name,
Id = targetLibrary.Id,
Type = sourceLibrary.Type,
AllowScrobbling = sourceLibrary.AllowScrobbling,
ExcludePatterns = sourceLibrary.LibraryExcludePatterns.Select(p => p.Pattern).ToList(),
FolderWatching = sourceLibrary.FolderWatching,
ManageCollections = sourceLibrary.ManageCollections,
FileGroupTypes = sourceLibrary.LibraryFileTypes.Select(t => t.FileTypeGroup).ToList(),
IncludeInDashboard = sourceLibrary.IncludeInDashboard,
IncludeInSearch = sourceLibrary.IncludeInSearch,
ManageReadingLists = sourceLibrary.ManageReadingLists
}, targetLibrary, dto.IncludeType);
}
await _unitOfWork.CommitAsync();
if (sourceLibrary.FolderWatching)
{
BackgroundJob.Enqueue(() => _libraryWatcher.RestartWatching());
}
return Ok();
}
/// <summary> /// <summary>
/// Given a valid path, will invoke either a Scan Series or Scan Library. If the folder does not exist within Kavita, the request will be ignored /// Given a valid path, will invoke either a Scan Series or Scan Library. If the folder does not exist within Kavita, the request will be ignored
/// </summary> /// </summary>
@ -474,33 +556,7 @@ public class LibraryController : BaseApiController
var typeUpdate = library.Type != dto.Type; var typeUpdate = library.Type != dto.Type;
var folderWatchingUpdate = library.FolderWatching != dto.FolderWatching; var folderWatchingUpdate = library.FolderWatching != dto.FolderWatching;
library.Type = dto.Type; UpdateLibrarySettings(dto, library);
library.FolderWatching = dto.FolderWatching;
library.IncludeInDashboard = dto.IncludeInDashboard;
library.IncludeInRecommended = dto.IncludeInRecommended;
library.IncludeInSearch = dto.IncludeInSearch;
library.ManageCollections = dto.ManageCollections;
library.ManageReadingLists = dto.ManageReadingLists;
library.AllowScrobbling = dto.AllowScrobbling;
library.LibraryFileTypes = dto.FileGroupTypes
.Select(t => new LibraryFileTypeGroup() {FileTypeGroup = t, LibraryId = library.Id})
.Distinct()
.ToList();
library.LibraryExcludePatterns = dto.ExcludePatterns
.Distinct()
.Select(t => new LibraryExcludePattern() {Pattern = t, LibraryId = library.Id})
.ToList();
// Override Scrobbling for Comic libraries since there are no providers to scrobble to
if (library.Type == LibraryType.Comic)
{
_logger.LogInformation("Overrode Library {Name} to disable scrobbling since there are no providers for Comics", dto.Name.Replace(Environment.NewLine, string.Empty));
library.AllowScrobbling = false;
}
_unitOfWork.LibraryRepository.Update(library);
if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(userId, "generic-library-update")); if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(userId, "generic-library-update"));
@ -526,6 +582,39 @@ public class LibraryController : BaseApiController
} }
private void UpdateLibrarySettings(UpdateLibraryDto dto, Library library, bool updateType = true)
{
if (updateType)
{
library.Type = dto.Type;
}
library.FolderWatching = dto.FolderWatching;
library.IncludeInDashboard = dto.IncludeInDashboard;
library.IncludeInSearch = dto.IncludeInSearch;
library.ManageCollections = dto.ManageCollections;
library.ManageReadingLists = dto.ManageReadingLists;
library.AllowScrobbling = dto.AllowScrobbling;
library.LibraryFileTypes = dto.FileGroupTypes
.Select(t => new LibraryFileTypeGroup() {FileTypeGroup = t, LibraryId = library.Id})
.Distinct()
.ToList();
library.LibraryExcludePatterns = dto.ExcludePatterns
.Distinct()
.Select(t => new LibraryExcludePattern() {Pattern = t, LibraryId = library.Id})
.ToList();
// Override Scrobbling for Comic libraries since there are no providers to scrobble to
if (library.Type is LibraryType.Comic or LibraryType.ComicVine)
{
_logger.LogInformation("Overrode Library {Name} to disable scrobbling since there are no providers for Comics", dto.Name.Replace(Environment.NewLine, string.Empty));
library.AllowScrobbling = false;
}
_unitOfWork.LibraryRepository.Update(library);
}
/// <summary> /// <summary>
/// Returns the type of the underlying library /// Returns the type of the underlying library
/// </summary> /// </summary>

12
API/DTOs/BulkActionDto.cs Normal file
View File

@ -0,0 +1,12 @@
using System.Collections.Generic;
namespace API.DTOs;
public class BulkActionDto
{
public List<int> Ids { get; set; }
/**
* If this is a Scan action, will ignore optimizations
*/
public bool? Force { get; set; }
}

View File

@ -111,7 +111,7 @@ public class ChapterDto : IHasReadTimeEstimate, IHasCoverImage
/// <inheritdoc cref="IHasReadTimeEstimate.MaxHoursToRead"/> /// <inheritdoc cref="IHasReadTimeEstimate.MaxHoursToRead"/>
public int MaxHoursToRead { get; set; } public int MaxHoursToRead { get; set; }
/// <inheritdoc cref="IHasReadTimeEstimate.AvgHoursToRead"/> /// <inheritdoc cref="IHasReadTimeEstimate.AvgHoursToRead"/>
public int AvgHoursToRead { get; set; } public float AvgHoursToRead { get; set; }
/// <summary> /// <summary>
/// Comma-separated link of urls to external services that have some relation to the Chapter /// Comma-separated link of urls to external services that have some relation to the Chapter
/// </summary> /// </summary>

View File

@ -0,0 +1,14 @@
using System.Collections.Generic;
namespace API.DTOs;
public class CopySettingsFromLibraryDto
{
public int SourceLibraryId { get; set; }
public List<int> TargetLibraryIds { get; set; }
/// <summary>
/// Include copying over the type
/// </summary>
public bool IncludeType { get; set; }
}

View File

@ -16,5 +16,5 @@ public record HourEstimateRangeDto
/// <summary> /// <summary>
/// Estimated average hours to read the selection /// Estimated average hours to read the selection
/// </summary> /// </summary>
public int AvgHours { get; init; } = 1; public float AvgHours { get; init; } = 1f;
} }

View File

@ -53,7 +53,7 @@ public class SeriesDto : IHasReadTimeEstimate, IHasCoverImage
/// <inheritdoc cref="IHasReadTimeEstimate.MaxHoursToRead"/> /// <inheritdoc cref="IHasReadTimeEstimate.MaxHoursToRead"/>
public int MaxHoursToRead { get; set; } public int MaxHoursToRead { get; set; }
/// <inheritdoc cref="IHasReadTimeEstimate.AvgHoursToRead"/> /// <inheritdoc cref="IHasReadTimeEstimate.AvgHoursToRead"/>
public int AvgHoursToRead { get; set; } public float AvgHoursToRead { get; set; }
/// <summary> /// <summary>
/// The highest level folder for this Series /// The highest level folder for this Series
/// </summary> /// </summary>

View File

@ -8,11 +8,11 @@ public class TopReadDto
/// <summary> /// <summary>
/// Amount of time read on Comic libraries /// Amount of time read on Comic libraries
/// </summary> /// </summary>
public long ComicsTime { get; set; } public float ComicsTime { get; set; }
/// <summary> /// <summary>
/// Amount of time read on /// Amount of time read on
/// </summary> /// </summary>
public long BooksTime { get; set; } public float BooksTime { get; set; }
public long MangaTime { get; set; } public float MangaTime { get; set; }
} }

View File

@ -19,8 +19,6 @@ public class UpdateLibraryDto
[Required] [Required]
public bool IncludeInDashboard { get; init; } public bool IncludeInDashboard { get; init; }
[Required] [Required]
public bool IncludeInRecommended { get; init; }
[Required]
public bool IncludeInSearch { get; init; } public bool IncludeInSearch { get; init; }
[Required] [Required]
public bool ManageCollections { get; init; } public bool ManageCollections { get; init; }

View File

@ -43,7 +43,7 @@ public class VolumeDto : IHasReadTimeEstimate, IHasCoverImage
/// <inheritdoc cref="IHasReadTimeEstimate.MaxHoursToRead"/> /// <inheritdoc cref="IHasReadTimeEstimate.MaxHoursToRead"/>
public int MaxHoursToRead { get; set; } public int MaxHoursToRead { get; set; }
/// <inheritdoc cref="IHasReadTimeEstimate.AvgHoursToRead"/> /// <inheritdoc cref="IHasReadTimeEstimate.AvgHoursToRead"/>
public int AvgHoursToRead { get; set; } public float AvgHoursToRead { get; set; }
public long WordCount { get; set; } public long WordCount { get; set; }
/// <summary> /// <summary>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,66 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class AvgReadingTimeFloat : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<float>(
name: "AvgHoursToRead",
table: "Volume",
type: "REAL",
nullable: false,
oldClrType: typeof(int),
oldType: "INTEGER");
migrationBuilder.AlterColumn<float>(
name: "AvgHoursToRead",
table: "Series",
type: "REAL",
nullable: false,
oldClrType: typeof(int),
oldType: "INTEGER");
migrationBuilder.AlterColumn<float>(
name: "AvgHoursToRead",
table: "Chapter",
type: "REAL",
nullable: false,
oldClrType: typeof(int),
oldType: "INTEGER");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
name: "AvgHoursToRead",
table: "Volume",
type: "INTEGER",
nullable: false,
oldClrType: typeof(float),
oldType: "REAL");
migrationBuilder.AlterColumn<int>(
name: "AvgHoursToRead",
table: "Series",
type: "INTEGER",
nullable: false,
oldClrType: typeof(float),
oldType: "REAL");
migrationBuilder.AlterColumn<int>(
name: "AvgHoursToRead",
table: "Chapter",
type: "INTEGER",
nullable: false,
oldClrType: typeof(float),
oldType: "REAL");
}
}
}

View File

@ -15,7 +15,7 @@ namespace API.Data.Migrations
protected override void BuildModel(ModelBuilder modelBuilder) protected override void BuildModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.7"); modelBuilder.HasAnnotation("ProductVersion", "8.0.8");
modelBuilder.Entity("API.Entities.AppRole", b => modelBuilder.Entity("API.Entities.AppRole", b =>
{ {
@ -731,8 +731,8 @@ namespace API.Data.Migrations
b.Property<string>("AlternateSeries") b.Property<string>("AlternateSeries")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<int>("AvgHoursToRead") b.Property<float>("AvgHoursToRead")
.HasColumnType("INTEGER"); .HasColumnType("REAL");
b.Property<bool>("CharacterLocked") b.Property<bool>("CharacterLocked")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
@ -1809,8 +1809,8 @@ namespace API.Data.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<int>("AvgHoursToRead") b.Property<float>("AvgHoursToRead")
.HasColumnType("INTEGER"); .HasColumnType("REAL");
b.Property<string>("CoverImage") b.Property<string>("CoverImage")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@ -2040,8 +2040,8 @@ namespace API.Data.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<int>("AvgHoursToRead") b.Property<float>("AvgHoursToRead")
.HasColumnType("INTEGER"); .HasColumnType("REAL");
b.Property<string>("CoverImage") b.Property<string>("CoverImage")
.HasColumnType("TEXT"); .HasColumnType("TEXT");

View File

@ -117,7 +117,7 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate, IHasCoverImage
/// <inheritdoc cref="IHasReadTimeEstimate"/> /// <inheritdoc cref="IHasReadTimeEstimate"/>
public int MaxHoursToRead { get; set; } public int MaxHoursToRead { get; set; }
/// <inheritdoc cref="IHasReadTimeEstimate"/> /// <inheritdoc cref="IHasReadTimeEstimate"/>
public int AvgHoursToRead { get; set; } public float AvgHoursToRead { get; set; }
/// <summary> /// <summary>
/// Comma-separated link of urls to external services that have some relation to the Chapter /// Comma-separated link of urls to external services that have some relation to the Chapter
/// </summary> /// </summary>

View File

@ -21,5 +21,5 @@ public interface IHasReadTimeEstimate
/// Average hours to read the chapter /// Average hours to read the chapter
/// </summary> /// </summary>
/// <remarks>Uses a fixed number to calculate from <see cref="ReaderService"/></remarks> /// <remarks>Uses a fixed number to calculate from <see cref="ReaderService"/></remarks>
public int AvgHoursToRead { get; set; } public float AvgHoursToRead { get; set; }
} }

View File

@ -38,7 +38,7 @@ public class Series : IEntityDate, IHasReadTimeEstimate, IHasCoverImage
/// </summary> /// </summary>
public DateTime Created { get; set; } public DateTime Created { get; set; }
/// <summary> /// <summary>
/// Whenever a modification occurs. Ie) New volumes, removed volumes, title update, etc /// Whenever a modification occurs. ex: New volumes, removed volumes, title update, etc
/// </summary> /// </summary>
public DateTime LastModified { get; set; } public DateTime LastModified { get; set; }
@ -101,7 +101,7 @@ public class Series : IEntityDate, IHasReadTimeEstimate, IHasCoverImage
public int MinHoursToRead { get; set; } public int MinHoursToRead { get; set; }
public int MaxHoursToRead { get; set; } public int MaxHoursToRead { get; set; }
public int AvgHoursToRead { get; set; } public float AvgHoursToRead { get; set; }
public SeriesMetadata Metadata { get; set; } = null!; public SeriesMetadata Metadata { get; set; } = null!;
public ExternalSeriesMetadata ExternalSeriesMetadata { get; set; } = null!; public ExternalSeriesMetadata ExternalSeriesMetadata { get; set; } = null!;

View File

@ -53,7 +53,7 @@ public class Volume : IEntityDate, IHasReadTimeEstimate, IHasCoverImage
public long WordCount { get; set; } public long WordCount { get; set; }
public int MinHoursToRead { get; set; } public int MinHoursToRead { get; set; }
public int MaxHoursToRead { get; set; } public int MaxHoursToRead { get; set; }
public int AvgHoursToRead { get; set; } public float AvgHoursToRead { get; set; }
// Relationships // Relationships

View File

@ -698,21 +698,23 @@ public class ReaderService : IReaderService
{ {
var minHours = Math.Max((int) Math.Round((wordCount / MinWordsPerHour)), 0); var minHours = Math.Max((int) Math.Round((wordCount / MinWordsPerHour)), 0);
var maxHours = Math.Max((int) Math.Round((wordCount / MaxWordsPerHour)), 0); var maxHours = Math.Max((int) Math.Round((wordCount / MaxWordsPerHour)), 0);
return new HourEstimateRangeDto return new HourEstimateRangeDto
{ {
MinHours = Math.Min(minHours, maxHours), MinHours = Math.Min(minHours, maxHours),
MaxHours = Math.Max(minHours, maxHours), MaxHours = Math.Max(minHours, maxHours),
AvgHours = (int) Math.Round((wordCount / AvgWordsPerHour)) AvgHours = wordCount / AvgWordsPerHour
}; };
} }
var minHoursPages = Math.Max((int) Math.Round((pageCount / MinPagesPerMinute / 60F)), 0); var minHoursPages = Math.Max((int) Math.Round((pageCount / MinPagesPerMinute / 60F)), 0);
var maxHoursPages = Math.Max((int) Math.Round((pageCount / MaxPagesPerMinute / 60F)), 0); var maxHoursPages = Math.Max((int) Math.Round((pageCount / MaxPagesPerMinute / 60F)), 0);
return new HourEstimateRangeDto return new HourEstimateRangeDto
{ {
MinHours = Math.Min(minHoursPages, maxHoursPages), MinHours = Math.Min(minHoursPages, maxHoursPages),
MaxHours = Math.Max(minHoursPages, maxHoursPages), MaxHours = Math.Max(minHoursPages, maxHoursPages),
AvgHours = (int) Math.Round((pageCount / AvgPagesPerMinute / 60F)) AvgHours = pageCount / AvgPagesPerMinute / 60F
}; };
} }
@ -808,6 +810,7 @@ public class ReaderService : IReaderService
{ {
switch(libraryType) switch(libraryType)
{ {
case LibraryType.Image:
case LibraryType.Manga: case LibraryType.Manga:
return "Chapter" + (includeSpace ? " " : string.Empty); return "Chapter" + (includeSpace ? " " : string.Empty);
case LibraryType.Comic: case LibraryType.Comic:

View File

@ -595,7 +595,6 @@ public class StatisticService : IStatisticService
.Contains(c.Id)) .Contains(c.Id))
}) })
.OrderByDescending(d => d.Chapters.Sum(c => c.AvgHoursToRead)) .OrderByDescending(d => d.Chapters.Sum(c => c.AvgHoursToRead))
.Take(5)
.ToList(); .ToList();
@ -615,16 +614,17 @@ public class StatisticService : IStatisticService
chapterLibLookup.Add(cl.ChapterId, cl.LibraryId); chapterLibLookup.Add(cl.ChapterId, cl.LibraryId);
} }
var user = new Dictionary<int, Dictionary<LibraryType, long>>(); var user = new Dictionary<int, Dictionary<LibraryType, float>>();
foreach (var userChapter in topUsersAndReadChapters) foreach (var userChapter in topUsersAndReadChapters)
{ {
if (!user.ContainsKey(userChapter.User.Id)) user.Add(userChapter.User.Id, new Dictionary<LibraryType, long>()); if (!user.ContainsKey(userChapter.User.Id)) user.Add(userChapter.User.Id, []);
var libraryTimes = user[userChapter.User.Id]; var libraryTimes = user[userChapter.User.Id];
foreach (var chapter in userChapter.Chapters) foreach (var chapter in userChapter.Chapters)
{ {
var library = libraries.First(l => l.Id == chapterLibLookup[chapter.Id]); var library = libraries.First(l => l.Id == chapterLibLookup[chapter.Id]);
if (!libraryTimes.ContainsKey(library.Type)) libraryTimes.Add(library.Type, 0L); libraryTimes.TryAdd(library.Type, 0f);
var existingHours = libraryTimes[library.Type]; var existingHours = libraryTimes[library.Type];
libraryTimes[library.Type] = existingHours + chapter.AvgHoursToRead; libraryTimes[library.Type] = existingHours + chapter.AvgHoursToRead;
} }

View File

@ -421,6 +421,12 @@ public class TaskScheduler : ITaskScheduler
BackgroundJob.Enqueue(() => _scannerService.ScanSeries(seriesId, forceUpdate)); BackgroundJob.Enqueue(() => _scannerService.ScanSeries(seriesId, forceUpdate));
} }
/// <summary>
/// Calculates TimeToRead and bytes
/// </summary>
/// <param name="libraryId"></param>
/// <param name="seriesId"></param>
/// <param name="forceUpdate"></param>
public void AnalyzeFilesForSeries(int libraryId, int seriesId, bool forceUpdate = false) public void AnalyzeFilesForSeries(int libraryId, int seriesId, bool forceUpdate = false)
{ {
if (HasAlreadyEnqueuedTask("WordCountAnalyzerService", "ScanSeries", [libraryId, seriesId, forceUpdate])) if (HasAlreadyEnqueuedTask("WordCountAnalyzerService", "ScanSeries", [libraryId, seriesId, forceUpdate]))

View File

@ -217,6 +217,7 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
chapter.MinHoursToRead = est.MinHours; chapter.MinHoursToRead = est.MinHours;
chapter.MaxHoursToRead = est.MaxHours; chapter.MaxHoursToRead = est.MaxHours;
chapter.AvgHoursToRead = est.AvgHours; chapter.AvgHoursToRead = est.AvgHours;
foreach (var file in chapter.Files) foreach (var file in chapter.Files)
{ {
UpdateFileAnalysis(file); UpdateFileAnalysis(file);

View File

@ -448,9 +448,7 @@ public class Startup
} }
catch (Exception ex) catch (Exception ex)
{ {
if ((ex.Message.Contains("Permission denied") if (ex is UnauthorizedAccessException && baseUrl.Equals(Configuration.DefaultBaseUrl) && OsInfo.IsDocker)
|| ex.Message.Contains("UnauthorizedAccessException"))
&& baseUrl.Equals(Configuration.DefaultBaseUrl) && OsInfo.IsDocker)
{ {
// Swallow the exception as the install is non-root and Docker // Swallow the exception as the install is non-root and Docker
return; return;

View File

@ -18,6 +18,6 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="xunit.assert" Version="2.9.0" /> <PackageReference Include="xunit.assert" Version="2.9.1" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -8,6 +8,7 @@
"minify-langs": "node minify-json.js", "minify-langs": "node minify-json.js",
"cache-locale": "node hash-localization.js", "cache-locale": "node hash-localization.js",
"cache-locale-prime": "node hash-localization-prime.js", "cache-locale-prime": "node hash-localization-prime.js",
"sync-locale": "node sync-locales.js",
"prod": "npm run cache-locale-prime && ng build --configuration production && npm run minify-langs && npm run cache-locale", "prod": "npm run cache-locale-prime && ng build --configuration production && npm run minify-langs && npm run cache-locale",
"explore": "ng build --stats-json && webpack-bundle-analyzer dist/stats.json", "explore": "ng build --stats-json && webpack-bundle-analyzer dist/stats.json",
"lint": "ng lint", "lint": "ng lint",

View File

@ -108,6 +108,10 @@ export enum Action {
* Invoke a refresh covers as false to generate colorscapes * Invoke a refresh covers as false to generate colorscapes
*/ */
GenerateColorScape = 26, GenerateColorScape = 26,
/**
* Copy settings from one entity to another
*/
CopySettings = 27
} }
/** /**
@ -254,6 +258,43 @@ export class ActionFactoryService {
return tasks.filter(t => !blacklist.includes(t.action)); return tasks.filter(t => !blacklist.includes(t.action));
} }
getBulkLibraryActions(callback: ActionCallback<Library>) {
// Scan is currently not supported due to the backend not being able to handle it yet
const actions = this.flattenActions<Library>(this.libraryActions).filter(a => {
return [Action.Delete, Action.GenerateColorScape, Action.AnalyzeFiles, Action.RefreshMetadata, Action.CopySettings].includes(a.action);
});
actions.push({
_extra: undefined,
class: undefined,
description: '',
dynamicList: undefined,
action: Action.CopySettings,
callback: this.dummyCallback,
children: [],
requiresAdmin: true,
title: 'copy-settings'
})
return this.applyCallbackToList(actions, callback);
}
flattenActions<T>(actions: Array<ActionItem<T>>): Array<ActionItem<T>> {
return actions.reduce<Array<ActionItem<T>>>((flatArray, action) => {
if (action.action !== Action.Submenu) {
flatArray.push(action);
}
// Recursively flatten the children, if any
if (action.children && action.children.length > 0) {
flatArray.push(...this.flattenActions<T>(action.children));
}
return flatArray;
}, [] as Array<ActionItem<T>>); // Explicitly defining the type of flatArray
}
private _resetActions() { private _resetActions() {
this.libraryActions = [ this.libraryActions = [
{ {

View File

@ -93,6 +93,10 @@ export class LibraryService {
return this.httpClient.post(this.baseUrl + 'library/scan?libraryId=' + libraryId + '&force=' + force, {}); return this.httpClient.post(this.baseUrl + 'library/scan?libraryId=' + libraryId + '&force=' + force, {});
} }
scanMultipleLibraries(libraryIds: Array<number>, force = false) {
return this.httpClient.post(this.baseUrl + 'library/scan-multiple', {ids: libraryIds, force: force});
}
analyze(libraryId: number) { analyze(libraryId: number) {
return this.httpClient.post(this.baseUrl + 'library/analyze?libraryId=' + libraryId, {}); return this.httpClient.post(this.baseUrl + 'library/analyze?libraryId=' + libraryId, {});
} }
@ -101,6 +105,18 @@ export class LibraryService {
return this.httpClient.post(this.baseUrl + `library/refresh-metadata?libraryId=${libraryId}&force=${forceUpdate}&forceColorscape=${forceColorscape}`, {}); return this.httpClient.post(this.baseUrl + `library/refresh-metadata?libraryId=${libraryId}&force=${forceUpdate}&forceColorscape=${forceColorscape}`, {});
} }
refreshMetadataMultipleLibraries(libraryIds: Array<number>, force = false, forceColorscape = false) {
return this.httpClient.post(this.baseUrl + 'library/refresh-metadata-multiple?forceColorscape=' + forceColorscape, {ids: libraryIds, force: force});
}
analyzeFilesMultipleLibraries(libraryIds: Array<number>) {
return this.httpClient.post(this.baseUrl + 'library/analyze-multiple', {ids: libraryIds, force: false});
}
copySettingsFromLibrary(sourceLibraryId: number, targetLibraryIds: Array<number>, includeType: boolean) {
return this.httpClient.post(this.baseUrl + 'library/copy-settings-from', {sourceLibraryId, targetLibraryIds, includeType});
}
create(model: {name: string, type: number, folders: string[]}) { create(model: {name: string, type: number, folders: string[]}) {
return this.httpClient.post(this.baseUrl + 'library/create', model); return this.httpClient.post(this.baseUrl + 'library/create', model);
} }

View File

@ -2,11 +2,17 @@
@if (actions.length > 0) { @if (actions.length > 0) {
@if ((utilityService.activeBreakpoint$ | async)! <= Breakpoint.Tablet) { @if ((utilityService.activeBreakpoint$ | async)! <= Breakpoint.Tablet) {
<button [disabled]="disabled" class="btn {{btnClass}}" id="actions-{{labelBy}}" <button [disabled]="disabled" class="btn {{btnClass}}" id="actions-{{labelBy}}"
(click)="openMobileActionableMenu($event)"><i class="fa {{iconClass}}" aria-hidden="true"></i></button> (click)="openMobileActionableMenu($event)">
{{label}}
<i class="fa {{iconClass}}" aria-hidden="true"></i>
</button>
} @else { } @else {
<div ngbDropdown container="body" class="d-inline-block"> <div ngbDropdown container="body" class="d-inline-block">
<button [disabled]="disabled" class="btn {{btnClass}}" id="actions-{{labelBy}}" ngbDropdownToggle <button [disabled]="disabled" class="btn {{btnClass}}" id="actions-{{labelBy}}" ngbDropdownToggle
(click)="preventEvent($event)"><i class="fa {{iconClass}}" aria-hidden="true"></i></button> (click)="preventEvent($event)">
{{label}}
<i class="fa {{iconClass}}" aria-hidden="true"></i>
</button>
<div ngbDropdownMenu attr.aria-labelledby="actions-{{labelBy}}"> <div ngbDropdownMenu attr.aria-labelledby="actions-{{labelBy}}">
<ng-container *ngTemplateOutlet="submenu; context: { list: actions }"></ng-container> <ng-container *ngTemplateOutlet="submenu; context: { list: actions }"></ng-container>
</div> </div>

View File

@ -16,7 +16,6 @@ import {TranslocoDirective} from "@jsverse/transloco";
import {DynamicListPipe} from "./_pipes/dynamic-list.pipe"; import {DynamicListPipe} from "./_pipes/dynamic-list.pipe";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {Breakpoint, UtilityService} from "../../shared/_services/utility.service"; import {Breakpoint, UtilityService} from "../../shared/_services/utility.service";
import {NavLinkModalComponent} from "../../nav/_components/nav-link-modal/nav-link-modal.component";
import {ActionableModalComponent} from "../actionable-modal/actionable-modal.component"; import {ActionableModalComponent} from "../actionable-modal/actionable-modal.component";
@Component({ @Component({
@ -41,6 +40,10 @@ export class CardActionablesComponent implements OnInit {
@Input() btnClass = ''; @Input() btnClass = '';
@Input() actions: ActionItem<any>[] = []; @Input() actions: ActionItem<any>[] = [];
@Input() labelBy = 'card'; @Input() labelBy = 'card';
/**
* Text to display as if actionable was a button
*/
@Input() label = '';
@Input() disabled: boolean = false; @Input() disabled: boolean = false;
@Output() actionHandler = new EventEmitter<ActionItem<any>>(); @Output() actionHandler = new EventEmitter<ActionItem<any>>();

View File

@ -0,0 +1,23 @@
<ng-container *transloco="let t; read:'copy-settings-from-library-modal'">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4>
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="modal.close(null)"></button>
</div>
<div class="modal-body">
<p>{{t('description')}}</p>
<form [formGroup]="libForm">
<select class="form-select" formControlName="library">
<option [value]="null">{{t('select-option')}}</option>
@for (lib of libraries; track lib.id) {
<option [value]="lib.id">{{lib.name}}</option>
}
</select>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="modal.close(null)">{{t('close')}}</button>
<button type="button" class="btn btn-primary" (click)="save()">{{t('select')}}</button>
</div>
</ng-container>

View File

@ -0,0 +1,30 @@
import {ChangeDetectionStrategy, Component, inject, Input} from '@angular/core';
import {Library} from "../../../_models/library/library";
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
import {TranslocoDirective} from "@jsverse/transloco";
import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
@Component({
selector: 'app-copy-settings-from-library-modal',
standalone: true,
imports: [
TranslocoDirective,
ReactiveFormsModule,
],
templateUrl: './copy-settings-from-library-modal.component.html',
styleUrl: './copy-settings-from-library-modal.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CopySettingsFromLibraryModalComponent {
protected readonly modal = inject(NgbActiveModal);
@Input() libraries: Array<Library> = [];
libForm = new FormGroup({
'library': new FormControl(null),
});
save() {
this.modal.close(parseInt(this.libForm.get('library')?.value + '', 10));
}
}

View File

@ -18,19 +18,20 @@ import {SelectionModel} from "../../../typeahead/_models/selection-model";
}) })
export class LibraryAccessModalComponent implements OnInit { export class LibraryAccessModalComponent implements OnInit {
protected readonly modal = inject(NgbActiveModal);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly libraryService = inject(LibraryService);
@Input() member: Member | undefined; @Input() member: Member | undefined;
allLibraries: Library[] = []; allLibraries: Library[] = [];
selectedLibraries: Array<{selected: boolean, data: Library}> = []; selectedLibraries: Array<{selected: boolean, data: Library}> = [];
selections!: SelectionModel<Library>; selections!: SelectionModel<Library>;
selectAll: boolean = false; selectAll: boolean = false;
cdRef = inject(ChangeDetectorRef);
get hasSomeSelected() { get hasSomeSelected() {
return this.selections != null && this.selections.hasSomeSelected(); return this.selections != null && this.selections.hasSomeSelected();
} }
constructor(public modal: NgbActiveModal, private libraryService: LibraryService) { }
ngOnInit(): void { ngOnInit(): void {
this.libraryService.getLibraries().subscribe(libs => { this.libraryService.getLibraries().subscribe(libs => {

View File

@ -1,15 +1,45 @@
<ng-container *transloco="let t; read: 'manage-library'"> <ng-container *transloco="let t; read: 'manage-library'">
<div class="position-relative"> <div class="position-relative">
<div class="position-absolute custom-position-2">
<app-card-actionables [actions]="bulkActions" btnClass="btn-primary-outline ms-1" [label]="t('bulk-action-label')" [disabled]="bulkMode" (actionHandler)="handleBulkAction($event, null)">
</app-card-actionables>
</div>
<button class="btn btn-primary-outline position-absolute custom-position" (click)="addLibrary()" [title]="t('add-library')"> <button class="btn btn-primary-outline position-absolute custom-position" (click)="addLibrary()" [title]="t('add-library')">
<i class="fa fa-plus" aria-hidden="true"></i><span class="phone-hidden ms-1">{{t('add-library')}}</span> <i class="fa fa-plus" aria-hidden="true"></i><span class="phone-hidden ms-1">{{t('add-library')}}</span>
</button> </button>
</div> </div>
<!-- TODO: We need a filter bar when there is more than 10 libraries --> @if (bulkMode && bulkAction === Action.CopySettings && sourceCopyToLibrary) {
<div class="alert alert-warning">
{{t('bulk-copy-to', {libraryName: sourceCopyToLibrary.name})}}
<form [formGroup]="bulkForm">
<div class="form-check form-switch">
<input id="bulk-action-type" type="checkbox" class="form-check-input" formControlName="includeType" aria-describedby="include-type-help">
<label class="form-check-label" for="bulk-action-type">{{t('include-type-label')}}</label>
<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="left" [ngbTooltip]="includeTypeTooltip" role="button" tabindex="0"></i>
<ng-template #includeTypeTooltip>{{t('include-type-tooltip')}}</ng-template>
<span class="visually-hidden" id="include-type-help"><ng-container [ngTemplateOutlet]="includeTypeTooltip"></ng-container></span>
</div>
</form>
<div class="mt-2">
<button class="btn btn-secondary" (click)="resetBulkMode()">{{t('cancel')}}</button>
<button class="btn btn-primary ms-1" (click)="applyBulkAction()" [disabled]="!hasSomeSelected">{{t('apply')}}</button>
</div>
</div>
}
<table class="table table-striped"> <table class="table table-striped">
<thead> <thead>
<tr> <tr>
<th scope="col">
<div class="form-check">
<input id="select-all" type="checkbox" class="form-check-input"
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="hasSomeSelected">
<label for="select-all" class="form-check-label d-md-block d-none">{{t('select-all')}}</label>
</div>
</th>
<th scope="col">{{t('name-header')}}</th> <th scope="col">{{t('name-header')}}</th>
<th scope="col">{{t('type-title')}}</th> <th scope="col">{{t('type-title')}}</th>
<th scope="col">{{t('shared-folders-title')}}</th> <th scope="col">{{t('shared-folders-title')}}</th>
@ -20,7 +50,14 @@
<tbody> <tbody>
@for(library of libraries; track library.name + library.type + library.folders.length + library.lastScanned; let idx = $index) { @for(library of libraries; track library.name + library.type + library.folders.length + library.lastScanned; let idx = $index) {
<tr> <tr>
<td id="username--{{idx}}"> <td>
<div class="form-check">
<input id="select-library-{{idx}}" type="checkbox" class="form-check-input"
[ngModel]="selections.isSelected(library)" (change)="handleSelection(library, idx)">
<label for="select-library-{{idx}}" class="form-check-label visually-hidden">{{library.name}}</label>
</div>
</td>
<td id="library--{{idx}}">
<a [routerLink]="'/library/' + library.id">{{library.name}}</a> <a [routerLink]="'/library/' + library.id">{{library.name}}</a>
</td> </td>
<td> <td>
@ -33,6 +70,7 @@
{{library.lastScanned | timeAgo | defaultDate}} {{library.lastScanned | timeAgo | defaultDate}}
</td> </td>
<td> <td>
<!-- On Mobile we want to use ... for each row -->
@if (useActionables$ | async) { @if (useActionables$ | async) {
<app-card-actionables [actions]="actions" (actionHandler)="performAction($event, library)"></app-card-actionables> <app-card-actionables [actions]="actions" (actionHandler)="performAction($event, library)"></app-card-actionables>
} @else { } @else {

View File

@ -5,6 +5,19 @@
top: -42px; top: -42px;
} }
.custom-position-2 {
right: 160px;
top: -42px;
}
@media(max-width: 576px) {
.custom-position-2 {
right: 65px;
}
}
.member-name { .member-name {
word-break: keep-all; word-break: keep-all;
margin: 0; margin: 0;

View File

@ -2,28 +2,31 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
DestroyRef, HostListener, DestroyRef,
HostListener,
inject, inject,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { NgbModal, NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; import {NgbModal, NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr'; import {ToastrService} from 'ngx-toastr';
import { distinctUntilChanged, filter, take } from 'rxjs/operators'; import {distinctUntilChanged, filter, take} from 'rxjs/operators';
import { ConfirmService } from 'src/app/shared/confirm.service'; import {ConfirmService} from 'src/app/shared/confirm.service';
import { LibrarySettingsModalComponent } from 'src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component'; import {
import { NotificationProgressEvent } from 'src/app/_models/events/notification-progress-event'; LibrarySettingsModalComponent
import { ScanSeriesEvent } from 'src/app/_models/events/scan-series-event'; } from 'src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component';
import { Library } from 'src/app/_models/library/library'; import {NotificationProgressEvent} from 'src/app/_models/events/notification-progress-event';
import { LibraryService } from 'src/app/_services/library.service'; import {ScanSeriesEvent} from 'src/app/_models/events/scan-series-event';
import { EVENTS, Message, MessageHubService } from 'src/app/_services/message-hub.service'; import {Library} from 'src/app/_models/library/library';
import {LibraryService} from 'src/app/_services/library.service';
import {EVENTS, Message, MessageHubService} from 'src/app/_services/message-hub.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { SentenceCasePipe } from '../../_pipes/sentence-case.pipe'; import {SentenceCasePipe} from '../../_pipes/sentence-case.pipe';
import { TimeAgoPipe } from '../../_pipes/time-ago.pipe'; import {TimeAgoPipe} from '../../_pipes/time-ago.pipe';
import { LibraryTypePipe } from '../../_pipes/library-type.pipe'; import {LibraryTypePipe} from '../../_pipes/library-type.pipe';
import { RouterLink } from '@angular/router'; import {RouterLink} from '@angular/router';
import {translate, TranslocoModule} from "@jsverse/transloco"; import {translate, TranslocoModule} from "@jsverse/transloco";
import {DefaultDatePipe} from "../../_pipes/default-date.pipe"; import {DefaultDatePipe} from "../../_pipes/default-date.pipe";
import {AsyncPipe, TitleCasePipe} from "@angular/common"; import {AsyncPipe, NgTemplateOutlet, TitleCasePipe} from "@angular/common";
import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
import {LoadingComponent} from "../../shared/loading/loading.component"; import {LoadingComponent} from "../../shared/loading/loading.component";
import {TagBadgeComponent} from "../../shared/tag-badge/tag-badge.component"; import {TagBadgeComponent} from "../../shared/tag-badge/tag-badge.component";
@ -33,6 +36,12 @@ import {Action, ActionFactoryService, ActionItem} from "../../_services/action-f
import {ActionService} from "../../_services/action.service"; import {ActionService} from "../../_services/action.service";
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component"; import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
import {BehaviorSubject, Observable} from "rxjs"; import {BehaviorSubject, Observable} from "rxjs";
import {Select2Module} from "ng-select2-component";
import {SelectionModel} from "../../typeahead/_models/selection-model";
import {
CopySettingsFromLibraryModalComponent
} from "../_modals/copy-settings-from-library-modal/copy-settings-from-library-modal.component";
import {FormControl, FormGroup} from "@angular/forms";
@Component({ @Component({
selector: 'app-manage-library', selector: 'app-manage-library',
@ -40,7 +49,9 @@ import {BehaviorSubject, Observable} from "rxjs";
styleUrls: ['./manage-library.component.scss'], styleUrls: ['./manage-library.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [RouterLink, NgbTooltip, LibraryTypePipe, TimeAgoPipe, SentenceCasePipe, TranslocoModule, DefaultDatePipe, AsyncPipe, DefaultValuePipe, LoadingComponent, TagBadgeComponent, TitleCasePipe, UtcToLocalTimePipe, CardActionablesComponent] imports: [RouterLink, NgbTooltip, LibraryTypePipe, TimeAgoPipe, SentenceCasePipe, TranslocoModule, DefaultDatePipe,
AsyncPipe, DefaultValuePipe, LoadingComponent, TagBadgeComponent, TitleCasePipe, UtcToLocalTimePipe,
CardActionablesComponent, Select2Module, NgTemplateOutlet]
}) })
export class ManageLibraryComponent implements OnInit { export class ManageLibraryComponent implements OnInit {
@ -56,8 +67,10 @@ export class ManageLibraryComponent implements OnInit {
private readonly actionService = inject(ActionService); private readonly actionService = inject(ActionService);
protected readonly Breakpoint = Breakpoint; protected readonly Breakpoint = Breakpoint;
protected readonly Action = Action;
actions = this.actionFactoryService.getLibraryActions(this.handleAction.bind(this)); actions = this.actionFactoryService.getLibraryActions(this.handleAction.bind(this));
bulkActions = this.actionFactoryService.getBulkLibraryActions(this.handleBulkAction.bind(this));
libraries: Library[] = []; libraries: Library[] = [];
loading = false; loading = false;
/** /**
@ -66,6 +79,25 @@ export class ManageLibraryComponent implements OnInit {
deletionInProgress: boolean = false; deletionInProgress: boolean = false;
useActionableSource = new BehaviorSubject<boolean>(this.utilityService.getActiveBreakpoint() <= Breakpoint.Tablet); useActionableSource = new BehaviorSubject<boolean>(this.utilityService.getActiveBreakpoint() <= Breakpoint.Tablet);
useActionables$: Observable<boolean> = this.useActionableSource.asObservable(); useActionables$: Observable<boolean> = this.useActionableSource.asObservable();
selections!: SelectionModel<Library>;
selectAll: boolean = false;
bulkMode = false;
bulkAction: Action | null = null;
sourceCopyToLibrary: Library | null = null;
bulkForm = new FormGroup({'includeType': new FormControl(false)});
isShiftDown: boolean = false;
lastSelectedIndex: number | null = null;
@HostListener('document:keydown.shift', ['$event'])
handleKeypress(event: KeyboardEvent) {
this.isShiftDown = true;
}
@HostListener('document:keyup.shift', ['$event'])
handleKeyUp(event: KeyboardEvent) {
this.isShiftDown = false;
}
@HostListener('window:resize', ['$event']) @HostListener('window:resize', ['$event'])
@HostListener('window:orientationchange', ['$event']) @HostListener('window:orientationchange', ['$event'])
@ -73,6 +105,10 @@ export class ManageLibraryComponent implements OnInit {
this.useActionableSource.next(this.utilityService.getActiveBreakpoint() <= Breakpoint.Tablet); this.useActionableSource.next(this.utilityService.getActiveBreakpoint() <= Breakpoint.Tablet);
} }
get hasSomeSelected() {
return this.selections != null && this.selections.hasSomeSelected();
}
ngOnInit(): void { ngOnInit(): void {
this.getLibraries(); this.getLibraries();
@ -118,6 +154,8 @@ export class ManageLibraryComponent implements OnInit {
this.cdRef.markForCheck(); this.cdRef.markForCheck();
this.libraryService.getLibraries().pipe(take(1), takeUntilDestroyed(this.destroyRef)).subscribe(libraries => { this.libraryService.getLibraries().pipe(take(1), takeUntilDestroyed(this.destroyRef)).subscribe(libraries => {
this.libraries = [...libraries]; this.libraries = [...libraries];
this.setupSelections();
this.resetBulkMode();
this.loading = false; this.loading = false;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
}); });
@ -158,6 +196,80 @@ export class ManageLibraryComponent implements OnInit {
await this.actionService.scanLibrary(library); await this.actionService.scanLibrary(library);
} }
async applyBulkAction() {
if (!this.bulkMode) {
this.resetBulkMode();
}
// Get Selected libraries
let selected = this.selections.selected();
// Remove the source library id from selected (if applicable)
if (this.bulkAction === Action.CopySettings) {
selected = selected.filter(l => l.id !== this.sourceCopyToLibrary!.id);
}
if (selected.length === 0) {
await this.confirmService.alert(translate('toasts.must-select-library'));
return;
}
switch(this.bulkAction) {
case (Action.Scan):
await this.confirmService.alert(translate('toasts.bulk-scan'));
this.libraryService.scanMultipleLibraries(selected.map(l => l.id)).subscribe();
break;
case Action.RefreshMetadata:
if (!await this.confirmService.confirm(translate('toasts.bulk-covers'))) return;
this.libraryService.refreshMetadataMultipleLibraries(selected.map(l => l.id), true, false).subscribe(() => this.getLibraries());
break
case Action.AnalyzeFiles:
this.libraryService.analyzeFilesMultipleLibraries(selected.map(l => l.id)).subscribe(() => this.getLibraries());
break;
case Action.GenerateColorScape:
this.libraryService.refreshMetadataMultipleLibraries(selected.map(l => l.id), false, true).subscribe(() => this.getLibraries());
break;
case Action.CopySettings:
// Remove the source library from the list
if (selected.length === 1 && selected[0].id === this.sourceCopyToLibrary!.id) {
return;
}
const includeType = this.bulkForm.get('includeType')!.value + '' == 'true';
this.libraryService.copySettingsFromLibrary(this.sourceCopyToLibrary!.id, selected.map(l => l.id), includeType).subscribe(() => this.getLibraries());
break;
}
}
async handleBulkAction(action: ActionItem<Library>, library : Library | null) {
this.bulkMode = true;
this.bulkAction = action.action;
this.cdRef.markForCheck();
switch (action.action) {
case(Action.Scan):
case(Action.RefreshMetadata):
case(Action.GenerateColorScape):
case (Action.Delete):
await this.applyBulkAction();
break;
case (Action.CopySettings):
// Prompt the user for the library then wait for them to manually trigger applyBulkAction
const ref = this.modalService.open(CopySettingsFromLibraryModalComponent, {size: 'lg', fullscreen: 'md'});
ref.componentInstance.libraries = this.libraries;
ref.closed.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((res: number | null) => {
if (res === null) return;
// res will be the library the user chose
this.bulkMode = true;
this.sourceCopyToLibrary = this.libraries.filter(l => l.id === res)[0];
this.cdRef.markForCheck();
});
break;
}
}
async handleAction(action: ActionItem<Library>, library: Library) { async handleAction(action: ActionItem<Library>, library: Library) {
switch (action.action) { switch (action.action) {
case(Action.Scan): case(Action.Scan):
@ -185,4 +297,57 @@ export class ManageLibraryComponent implements OnInit {
action.callback(action, library); action.callback(action, library);
} }
} }
setupSelections() {
this.selections = new SelectionModel<Library>(false, this.libraries);
this.cdRef.markForCheck();
}
toggleAll() {
this.selectAll = !this.selectAll;
this.libraries.forEach(s => this.selections.toggle(s, this.selectAll));
this.cdRef.markForCheck();
}
handleSelection(item: Library, index: number) {
if (this.isShiftDown && this.lastSelectedIndex !== null) {
// Bulk select items between the last selected item and the current one
const start = Math.min(this.lastSelectedIndex, index);
const end = Math.max(this.lastSelectedIndex, index);
for (let i = start; i <= end; i++) {
const library = this.libraries[i];
if (!this.selections.isSelected(library)) {
this.selections.toggle(library, true); // Select the item
}
}
} else {
// Toggle the clicked item
this.selections.toggle(item);
}
// Update the last selected index
this.lastSelectedIndex = index;
// Manage the state of "Select All" and "Has Some Selected"
const numberOfSelected = this.selections.selected().length;
this.selectAll = numberOfSelected === this.libraries.length;
this.cdRef.markForCheck();
}
resetBulkMode() {
this.bulkAction = null;
this.bulkMode = false;
this.sourceCopyToLibrary = null;
this.libraries.forEach(s => {
if (this.selections.isSelected(s)) {
this.selections.toggle(s, false)
}
});
this.selectAll = false;
this.cdRef.markForCheck();
}
} }

View File

@ -1,7 +1,7 @@
import { ConfirmButton } from './confirm-button'; import { ConfirmButton } from './confirm-button';
export class ConfirmConfig { export class ConfirmConfig {
_type: string = 'confirm'; // internal only: confirm or alert (todo: use enum) _type: 'confirm' | 'alert' = 'confirm';
header: string = 'Confirm'; header: string = 'Confirm';
content: string = ''; content: string = '';
buttons: Array<ConfirmButton> = []; buttons: Array<ConfirmButton> = [];

View File

@ -3,6 +3,7 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { take } from 'rxjs/operators'; import { take } from 'rxjs/operators';
import { ConfirmDialogComponent } from './confirm-dialog/confirm-dialog.component'; import { ConfirmDialogComponent } from './confirm-dialog/confirm-dialog.component';
import { ConfirmConfig } from './confirm-dialog/_models/confirm-config'; import { ConfirmConfig } from './confirm-dialog/_models/confirm-config';
import {translate} from "@jsverse/transloco";
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -32,6 +33,7 @@ export class ConfirmService {
if (content !== undefined && config === undefined) { if (content !== undefined && config === undefined) {
config = this.defaultConfirm; config = this.defaultConfirm;
config.header = translate('confirm.confirm');
config.content = content; config.content = content;
} }
if (content !== undefined && content !== '' && config!.content === '') { if (content !== undefined && content !== '' && config!.content === '') {
@ -58,7 +60,8 @@ export class ConfirmService {
} }
if (content !== undefined && config === undefined) { if (content !== undefined && config === undefined) {
config = this.defaultConfirm; config = this.defaultAlert;
config.header = translate('confirm.alert');
config.content = content; config.content = content;
} }

View File

@ -23,7 +23,7 @@ import {TopReadersComponent} from '../top-readers/top-readers.component';
import {StatListComponent} from '../stat-list/stat-list.component'; import {StatListComponent} from '../stat-list/stat-list.component';
import {IconAndTitleComponent} from '../../../shared/icon-and-title/icon-and-title.component'; import {IconAndTitleComponent} from '../../../shared/icon-and-title/icon-and-title.component';
import {AsyncPipe, DecimalPipe, NgIf} from '@angular/common'; import {AsyncPipe, DecimalPipe, NgIf} from '@angular/common';
import {translate, TranslocoDirective, TranslocoService} from "@jsverse/transloco"; import {translate, TranslocoDirective} from "@jsverse/transloco";
import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison"; import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison";
import {FilterField} from "../../../_models/metadata/v2/filter-field"; import {FilterField} from "../../../_models/metadata/v2/filter-field";
import { import {

View File

@ -9,28 +9,34 @@
<label for="time-select-top-reads" class="form-check-label visually-hidden">{{t('time-selection-label')}}</label> <label for="time-select-top-reads" class="form-check-label visually-hidden">{{t('time-selection-label')}}</label>
<select id="time-select-top-reads" class="form-select" formControlName="days" <select id="time-select-top-reads" class="form-select" formControlName="days"
[class.is-invalid]="formGroup.get('days')?.invalid && formGroup.get('days')?.touched"> [class.is-invalid]="formGroup.get('days')?.invalid && formGroup.get('days')?.touched">
<option *ngFor="let item of timePeriods" [value]="item.value">{{t(item.title)}}</option> @for (item of timePeriods; track item.value) {
<option [value]="item.value">{{t(item.title)}}</option>
}
</select> </select>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
@if (users$ | async; as users) {
<ng-container> <app-carousel-reel [alwaysShow]="false" [clickableTitle]="false" [items]="users">
<div class="grid row g-0"> <ng-template #carouselItem let-item>
<div class="card" *ngFor="let user of (users$ | async)"> <div class="card me-2">
<div class="card-header text-center"> <div class="card-header text-center">
{{user.username}} {{item.username}}
</div> </div>
<ul class="list-group list-group-flush"> <ul class="list-group list-group-flush">
<li class="list-group-item">{{t('comics-label', {value: user.comicsTime})}}</li> <li class="list-group-item">{{t('comics-label', {value: item.comicsTime})}}</li>
<li class="list-group-item">{{t('manga-label', {value: user.mangaTime})}}</li> <li class="list-group-item">{{t('manga-label', {value: item.mangaTime})}}</li>
<li class="list-group-item">{{t('books-label', {value: user.booksTime})}}</li> <li class="list-group-item">{{t('books-label', {value: item.booksTime})}}</li>
</ul> </ul>
</div> </div>
</div> </ng-template>
</ng-container> </app-carousel-reel>
}

View File

@ -11,8 +11,9 @@ import { Observable, switchMap, shareReplay } from 'rxjs';
import { StatisticsService } from 'src/app/_services/statistics.service'; import { StatisticsService } from 'src/app/_services/statistics.service';
import { TopUserRead } from '../../_models/top-reads'; import { TopUserRead } from '../../_models/top-reads';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { NgFor, AsyncPipe } from '@angular/common'; import { AsyncPipe } from '@angular/common';
import {TranslocoDirective} from "@jsverse/transloco"; import {TranslocoDirective} from "@jsverse/transloco";
import {CarouselReelComponent} from "../../../carousel/_components/carousel-reel/carousel-reel.component";
export const TimePeriods: Array<{title: string, value: number}> = export const TimePeriods: Array<{title: string, value: number}> =
[{title: 'this-week', value: new Date().getDay() || 1}, [{title: 'this-week', value: new Date().getDay() || 1},
@ -28,15 +29,16 @@ export const TimePeriods: Array<{title: string, value: number}> =
styleUrls: ['./top-readers.component.scss'], styleUrls: ['./top-readers.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [ReactiveFormsModule, NgFor, AsyncPipe, TranslocoDirective] imports: [ReactiveFormsModule, AsyncPipe, TranslocoDirective, CarouselReelComponent]
}) })
export class TopReadersComponent implements OnInit { export class TopReadersComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef);
formGroup: FormGroup; formGroup: FormGroup;
timePeriods = TimePeriods; timePeriods = TimePeriods;
users$: Observable<TopUserRead[]>; users$: Observable<TopUserRead[]>;
private readonly destroyRef = inject(DestroyRef);
constructor(private statsService: StatisticsService, private readonly cdRef: ChangeDetectorRef) { constructor(private statsService: StatisticsService, private readonly cdRef: ChangeDetectorRef) {
this.formGroup = new FormGroup({ this.formGroup = new FormGroup({

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1172,7 +1172,7 @@
"no-data": "There are no libraries. Try creating one.", "no-data": "There are no libraries. Try creating one.",
"loading": "{{common.loading}}", "loading": "{{common.loading}}",
"last-scanned-title": "Last Scanned", "last-scanned-title": "Last Scanned",
"shared-folders-title": "Shared Folders", "shared-folders-title": "Folders",
"type-title": "Type", "type-title": "Type",
"scan-library": "Scan Library", "scan-library": "Scan Library",
"delete-library": "Delete Library", "delete-library": "Delete Library",
@ -1181,7 +1181,23 @@
"edit-library-by-name": "Delete {{name}}", "edit-library-by-name": "Delete {{name}}",
"folder-count": "{{num}} folders", "folder-count": "{{num}} folders",
"actions-header": "{{manage-users.actions-header}}", "actions-header": "{{manage-users.actions-header}}",
"name-header": "{{manage-users.name-header}}" "name-header": "{{manage-users.name-header}}",
"deselect-all": "{{common.deselect-all}}",
"select-all": "{{common.select-all}}",
"cancel": "{{common.cancel}}",
"apply": "{{common.apply}}",
"bulk-copy-to": "Select which libraries to copy the settings from {{libraryName}} to",
"include-type-label": "Include copying Library type",
"include-type-tooltip": "This will not scan automatically. On the next scan, you may have series shift due to parsing differences",
"bulk-action-label": "Bulk Action"
},
"copy-settings-from-library-modal": {
"close": "{{common.close}}",
"select": "Select",
"title": "Copy settings from one library to multiple others",
"description": "Select a library to copy settings from and on the next screen select libraries to apply to.",
"select-option": "Select a Library"
}, },
"manage-media-settings": { "manage-media-settings": {
@ -2239,7 +2255,10 @@
"is-empty": "Is Empty" "is-empty": "Is Empty"
}, },
"confirm": {
"alert": "Alert",
"confirm": "Confirm"
},
"toasts": { "toasts": {
"regen-cover": "A job has been enqueued to regenerate the cover image", "regen-cover": "A job has been enqueued to regenerate the cover image",
@ -2351,7 +2370,10 @@
"stack-imported": "Stack Imported", "stack-imported": "Stack Imported",
"confirm-delete-theme": "Removing this theme will delete it from the disk. You can grab it from temp directory before removal", "confirm-delete-theme": "Removing this theme will delete it from the disk. You can grab it from temp directory before removal",
"mal-token-required": "MAL Token is required, set in User Settings", "mal-token-required": "MAL Token is required, set in User Settings",
"confirm-reset-server-settings": "This will reset your settings to first install values. Are you sure you want to continue?" "confirm-reset-server-settings": "This will reset your settings to first install values. Are you sure you want to continue?",
"must-select-library": "At least one library must be selected",
"bulk-scan": "Scanning multiple libraries will be done linearly. This may take a long time and not complete depending on library size.",
"bulk-covers": "Refreshing covers on multiple libraries is intensive and can take a long time. Are you sure you want to continue?"
}, },
"read-time-pipe": { "read-time-pipe": {
@ -2423,8 +2445,8 @@
"new-collection": "New Collection", "new-collection": "New Collection",
"multiple-selections": "Multiple Selections", "multiple-selections": "Multiple Selections",
"back-to": "Back to {{action}}", "back-to": "Back to {{action}}",
"title": "Actions" "title": "Actions",
"copy-settings": "Copy Settings From"
}, },
"preferences": { "preferences": {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

80
UI/Web/sync-locales.js Normal file
View File

@ -0,0 +1,80 @@
const fs = require('fs');
const path = require('path');
function syncLocales() {
const webDir = path.resolve(__dirname);
const langDir = path.join(webDir, 'src', 'assets', 'langs');
const sourceFile = path.join(langDir, 'en.json');
console.log('Web directory:', webDir);
console.log('Language directory:', langDir);
console.log('Source file:', sourceFile);
if (!fs.existsSync(sourceFile)) {
console.error(`Source file not found: ${sourceFile}`);
process.exit(1);
}
const sourceData = JSON.parse(fs.readFileSync(sourceFile, 'utf8'));
const localeFiles = fs.readdirSync(langDir).filter(file => file.endsWith('.json') && file !== 'en.json');
localeFiles.forEach(localeFile => {
const filePath = path.join(langDir, localeFile);
console.log(`Processing: ${filePath}`);
if (!fs.existsSync(filePath)) {
console.error(`File not found: ${filePath}`);
return;
}
let localeData = JSON.parse(fs.readFileSync(filePath, 'utf8'));
let updated = false;
function updateNestedObject(source, target, parentKeys = []) {
const updatedTarget = {};
Object.keys(source).forEach(key => {
const fullKeyPath = [...parentKeys, key].join('.'); // Track parent keys
if (typeof source[key] === 'object' && source[key] !== null) {
if (!target[key] || Object.keys(target[key]).length === 0) {
updatedTarget[key] = {};
updated = true;
console.log(`Added new object for key: ${fullKeyPath}`);
} else {
updatedTarget[key] = target[key];
}
updatedTarget[key] = updateNestedObject(source[key], updatedTarget[key], [...parentKeys, key]);
} else {
if (typeof source[key] === 'string') {
if (source[key].match(/{{.+\..+}}/)) {
if (target[key] !== source[key]) {
updatedTarget[key] = source[key];
updated = true;
console.log(`Updated key: ${fullKeyPath}`);
} else {
updatedTarget[key] = target[key];
}
} else if (!target.hasOwnProperty(key)) {
updatedTarget[key] = '';
updated = true;
console.log(`Added empty string for key: ${fullKeyPath}`);
} else {
updatedTarget[key] = target[key];
}
}
}
});
return updatedTarget;
}
localeData = updateNestedObject(sourceData, localeData);
if (updated) {
fs.writeFileSync(filePath, JSON.stringify(localeData, null, 2));
console.log(`Updated ${localeFile}`);
} else {
console.log(`No updates needed for ${localeFile}`);
}
});
}
syncLocales();

View File

@ -3082,6 +3082,38 @@
} }
} }
}, },
"/api/Library/scan-multiple": {
"post": {
"tags": [
"Library"
],
"summary": "Enqueues a bunch of library scans",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BulkActionDto"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/BulkActionDto"
}
},
"application/*+json": {
"schema": {
"$ref": "#/components/schemas/BulkActionDto"
}
}
}
},
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/api/Library/scan-all": { "/api/Library/scan-all": {
"post": { "post": {
"tags": [ "tags": [
@ -3144,6 +3176,47 @@
} }
} }
}, },
"/api/Library/refresh-metadata-multiple": {
"post": {
"tags": [
"Library"
],
"parameters": [
{
"name": "forceColorscape",
"in": "query",
"schema": {
"type": "boolean",
"default": true
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BulkActionDto"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/BulkActionDto"
}
},
"application/*+json": {
"schema": {
"$ref": "#/components/schemas/BulkActionDto"
}
}
}
},
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/api/Library/analyze": { "/api/Library/analyze": {
"post": { "post": {
"tags": [ "tags": [
@ -3166,6 +3239,70 @@
} }
} }
}, },
"/api/Library/analyze-multiple": {
"post": {
"tags": [
"Library"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BulkActionDto"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/BulkActionDto"
}
},
"application/*+json": {
"schema": {
"$ref": "#/components/schemas/BulkActionDto"
}
}
}
},
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/api/Library/copy-settings-from": {
"post": {
"tags": [
"Library"
],
"summary": "Copy the library settings (adv tab + optional type) to a set of other libraries.",
"requestBody": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CopySettingsFromLibraryDto"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/CopySettingsFromLibraryDto"
}
},
"application/*+json": {
"schema": {
"$ref": "#/components/schemas/CopySettingsFromLibraryDto"
}
}
}
},
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/api/Library/scan-folder": { "/api/Library/scan-folder": {
"post": { "post": {
"tags": [ "tags": [
@ -14965,6 +15102,24 @@
}, },
"additionalProperties": false "additionalProperties": false
}, },
"BulkActionDto": {
"type": "object",
"properties": {
"ids": {
"type": "array",
"items": {
"type": "integer",
"format": "int32"
},
"nullable": true
},
"force": {
"type": "boolean",
"nullable": true
}
},
"additionalProperties": false
},
"BulkRemoveBookmarkForSeriesDto": { "BulkRemoveBookmarkForSeriesDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -15187,8 +15342,8 @@
"format": "int32" "format": "int32"
}, },
"avgHoursToRead": { "avgHoursToRead": {
"type": "integer", "type": "number",
"format": "int32" "format": "float"
}, },
"webLinks": { "webLinks": {
"type": "string", "type": "string",
@ -15451,8 +15606,8 @@
"format": "int32" "format": "int32"
}, },
"avgHoursToRead": { "avgHoursToRead": {
"type": "integer", "type": "number",
"format": "int32" "format": "float"
}, },
"webLinks": { "webLinks": {
"type": "string", "type": "string",
@ -16160,6 +16315,28 @@
}, },
"additionalProperties": false "additionalProperties": false
}, },
"CopySettingsFromLibraryDto": {
"type": "object",
"properties": {
"sourceLibraryId": {
"type": "integer",
"format": "int32"
},
"targetLibraryIds": {
"type": "array",
"items": {
"type": "integer",
"format": "int32"
},
"nullable": true
},
"includeType": {
"type": "boolean",
"description": "Include copying over the type"
}
},
"additionalProperties": false
},
"CreateDeviceDto": { "CreateDeviceDto": {
"required": [ "required": [
"emailAddress", "emailAddress",
@ -17511,9 +17688,9 @@
"format": "int32" "format": "int32"
}, },
"avgHours": { "avgHours": {
"type": "integer", "type": "number",
"description": "Estimated average hours to read the selection", "description": "Estimated average hours to read the selection",
"format": "int32" "format": "float"
} }
}, },
"additionalProperties": false, "additionalProperties": false,
@ -19707,7 +19884,7 @@
}, },
"lastModified": { "lastModified": {
"type": "string", "type": "string",
"description": "Whenever a modification occurs. Ie) New volumes, removed volumes, title update, etc", "description": "Whenever a modification occurs. ex: New volumes, removed volumes, title update, etc",
"format": "date-time" "format": "date-time"
}, },
"createdUtc": { "createdUtc": {
@ -19801,8 +19978,8 @@
"format": "int32" "format": "int32"
}, },
"avgHoursToRead": { "avgHoursToRead": {
"type": "integer", "type": "number",
"format": "int32" "format": "float"
}, },
"metadata": { "metadata": {
"$ref": "#/components/schemas/SeriesMetadata" "$ref": "#/components/schemas/SeriesMetadata"
@ -20051,8 +20228,8 @@
"format": "int32" "format": "int32"
}, },
"avgHoursToRead": { "avgHoursToRead": {
"type": "integer", "type": "number",
"format": "int32" "format": "float"
}, },
"folderPath": { "folderPath": {
"type": "string", "type": "string",
@ -21417,18 +21594,18 @@
"nullable": true "nullable": true
}, },
"comicsTime": { "comicsTime": {
"type": "integer", "type": "number",
"description": "Amount of time read on Comic libraries", "description": "Amount of time read on Comic libraries",
"format": "int64" "format": "float"
}, },
"booksTime": { "booksTime": {
"type": "integer", "type": "number",
"description": "Amount of time read on", "description": "Amount of time read on",
"format": "int64" "format": "float"
}, },
"mangaTime": { "mangaTime": {
"type": "integer", "type": "number",
"format": "int64" "format": "float"
} }
}, },
"additionalProperties": false "additionalProperties": false
@ -21778,7 +21955,6 @@
"folderWatching", "folderWatching",
"id", "id",
"includeInDashboard", "includeInDashboard",
"includeInRecommended",
"includeInSearch", "includeInSearch",
"manageCollections", "manageCollections",
"manageReadingLists", "manageReadingLists",
@ -21819,9 +21995,6 @@
"includeInDashboard": { "includeInDashboard": {
"type": "boolean" "type": "boolean"
}, },
"includeInRecommended": {
"type": "boolean"
},
"includeInSearch": { "includeInSearch": {
"type": "boolean" "type": "boolean"
}, },
@ -22957,8 +23130,8 @@
"format": "int32" "format": "int32"
}, },
"avgHoursToRead": { "avgHoursToRead": {
"type": "integer", "type": "number",
"format": "int32" "format": "float"
}, },
"chapters": { "chapters": {
"type": "array", "type": "array",
@ -23048,8 +23221,8 @@
"format": "int32" "format": "int32"
}, },
"avgHoursToRead": { "avgHoursToRead": {
"type": "integer", "type": "number",
"format": "int32" "format": "float"
}, },
"wordCount": { "wordCount": {
"type": "integer", "type": "integer",