mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
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:
parent
894b49bb76
commit
857e419e4e
2
.github/workflows/canary-workflow.yml
vendored
2
.github/workflows/canary-workflow.yml
vendored
@ -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
|
||||||
|
|
||||||
|
2
.github/workflows/develop-workflow.yml
vendored
2
.github/workflows/develop-workflow.yml
vendored
@ -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
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
|
@ -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>
|
||||||
|
@ -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);
|
||||||
|
@ -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 --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>
|
||||||
|
@ -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
12
API/DTOs/BulkActionDto.cs
Normal 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; }
|
||||||
|
}
|
@ -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>
|
||||||
|
14
API/DTOs/CopySettingsFromLibraryDto.cs
Normal file
14
API/DTOs/CopySettingsFromLibraryDto.cs
Normal 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; }
|
||||||
|
|
||||||
|
}
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
@ -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; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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; }
|
||||||
|
@ -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>
|
||||||
|
3145
API/Data/Migrations/20240917180034_AvgReadingTimeFloat.Designer.cs
generated
Normal file
3145
API/Data/Migrations/20240917180034_AvgReadingTimeFloat.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
66
API/Data/Migrations/20240917180034_AvgReadingTimeFloat.cs
Normal file
66
API/Data/Migrations/20240917180034_AvgReadingTimeFloat.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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");
|
||||||
|
@ -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>
|
||||||
|
@ -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; }
|
||||||
}
|
}
|
||||||
|
@ -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!;
|
||||||
|
@ -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
|
||||||
|
@ -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:
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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]))
|
||||||
|
@ -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);
|
||||||
|
@ -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;
|
||||||
|
@ -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>
|
||||||
|
@ -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",
|
||||||
|
@ -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 = [
|
||||||
{
|
{
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
@ -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>>();
|
||||||
|
|
||||||
|
@ -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>
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
@ -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 => {
|
||||||
|
@ -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 {
|
||||||
|
@ -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;
|
||||||
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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> = [];
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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 {
|
||||||
|
@ -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>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@ -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
@ -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
80
UI/Web/sync-locales.js
Normal 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();
|
223
openapi.json
223
openapi.json
@ -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",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user