mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-06-03 05:34:21 -04:00
Misc bunch of changes (#2815)
This commit is contained in:
parent
18792b7b56
commit
63c9bff32e
@ -7,6 +7,7 @@ using System.Threading.Tasks;
|
|||||||
using API.Data;
|
using API.Data;
|
||||||
using API.Data.Repositories;
|
using API.Data.Repositories;
|
||||||
using API.DTOs;
|
using API.DTOs;
|
||||||
|
using API.DTOs.Progress;
|
||||||
using API.DTOs.Reader;
|
using API.DTOs.Reader;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using API.Data.ManualMigrations;
|
using API.Data.ManualMigrations;
|
||||||
|
using API.DTOs.Progress;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
@ -29,4 +30,15 @@ public class AdminController : BaseApiController
|
|||||||
var users = await _userManager.GetUsersInRoleAsync("Admin");
|
var users = await _userManager.GetUsersInRoleAsync("Admin");
|
||||||
return users.Count > 0;
|
return users.Count > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set the progress information for a particular user
|
||||||
|
/// </summary>
|
||||||
|
/// <returns></returns>
|
||||||
|
[Authorize("RequireAdminRole")]
|
||||||
|
[HttpPost("update-chapter-progress")]
|
||||||
|
public async Task<ActionResult<bool>> UpdateChapterProgress(UpdateUserProgressDto dto)
|
||||||
|
{
|
||||||
|
return Ok(await Task.FromResult(false));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,10 +3,12 @@ using System.Collections.Generic;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using API.Data;
|
using API.Data;
|
||||||
using API.Data.Repositories;
|
using API.Data.Repositories;
|
||||||
|
using API.DTOs.Collection;
|
||||||
using API.DTOs.CollectionTags;
|
using API.DTOs.CollectionTags;
|
||||||
using API.Entities.Metadata;
|
using API.Entities.Metadata;
|
||||||
using API.Extensions;
|
using API.Extensions;
|
||||||
using API.Services;
|
using API.Services;
|
||||||
|
using API.Services.Plus;
|
||||||
using Kavita.Common;
|
using Kavita.Common;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@ -23,14 +25,16 @@ public class CollectionController : BaseApiController
|
|||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
private readonly ICollectionTagService _collectionService;
|
private readonly ICollectionTagService _collectionService;
|
||||||
private readonly ILocalizationService _localizationService;
|
private readonly ILocalizationService _localizationService;
|
||||||
|
private readonly IExternalMetadataService _externalMetadataService;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public CollectionController(IUnitOfWork unitOfWork, ICollectionTagService collectionService,
|
public CollectionController(IUnitOfWork unitOfWork, ICollectionTagService collectionService,
|
||||||
ILocalizationService localizationService)
|
ILocalizationService localizationService, IExternalMetadataService externalMetadataService)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_collectionService = collectionService;
|
_collectionService = collectionService;
|
||||||
_localizationService = localizationService;
|
_localizationService = localizationService;
|
||||||
|
_externalMetadataService = externalMetadataService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -168,4 +172,15 @@ public class CollectionController : BaseApiController
|
|||||||
|
|
||||||
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error"));
|
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// For the authenticated user, if they have an active Kavita+ subscription and a MAL username on record,
|
||||||
|
/// fetch their Mal interest stacks (including restacks)
|
||||||
|
/// </summary>
|
||||||
|
/// <returns></returns>
|
||||||
|
[HttpGet("mal-stacks")]
|
||||||
|
public async Task<ActionResult<IList<MalStackDto>>> GetMalStacksForUser()
|
||||||
|
{
|
||||||
|
return Ok(await _externalMetadataService.GetStacksForUser(User.GetUserId()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -13,6 +13,7 @@ using API.DTOs.CollectionTags;
|
|||||||
using API.DTOs.Filtering;
|
using API.DTOs.Filtering;
|
||||||
using API.DTOs.Filtering.v2;
|
using API.DTOs.Filtering.v2;
|
||||||
using API.DTOs.OPDS;
|
using API.DTOs.OPDS;
|
||||||
|
using API.DTOs.Progress;
|
||||||
using API.DTOs.Search;
|
using API.DTOs.Search;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using API.Data;
|
using API.Data;
|
||||||
using API.DTOs;
|
using API.DTOs;
|
||||||
|
using API.DTOs.Progress;
|
||||||
using API.Services;
|
using API.Services;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
@ -7,8 +7,8 @@ using API.Constants;
|
|||||||
using API.Data;
|
using API.Data;
|
||||||
using API.Data.Repositories;
|
using API.Data.Repositories;
|
||||||
using API.DTOs;
|
using API.DTOs;
|
||||||
using API.DTOs.Filtering;
|
|
||||||
using API.DTOs.Filtering.v2;
|
using API.DTOs.Filtering.v2;
|
||||||
|
using API.DTOs.Progress;
|
||||||
using API.DTOs.Reader;
|
using API.DTOs.Reader;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
@ -880,4 +880,21 @@ public class ReaderController : BaseApiController
|
|||||||
await _unitOfWork.CommitAsync();
|
await _unitOfWork.CommitAsync();
|
||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get all progress events for a given chapter
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="chapterId"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
[HttpGet("all-chapter-progress")]
|
||||||
|
public async Task<ActionResult<IEnumerable<FullProgressDto>>> GetProgressForChapter(int chapterId)
|
||||||
|
{
|
||||||
|
if (User.IsInRole(PolicyConstants.AdminRole))
|
||||||
|
{
|
||||||
|
return Ok(await _unitOfWork.AppUserProgressRepository.GetUserProgressForChapter(chapterId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(await _unitOfWork.AppUserProgressRepository.GetUserProgressForChapter(chapterId, User.GetUserId()));
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -52,6 +52,23 @@ public class ScrobblingController : BaseApiController
|
|||||||
return Ok(user.AniListAccessToken);
|
return Ok(user.AniListAccessToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get the current user's MAL token & username
|
||||||
|
/// </summary>
|
||||||
|
/// <returns></returns>
|
||||||
|
[HttpGet("mal-token")]
|
||||||
|
public async Task<ActionResult<MalUserInfoDto>> GetMalToken()
|
||||||
|
{
|
||||||
|
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||||
|
if (user == null) return Unauthorized();
|
||||||
|
|
||||||
|
return Ok(new MalUserInfoDto()
|
||||||
|
{
|
||||||
|
Username = user.MalUserName,
|
||||||
|
AccessToken = user.MalAccessToken
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Update the current user's AniList token
|
/// Update the current user's AniList token
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -76,6 +93,26 @@ public class ScrobblingController : BaseApiController
|
|||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Update the current user's MAL token (Client ID) and Username
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dto"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
[HttpPost("update-mal-token")]
|
||||||
|
public async Task<ActionResult> UpdateMalToken(MalUserInfoDto dto)
|
||||||
|
{
|
||||||
|
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||||
|
if (user == null) return Unauthorized();
|
||||||
|
|
||||||
|
user.MalAccessToken = dto.AccessToken;
|
||||||
|
user.MalUserName = dto.Username;
|
||||||
|
|
||||||
|
_unitOfWork.UserRepository.Update(user);
|
||||||
|
await _unitOfWork.CommitAsync();
|
||||||
|
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks if the current Scrobbling token for the given Provider has expired for the current user
|
/// Checks if the current Scrobbling token for the given Provider has expired for the current user
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -457,6 +457,7 @@ public class SettingsController : BaseApiController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// All values allowed for Task Scheduling APIs. A custom cron job is not included. Disabled is not applicable for Cleanup.
|
/// All values allowed for Task Scheduling APIs. A custom cron job is not included. Disabled is not applicable for Cleanup.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -8,6 +8,7 @@ using API.Entities;
|
|||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
using API.Extensions;
|
using API.Extensions;
|
||||||
using API.Services;
|
using API.Services;
|
||||||
|
using API.Services.Plus;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@ -22,14 +23,16 @@ public class StatsController : BaseApiController
|
|||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
private readonly UserManager<AppUser> _userManager;
|
private readonly UserManager<AppUser> _userManager;
|
||||||
private readonly ILocalizationService _localizationService;
|
private readonly ILocalizationService _localizationService;
|
||||||
|
private readonly ILicenseService _licenseService;
|
||||||
|
|
||||||
public StatsController(IStatisticService statService, IUnitOfWork unitOfWork,
|
public StatsController(IStatisticService statService, IUnitOfWork unitOfWork,
|
||||||
UserManager<AppUser> userManager, ILocalizationService localizationService)
|
UserManager<AppUser> userManager, ILocalizationService localizationService, ILicenseService licenseService)
|
||||||
{
|
{
|
||||||
_statService = statService;
|
_statService = statService;
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_localizationService = localizationService;
|
_localizationService = localizationService;
|
||||||
|
_licenseService = licenseService;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("user/{userId}/read")]
|
[HttpGet("user/{userId}/read")]
|
||||||
@ -181,6 +184,18 @@ public class StatsController : BaseApiController
|
|||||||
return Ok(_statService.GetWordsReadCountByYear(userId));
|
return Ok(_statService.GetWordsReadCountByYear(userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns for Kavita+ the number of Series that have been processed, errored, and not processed
|
||||||
|
/// </summary>
|
||||||
|
/// <returns></returns>
|
||||||
|
[Authorize("RequireAdminRole")]
|
||||||
|
[HttpGet("kavitaplus-metadata-breakdown")]
|
||||||
|
[ResponseCache(CacheProfileName = "Statistics")]
|
||||||
|
public async Task<ActionResult<IEnumerable<StatCount<int>>>> GetKavitaPlusMetadataBreakdown()
|
||||||
|
{
|
||||||
|
if (!await _licenseService.HasActiveLicense())
|
||||||
|
return BadRequest("This data is not available for non-Kavita+ servers");
|
||||||
|
return Ok(await _statService.GetKavitaPlusMetadataBreakdown());
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
19
API/DTOs/Collection/MalStackDto.cs
Normal file
19
API/DTOs/Collection/MalStackDto.cs
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
namespace API.DTOs.Collection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents an Interest Stack from MAL
|
||||||
|
/// </summary>
|
||||||
|
public class MalStackDto
|
||||||
|
{
|
||||||
|
public required string Title { get; set; }
|
||||||
|
public required long StackId { get; set; }
|
||||||
|
public required string Url { get; set; }
|
||||||
|
public required string? Author { get; set; }
|
||||||
|
public required int SeriesCount { get; set; }
|
||||||
|
public required int RestackCount { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// If an existing collection exists within Kavita
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>This is filled out from Kavita and not Kavita+</remarks>
|
||||||
|
public int ExistingId { get; set; }
|
||||||
|
}
|
19
API/DTOs/Progress/FullProgressDto.cs
Normal file
19
API/DTOs/Progress/FullProgressDto.cs
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace API.DTOs.Progress;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A full progress Record from the DB (not all data, only what's needed for API)
|
||||||
|
/// </summary>
|
||||||
|
public class FullProgressDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int ChapterId { get; set; }
|
||||||
|
public int PagesRead { get; set; }
|
||||||
|
public DateTime LastModified { get; set; }
|
||||||
|
public DateTime LastModifiedUtc { get; set; }
|
||||||
|
public DateTime Created { get; set; }
|
||||||
|
public DateTime CreatedUtc { get; set; }
|
||||||
|
public int AppUserId { get; set; }
|
||||||
|
public string UserName { get; set; }
|
||||||
|
}
|
@ -1,7 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
namespace API.DTOs;
|
namespace API.DTOs.Progress;
|
||||||
#nullable enable
|
#nullable enable
|
||||||
|
|
||||||
public class ProgressDto
|
public class ProgressDto
|
11
API/DTOs/Progress/UpdateUserProgressDto.cs
Normal file
11
API/DTOs/Progress/UpdateUserProgressDto.cs
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace API.DTOs.Progress;
|
||||||
|
#nullable enable
|
||||||
|
|
||||||
|
public class UpdateUserProgressDto
|
||||||
|
{
|
||||||
|
public int PageNum { get; set; }
|
||||||
|
public DateTime LastModifiedUtc { get; set; }
|
||||||
|
public DateTime CreatedUtc { get; set; }
|
||||||
|
}
|
13
API/DTOs/Scrobbling/MalUserInfoDto.cs
Normal file
13
API/DTOs/Scrobbling/MalUserInfoDto.cs
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
namespace API.DTOs.Scrobbling;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Information about a User's MAL connection
|
||||||
|
/// </summary>
|
||||||
|
public class MalUserInfoDto
|
||||||
|
{
|
||||||
|
public required string Username { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// This is actually the Client Id
|
||||||
|
/// </summary>
|
||||||
|
public required string AccessToken { get; set; }
|
||||||
|
}
|
17
API/DTOs/Statistics/KavitaPlusMetadataBreakdownDto.cs
Normal file
17
API/DTOs/Statistics/KavitaPlusMetadataBreakdownDto.cs
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
namespace API.DTOs.Statistics;
|
||||||
|
|
||||||
|
public class KavitaPlusMetadataBreakdownDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Total amount of Series
|
||||||
|
/// </summary>
|
||||||
|
public int TotalSeries { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Series on the Blacklist (errored or bad match)
|
||||||
|
/// </summary>
|
||||||
|
public int ErroredSeries { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Completed so far
|
||||||
|
/// </summary>
|
||||||
|
public int SeriesCompleted { get; set; }
|
||||||
|
}
|
2904
API/Data/Migrations/20240321173812_UserMalToken.Designer.cs
generated
Normal file
2904
API/Data/Migrations/20240321173812_UserMalToken.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
API/Data/Migrations/20240321173812_UserMalToken.cs
Normal file
38
API/Data/Migrations/20240321173812_UserMalToken.cs
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace API.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class UserMalToken : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "MalAccessToken",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "MalUserName",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "MalAccessToken",
|
||||||
|
table: "AspNetUsers");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "MalUserName",
|
||||||
|
table: "AspNetUsers");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -97,6 +97,12 @@ namespace API.Data.Migrations
|
|||||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("MalAccessToken")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("MalUserName")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("NormalizedEmail")
|
b.Property<string>("NormalizedEmail")
|
||||||
.HasMaxLength(256)
|
.HasMaxLength(256)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
@ -5,8 +5,10 @@ using System.Text;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using API.Data.ManualMigrations;
|
using API.Data.ManualMigrations;
|
||||||
using API.DTOs;
|
using API.DTOs;
|
||||||
|
using API.DTOs.Progress;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
|
using API.Extensions.QueryExtensions;
|
||||||
using API.Services.Tasks.Scanner.Parser;
|
using API.Services.Tasks.Scanner.Parser;
|
||||||
using AutoMapper;
|
using AutoMapper;
|
||||||
using AutoMapper.QueryableExtensions;
|
using AutoMapper.QueryableExtensions;
|
||||||
@ -36,6 +38,7 @@ public interface IAppUserProgressRepository
|
|||||||
Task<DateTime?> GetLatestProgressForSeries(int seriesId, int userId);
|
Task<DateTime?> GetLatestProgressForSeries(int seriesId, int userId);
|
||||||
Task<DateTime?> GetFirstProgressForSeries(int seriesId, int userId);
|
Task<DateTime?> GetFirstProgressForSeries(int seriesId, int userId);
|
||||||
Task UpdateAllProgressThatAreMoreThanChapterPages();
|
Task UpdateAllProgressThatAreMoreThanChapterPages();
|
||||||
|
Task<IList<FullProgressDto>> GetUserProgressForChapter(int chapterId, int userId = 0);
|
||||||
}
|
}
|
||||||
#nullable disable
|
#nullable disable
|
||||||
public class AppUserProgressRepository : IAppUserProgressRepository
|
public class AppUserProgressRepository : IAppUserProgressRepository
|
||||||
@ -233,6 +236,33 @@ public class AppUserProgressRepository : IAppUserProgressRepository
|
|||||||
await _context.Database.ExecuteSqlRawAsync(batchSql);
|
await _context.Database.ExecuteSqlRawAsync(batchSql);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="chapterId"></param>
|
||||||
|
/// <param name="userId">If 0, will pull all records</param>
|
||||||
|
/// <returns></returns>
|
||||||
|
public async Task<IList<FullProgressDto>> GetUserProgressForChapter(int chapterId, int userId = 0)
|
||||||
|
{
|
||||||
|
return await _context.AppUserProgresses
|
||||||
|
.WhereIf(userId > 0, p => p.AppUserId == userId)
|
||||||
|
.Where(p => p.ChapterId == chapterId)
|
||||||
|
.Include(p => p.AppUser)
|
||||||
|
.Select(p => new FullProgressDto()
|
||||||
|
{
|
||||||
|
AppUserId = p.AppUserId,
|
||||||
|
ChapterId = p.ChapterId,
|
||||||
|
PagesRead = p.PagesRead,
|
||||||
|
Id = p.Id,
|
||||||
|
Created = p.Created,
|
||||||
|
CreatedUtc = p.CreatedUtc,
|
||||||
|
LastModified = p.LastModified,
|
||||||
|
LastModifiedUtc = p.LastModifiedUtc,
|
||||||
|
UserName = p.AppUser.UserName
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
#nullable enable
|
#nullable enable
|
||||||
public async Task<AppUserProgress?> GetUserProgressAsync(int chapterId, int userId)
|
public async Task<AppUserProgress?> GetUserProgressAsync(int chapterId, int userId)
|
||||||
{
|
{
|
||||||
|
@ -63,6 +63,15 @@ public class AppUser : IdentityUser<int>, IHasConcurrencyToken
|
|||||||
/// <remarks>Requires Kavita+ Subscription</remarks>
|
/// <remarks>Requires Kavita+ Subscription</remarks>
|
||||||
public string? AniListAccessToken { get; set; }
|
public string? AniListAccessToken { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Username of the MAL user
|
||||||
|
/// </summary>
|
||||||
|
public string? MalUserName { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// The Client ID for the user's MAL account. User should create a client on MAL for this.
|
||||||
|
/// </summary>
|
||||||
|
public string? MalAccessToken { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A list of Series the user doesn't want scrobbling for
|
/// A list of Series the user doesn't want scrobbling for
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
using System.Collections.Generic;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using API.Entities.Metadata;
|
using API.Entities.Metadata;
|
||||||
|
using API.Services.Plus;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace API.Entities;
|
namespace API.Entities;
|
||||||
@ -41,6 +43,21 @@ public class CollectionTag
|
|||||||
|
|
||||||
public ICollection<SeriesMetadata> SeriesMetadatas { get; set; } = null!;
|
public ICollection<SeriesMetadata> SeriesMetadatas { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Is this Collection tag managed by another system, like Kavita+
|
||||||
|
/// </summary>
|
||||||
|
//public bool IsManaged { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The last time this Collection was Synchronized. Only applicable for Managed Tags.
|
||||||
|
/// </summary>
|
||||||
|
//public DateTime LastSynchronized { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Who created this Collection (Kavita, or external services)
|
||||||
|
/// </summary>
|
||||||
|
//public ScrobbleProvider Provider { get; set; } = ScrobbleProvider.Kavita;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Not Used due to not using concurrency update
|
/// Not Used due to not using concurrency update
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -10,6 +10,7 @@ using API.DTOs.Filtering;
|
|||||||
using API.DTOs.Filtering.v2;
|
using API.DTOs.Filtering.v2;
|
||||||
using API.DTOs.MediaErrors;
|
using API.DTOs.MediaErrors;
|
||||||
using API.DTOs.Metadata;
|
using API.DTOs.Metadata;
|
||||||
|
using API.DTOs.Progress;
|
||||||
using API.DTOs.Reader;
|
using API.DTOs.Reader;
|
||||||
using API.DTOs.ReadingLists;
|
using API.DTOs.ReadingLists;
|
||||||
using API.DTOs.Recommendation;
|
using API.DTOs.Recommendation;
|
||||||
|
@ -6,6 +6,7 @@ using System.Threading.Tasks;
|
|||||||
using API.Data;
|
using API.Data;
|
||||||
using API.Data.Repositories;
|
using API.Data.Repositories;
|
||||||
using API.DTOs;
|
using API.DTOs;
|
||||||
|
using API.DTOs.Collection;
|
||||||
using API.DTOs.Recommendation;
|
using API.DTOs.Recommendation;
|
||||||
using API.DTOs.Scrobbling;
|
using API.DTOs.Scrobbling;
|
||||||
using API.DTOs.SeriesDetail;
|
using API.DTOs.SeriesDetail;
|
||||||
@ -61,6 +62,8 @@ public interface IExternalMetadataService
|
|||||||
/// <param name="libraryType"></param>
|
/// <param name="libraryType"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
Task GetNewSeriesData(int seriesId, LibraryType libraryType);
|
Task GetNewSeriesData(int seriesId, LibraryType libraryType);
|
||||||
|
|
||||||
|
Task<IList<MalStackDto>> GetStacksForUser(int userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ExternalMetadataService : IExternalMetadataService
|
public class ExternalMetadataService : IExternalMetadataService
|
||||||
@ -70,7 +73,8 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
private readonly IMapper _mapper;
|
private readonly IMapper _mapper;
|
||||||
private readonly ILicenseService _licenseService;
|
private readonly ILicenseService _licenseService;
|
||||||
private readonly TimeSpan _externalSeriesMetadataCache = TimeSpan.FromDays(30);
|
private readonly TimeSpan _externalSeriesMetadataCache = TimeSpan.FromDays(30);
|
||||||
public static readonly ImmutableArray<LibraryType> NonEligibleLibraryTypes = ImmutableArray.Create<LibraryType>(LibraryType.Comic, LibraryType.Book, LibraryType.Image, LibraryType.ComicVine);
|
public static readonly ImmutableArray<LibraryType> NonEligibleLibraryTypes = ImmutableArray.Create
|
||||||
|
(LibraryType.Comic, LibraryType.Book, LibraryType.Image, LibraryType.ComicVine);
|
||||||
private readonly SeriesDetailPlusDto _defaultReturn = new()
|
private readonly SeriesDetailPlusDto _defaultReturn = new()
|
||||||
{
|
{
|
||||||
Recommendations = null,
|
Recommendations = null,
|
||||||
@ -137,12 +141,15 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
public async Task ForceKavitaPlusRefresh(int seriesId)
|
public async Task ForceKavitaPlusRefresh(int seriesId)
|
||||||
{
|
{
|
||||||
if (!await _licenseService.HasActiveLicense()) return;
|
if (!await _licenseService.HasActiveLicense()) return;
|
||||||
// Remove from Blacklist if applicable
|
|
||||||
var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeBySeriesIdAsync(seriesId);
|
var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeBySeriesIdAsync(seriesId);
|
||||||
if (!IsPlusEligible(libraryType)) return;
|
if (!IsPlusEligible(libraryType)) return;
|
||||||
|
|
||||||
|
// Remove from Blacklist if applicable
|
||||||
await _unitOfWork.ExternalSeriesMetadataRepository.RemoveFromBlacklist(seriesId);
|
await _unitOfWork.ExternalSeriesMetadataRepository.RemoveFromBlacklist(seriesId);
|
||||||
|
|
||||||
var metadata = await _unitOfWork.ExternalSeriesMetadataRepository.GetExternalSeriesMetadata(seriesId);
|
var metadata = await _unitOfWork.ExternalSeriesMetadataRepository.GetExternalSeriesMetadata(seriesId);
|
||||||
if (metadata == null) return;
|
if (metadata == null) return;
|
||||||
|
|
||||||
metadata.ValidUntilUtc = DateTime.UtcNow.Subtract(_externalSeriesMetadataCache);
|
metadata.ValidUntilUtc = DateTime.UtcNow.Subtract(_externalSeriesMetadataCache);
|
||||||
await _unitOfWork.CommitAsync();
|
await _unitOfWork.CommitAsync();
|
||||||
}
|
}
|
||||||
@ -170,10 +177,50 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
// Prefetch SeriesDetail data
|
// Prefetch SeriesDetail data
|
||||||
await GetSeriesDetailPlus(seriesId, libraryType);
|
await GetSeriesDetailPlus(seriesId, libraryType);
|
||||||
|
|
||||||
// TODO: Fetch Series Metadata
|
// TODO: Fetch Series Metadata (Summary, etc)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IList<MalStackDto>> GetStacksForUser(int userId)
|
||||||
|
{
|
||||||
|
if (!await _licenseService.HasActiveLicense()) return ArraySegment<MalStackDto>.Empty;
|
||||||
|
|
||||||
|
// See if this user has Mal account on record
|
||||||
|
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
|
||||||
|
if (user == null || string.IsNullOrEmpty(user.MalUserName) || string.IsNullOrEmpty(user.MalAccessToken))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("User is attempting to fetch MAL Stacks, but missing information on their account");
|
||||||
|
return ArraySegment<MalStackDto>.Empty;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Fetching Kavita+ for MAL Stacks for user {UserName}", user.MalUserName);
|
||||||
|
|
||||||
|
var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value;
|
||||||
|
var result = await ($"{Configuration.KavitaPlusApiUrl}/api/metadata/v2/stacks?username={user.MalUserName}")
|
||||||
|
.WithHeader("Accept", "application/json")
|
||||||
|
.WithHeader("User-Agent", "Kavita")
|
||||||
|
.WithHeader("x-license-key", license)
|
||||||
|
.WithHeader("x-installId", HashUtil.ServerToken())
|
||||||
|
.WithHeader("x-kavita-version", BuildInfo.Version)
|
||||||
|
.WithHeader("Content-Type", "application/json")
|
||||||
|
.WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs))
|
||||||
|
.GetJsonAsync<IList<MalStackDto>>();
|
||||||
|
|
||||||
|
if (result == null)
|
||||||
|
{
|
||||||
|
return ArraySegment<MalStackDto>.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Fetching Kavita+ for MAL Stacks for user {UserName} failed", user.MalUserName);
|
||||||
|
return ArraySegment<MalStackDto>.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retrieves Metadata about a Recommended External Series
|
/// Retrieves Metadata about a Recommended External Series
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -94,6 +94,7 @@ public class ScrobblingService : IScrobblingService
|
|||||||
ScrobbleProvider.AniList
|
ScrobbleProvider.AniList
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
private const string UnknownSeriesErrorMessage = "Series cannot be matched for Scrobbling";
|
private const string UnknownSeriesErrorMessage = "Series cannot be matched for Scrobbling";
|
||||||
private const string AccessTokenErrorMessage = "Access Token needs to be rotated to continue scrobbling";
|
private const string AccessTokenErrorMessage = "Access Token needs to be rotated to continue scrobbling";
|
||||||
|
|
||||||
@ -332,15 +333,7 @@ public class ScrobblingService : IScrobblingService
|
|||||||
await _unitOfWork.AppUserProgressRepository.GetHighestFullyReadChapterForSeries(seriesId, userId),
|
await _unitOfWork.AppUserProgressRepository.GetHighestFullyReadChapterForSeries(seriesId, userId),
|
||||||
Format = LibraryTypeHelper.GetFormat(series.Library.Type),
|
Format = LibraryTypeHelper.GetFormat(series.Library.Type),
|
||||||
};
|
};
|
||||||
// NOTE: Not sure how to handle scrobbling specials or handling sending loose leaf volumes
|
|
||||||
if (evt.VolumeNumber is Parser.SpecialVolumeNumber)
|
|
||||||
{
|
|
||||||
evt.VolumeNumber = 0;
|
|
||||||
}
|
|
||||||
if (evt.VolumeNumber is Parser.DefaultChapterNumber)
|
|
||||||
{
|
|
||||||
evt.VolumeNumber = 0;
|
|
||||||
}
|
|
||||||
_unitOfWork.ScrobbleRepository.Attach(evt);
|
_unitOfWork.ScrobbleRepository.Attach(evt);
|
||||||
await _unitOfWork.CommitAsync();
|
await _unitOfWork.CommitAsync();
|
||||||
_logger.LogDebug("Added Scrobbling Read update on {SeriesName} with Userid {UserId} ", series.Name, userId);
|
_logger.LogDebug("Added Scrobbling Read update on {SeriesName} with Userid {UserId} ", series.Name, userId);
|
||||||
@ -826,6 +819,20 @@ public class ScrobblingService : IScrobblingService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var data = await createEvent(evt);
|
var data = await createEvent(evt);
|
||||||
|
// We need to handle the encoding and changing it to the old one until we can update the API layer to handle these
|
||||||
|
// which could happen in v0.8.3
|
||||||
|
if (data.VolumeNumber is Parser.SpecialVolumeNumber)
|
||||||
|
{
|
||||||
|
data.VolumeNumber = 0;
|
||||||
|
}
|
||||||
|
if (data.VolumeNumber is Parser.DefaultChapterNumber)
|
||||||
|
{
|
||||||
|
data.VolumeNumber = 0;
|
||||||
|
}
|
||||||
|
if (data.ChapterNumber is Parser.DefaultChapterNumber)
|
||||||
|
{
|
||||||
|
data.ChapterNumber = 0;
|
||||||
|
}
|
||||||
userRateLimits[evt.AppUserId] = await PostScrobbleUpdate(data, license.Value, evt);
|
userRateLimits[evt.AppUserId] = await PostScrobbleUpdate(data, license.Value, evt);
|
||||||
evt.IsProcessed = true;
|
evt.IsProcessed = true;
|
||||||
evt.ProcessDateUtc = DateTime.UtcNow;
|
evt.ProcessDateUtc = DateTime.UtcNow;
|
||||||
@ -870,6 +877,7 @@ public class ScrobblingService : IScrobblingService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private static bool DoesUserHaveProviderAndValid(ScrobbleEvent readEvent)
|
private static bool DoesUserHaveProviderAndValid(ScrobbleEvent readEvent)
|
||||||
{
|
{
|
||||||
var userProviders = GetUserProviders(readEvent.AppUser);
|
var userProviders = GetUserProviders(readEvent.AppUser);
|
||||||
|
@ -9,6 +9,7 @@ using API.Comparators;
|
|||||||
using API.Data;
|
using API.Data;
|
||||||
using API.Data.Repositories;
|
using API.Data.Repositories;
|
||||||
using API.DTOs;
|
using API.DTOs;
|
||||||
|
using API.DTOs.Progress;
|
||||||
using API.DTOs.Reader;
|
using API.DTOs.Reader;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
|
@ -9,6 +9,7 @@ using API.Entities;
|
|||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
using API.Extensions;
|
using API.Extensions;
|
||||||
using API.Extensions.QueryExtensions;
|
using API.Extensions.QueryExtensions;
|
||||||
|
using API.Services.Plus;
|
||||||
using API.Services.Tasks.Scanner.Parser;
|
using API.Services.Tasks.Scanner.Parser;
|
||||||
using AutoMapper;
|
using AutoMapper;
|
||||||
using AutoMapper.QueryableExtensions;
|
using AutoMapper.QueryableExtensions;
|
||||||
@ -33,6 +34,7 @@ public interface IStatisticService
|
|||||||
IEnumerable<StatCount<int>> GetWordsReadCountByYear(int userId = 0);
|
IEnumerable<StatCount<int>> GetWordsReadCountByYear(int userId = 0);
|
||||||
Task UpdateServerStatistics();
|
Task UpdateServerStatistics();
|
||||||
Task<long> TimeSpentReadingForUsersAsync(IList<int> userIds, IList<int> libraryIds);
|
Task<long> TimeSpentReadingForUsersAsync(IList<int> userIds, IList<int> libraryIds);
|
||||||
|
Task<KavitaPlusMetadataBreakdownDto> GetKavitaPlusMetadataBreakdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -531,6 +533,29 @@ public class StatisticService : IStatisticService
|
|||||||
p.chapter.AvgHoursToRead * (p.progress.PagesRead / (1.0f * p.chapter.Pages))));
|
p.chapter.AvgHoursToRead * (p.progress.PagesRead / (1.0f * p.chapter.Pages))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<KavitaPlusMetadataBreakdownDto> GetKavitaPlusMetadataBreakdown()
|
||||||
|
{
|
||||||
|
// We need to count number of Series that have an external series record
|
||||||
|
// Then count how many series are blacklisted
|
||||||
|
// Then get total count of series that are Kavita+ eligible
|
||||||
|
var plusLibraries = await _context.Library
|
||||||
|
.Where(l => !ExternalMetadataService.NonEligibleLibraryTypes.Contains(l.Type))
|
||||||
|
.Select(l => l.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var countOfBlacklisted = await _context.SeriesBlacklist.CountAsync();
|
||||||
|
var totalSeries = await _context.Series.Where(s => plusLibraries.Contains(s.LibraryId)).CountAsync();
|
||||||
|
var seriesWithMetadata = await _context.ExternalSeriesMetadata.CountAsync();
|
||||||
|
|
||||||
|
return new KavitaPlusMetadataBreakdownDto()
|
||||||
|
{
|
||||||
|
TotalSeries = totalSeries,
|
||||||
|
ErroredSeries = countOfBlacklisted,
|
||||||
|
SeriesCompleted = seriesWithMetadata
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IEnumerable<TopReadDto>> GetTopUsers(int days)
|
public async Task<IEnumerable<TopReadDto>> GetTopUsers(int days)
|
||||||
{
|
{
|
||||||
var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList();
|
var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList();
|
||||||
|
@ -9,6 +9,7 @@ using API.Entities.Enums;
|
|||||||
using API.Extensions;
|
using API.Extensions;
|
||||||
using API.Services.Tasks.Scanner.Parser;
|
using API.Services.Tasks.Scanner.Parser;
|
||||||
using API.SignalR;
|
using API.SignalR;
|
||||||
|
using ExCSS;
|
||||||
using Kavita.Common.Helpers;
|
using Kavita.Common.Helpers;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
@ -448,22 +449,42 @@ public class ParseScannedFiles
|
|||||||
var infos = scannedSeries[series].Where(info => info.Volumes == volume.Key).ToList();
|
var infos = scannedSeries[series].Where(info => info.Volumes == volume.Key).ToList();
|
||||||
IList<ParserInfo> chapters;
|
IList<ParserInfo> chapters;
|
||||||
var specialTreatment = infos.TrueForAll(info => info.IsSpecial);
|
var specialTreatment = infos.TrueForAll(info => info.IsSpecial);
|
||||||
|
var hasAnySpMarker = infos.Exists(info => info.SpecialIndex > 0);
|
||||||
|
var counter = 0f;
|
||||||
|
|
||||||
if (specialTreatment)
|
if (specialTreatment && hasAnySpMarker)
|
||||||
{
|
{
|
||||||
chapters = infos
|
chapters = infos
|
||||||
.OrderBy(info => info.SpecialIndex)
|
.OrderBy(info => info.SpecialIndex)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var chapter in chapters)
|
||||||
|
{
|
||||||
|
chapter.IssueOrder = counter;
|
||||||
|
counter++;
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
else
|
|
||||||
|
|
||||||
|
chapters = infos
|
||||||
|
.OrderByNatural(info => info.Chapters)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
|
||||||
|
// If everything is a special but we don't have any SpecialIndex, then order naturally and use 0, 1, 2
|
||||||
|
if (specialTreatment)
|
||||||
{
|
{
|
||||||
chapters = infos
|
foreach (var chapter in chapters)
|
||||||
.OrderByNatural(info => info.Chapters)
|
{
|
||||||
.ToList();
|
chapter.IssueOrder = counter;
|
||||||
|
counter++;
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
var counter = 0f;
|
counter = 0f;
|
||||||
var prevIssue = string.Empty;
|
var prevIssue = string.Empty;
|
||||||
foreach (var chapter in chapters)
|
foreach (var chapter in chapters)
|
||||||
{
|
{
|
||||||
|
@ -95,6 +95,11 @@ public class BasicParser(IDirectoryService directoryService, IDefaultParser imag
|
|||||||
// Patch in other information from ComicInfo
|
// Patch in other information from ComicInfo
|
||||||
UpdateFromComicInfo(ret);
|
UpdateFromComicInfo(ret);
|
||||||
|
|
||||||
|
if (ret.Volumes == Parser.LooseLeafVolume && ret.Chapters == Parser.DefaultChapter)
|
||||||
|
{
|
||||||
|
ret.IsSpecial = true;
|
||||||
|
}
|
||||||
|
|
||||||
// v0.8.x: Introducing a change where Specials will go in a separate Volume with a reserved number
|
// v0.8.x: Introducing a change where Specials will go in a separate Volume with a reserved number
|
||||||
if (ret.IsSpecial)
|
if (ret.IsSpecial)
|
||||||
{
|
{
|
||||||
|
@ -22,6 +22,7 @@ public class BookParser(IDirectoryService directoryService, IBookService bookSer
|
|||||||
|
|
||||||
if (string.IsNullOrEmpty(info.ComicInfo?.Volume) && hasVolumeInTitle && (hasVolumeInSeries || string.IsNullOrEmpty(info.Series)))
|
if (string.IsNullOrEmpty(info.ComicInfo?.Volume) && hasVolumeInTitle && (hasVolumeInSeries || string.IsNullOrEmpty(info.Series)))
|
||||||
{
|
{
|
||||||
|
// NOTE: I'm not sure the comment is true. I've never seen this triggered
|
||||||
// This is likely a light novel for which we can set series from parsed title
|
// This is likely a light novel for which we can set series from parsed title
|
||||||
info.Series = Parser.ParseSeries(info.Title);
|
info.Series = Parser.ParseSeries(info.Title);
|
||||||
info.Volumes = Parser.ParseVolume(info.Title);
|
info.Volumes = Parser.ParseVolume(info.Title);
|
||||||
@ -30,6 +31,12 @@ public class BookParser(IDirectoryService directoryService, IBookService bookSer
|
|||||||
{
|
{
|
||||||
var info2 = basicParser.Parse(filePath, rootPath, libraryRoot, LibraryType.Book, comicInfo);
|
var info2 = basicParser.Parse(filePath, rootPath, libraryRoot, LibraryType.Book, comicInfo);
|
||||||
info.Merge(info2);
|
info.Merge(info2);
|
||||||
|
if (type == LibraryType.LightNovel && hasVolumeInSeries && info2 != null && Parser.ParseVolume(info2.Series)
|
||||||
|
.Equals(Parser.LooseLeafVolume))
|
||||||
|
{
|
||||||
|
// Override the Series name so it groups appropriately
|
||||||
|
info.Series = info2.Series;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
8
UI/Web/src/app/_models/collection/mal-stack.ts
Normal file
8
UI/Web/src/app/_models/collection/mal-stack.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export interface MalStack {
|
||||||
|
title: string;
|
||||||
|
stackId: number;
|
||||||
|
url: string;
|
||||||
|
author?: string;
|
||||||
|
seriesCount: number;
|
||||||
|
restackCount: number;
|
||||||
|
}
|
11
UI/Web/src/app/_models/readers/full-progress.ts
Normal file
11
UI/Web/src/app/_models/readers/full-progress.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
export interface FullProgress {
|
||||||
|
id: number;
|
||||||
|
chapterId: number;
|
||||||
|
pagesRead: number;
|
||||||
|
lastModified: string;
|
||||||
|
lastModifiedUtc: string;
|
||||||
|
created: string;
|
||||||
|
createdUtc: string;
|
||||||
|
appUserId: number;
|
||||||
|
userName: string;
|
||||||
|
}
|
@ -5,6 +5,7 @@ import { environment } from 'src/environments/environment';
|
|||||||
import { CollectionTag } from '../_models/collection-tag';
|
import { CollectionTag } from '../_models/collection-tag';
|
||||||
import { TextResonse } from '../_types/text-response';
|
import { TextResonse } from '../_types/text-response';
|
||||||
import { ImageService } from './image.service';
|
import { ImageService } from './image.service';
|
||||||
|
import {MalStack} from "../_models/collection/mal-stack";
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
@ -45,4 +46,8 @@ export class CollectionTagService {
|
|||||||
deleteTag(tagId: number) {
|
deleteTag(tagId: number) {
|
||||||
return this.httpClient.delete<string>(this.baseUrl + 'collection?tagId=' + tagId, TextResonse);
|
return this.httpClient.delete<string>(this.baseUrl + 'collection?tagId=' + tagId, TextResonse);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getMalStacks() {
|
||||||
|
return this.httpClient.get<Array<MalStack>>(this.baseUrl + 'collection/mal-stacks');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -23,7 +23,9 @@ export class LibraryService {
|
|||||||
constructor(private httpClient: HttpClient, private readonly messageHub: MessageHubService, private readonly destroyRef: DestroyRef) {
|
constructor(private httpClient: HttpClient, private readonly messageHub: MessageHubService, private readonly destroyRef: DestroyRef) {
|
||||||
this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef), filter(e => e.event === EVENTS.LibraryModified),
|
this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef), filter(e => e.event === EVENTS.LibraryModified),
|
||||||
tap((e) => {
|
tap((e) => {
|
||||||
this.libraryNames = undefined;
|
console.log('LibraryModified event came in, clearing library name cache');
|
||||||
|
this.libraryNames = undefined;
|
||||||
|
this.libraryTypes = undefined;
|
||||||
})).subscribe();
|
})).subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -18,6 +18,7 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
|||||||
import {PersonalToC} from "../_models/readers/personal-toc";
|
import {PersonalToC} from "../_models/readers/personal-toc";
|
||||||
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
|
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
|
||||||
import NoSleep from 'nosleep.js';
|
import NoSleep from 'nosleep.js';
|
||||||
|
import {FullProgress} from "../_models/readers/full-progress";
|
||||||
|
|
||||||
|
|
||||||
export const CHAPTER_ID_DOESNT_EXIST = -1;
|
export const CHAPTER_ID_DOESNT_EXIST = -1;
|
||||||
@ -155,6 +156,10 @@ export class ReaderService {
|
|||||||
return this.httpClient.post(this.baseUrl + 'reader/progress', {libraryId, seriesId, volumeId, chapterId, pageNum: page, bookScrollId});
|
return this.httpClient.post(this.baseUrl + 'reader/progress', {libraryId, seriesId, volumeId, chapterId, pageNum: page, bookScrollId});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getAllProgressForChapter(chapterId: number) {
|
||||||
|
return this.httpClient.get<Array<FullProgress>>(this.baseUrl + 'reader/all-chapter-progress?chapterId=' + chapterId);
|
||||||
|
}
|
||||||
|
|
||||||
markVolumeRead(seriesId: number, volumeId: number) {
|
markVolumeRead(seriesId: number, volumeId: number) {
|
||||||
return this.httpClient.post(this.baseUrl + 'reader/mark-volume-read', {seriesId, volumeId});
|
return this.httpClient.post(this.baseUrl + 'reader/mark-volume-read', {seriesId, volumeId});
|
||||||
}
|
}
|
||||||
|
@ -36,10 +36,18 @@ export class ScrobblingService {
|
|||||||
return this.httpClient.post(this.baseUrl + 'scrobbling/update-anilist-token', {token});
|
return this.httpClient.post(this.baseUrl + 'scrobbling/update-anilist-token', {token});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateMalToken(username: string, accessToken: string) {
|
||||||
|
return this.httpClient.post(this.baseUrl + 'scrobbling/update-mal-token', {username, accessToken});
|
||||||
|
}
|
||||||
|
|
||||||
getAniListToken() {
|
getAniListToken() {
|
||||||
return this.httpClient.get<string>(this.baseUrl + 'scrobbling/anilist-token', TextResonse);
|
return this.httpClient.get<string>(this.baseUrl + 'scrobbling/anilist-token', TextResonse);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getMalToken() {
|
||||||
|
return this.httpClient.get<{username: string, accessToken: string}>(this.baseUrl + 'scrobbling/mal-token');
|
||||||
|
}
|
||||||
|
|
||||||
getScrobbleErrors() {
|
getScrobbleErrors() {
|
||||||
return this.httpClient.get<Array<ScrobbleError>>(this.baseUrl + 'scrobbling/scrobble-errors');
|
return this.httpClient.get<Array<ScrobbleError>>(this.baseUrl + 'scrobbling/scrobble-errors');
|
||||||
}
|
}
|
||||||
|
@ -14,6 +14,7 @@ import { PublicationStatus } from '../_models/metadata/publication-status';
|
|||||||
import { MangaFormat } from '../_models/manga-format';
|
import { MangaFormat } from '../_models/manga-format';
|
||||||
import { TextResonse } from '../_types/text-response';
|
import { TextResonse } from '../_types/text-response';
|
||||||
import {TranslocoService} from "@ngneat/transloco";
|
import {TranslocoService} from "@ngneat/transloco";
|
||||||
|
import {KavitaPlusMetadataBreakdown} from "../statistics/_models/kavitaplus-metadata-breakdown";
|
||||||
|
|
||||||
export enum DayOfWeek
|
export enum DayOfWeek
|
||||||
{
|
{
|
||||||
@ -115,4 +116,8 @@ export class StatisticsService {
|
|||||||
getDayBreakdown( userId = 0) {
|
getDayBreakdown( userId = 0) {
|
||||||
return this.httpClient.get<Array<StatCount<DayOfWeek>>>(this.baseUrl + 'stats/day-breakdown?userId=' + userId);
|
return this.httpClient.get<Array<StatCount<DayOfWeek>>>(this.baseUrl + 'stats/day-breakdown?userId=' + userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getKavitaPlusMetadataBreakdown() {
|
||||||
|
return this.httpClient.get<KavitaPlusMetadataBreakdown>(this.baseUrl + 'stats/kavitaplus-metadata-breakdown');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -11,12 +11,13 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2 mt-4">
|
<div class="col-md-2 mt-4">
|
||||||
<ngb-pagination *ngIf="pagination"
|
@if(pagination) {
|
||||||
[(page)]="pagination.currentPage"
|
<ngb-pagination [(page)]="pagination.currentPage"
|
||||||
[pageSize]="pagination.itemsPerPage"
|
[pageSize]="pagination.itemsPerPage"
|
||||||
[collectionSize]="pagination.totalItems"
|
[collectionSize]="pagination.totalItems"
|
||||||
(pageChange)="onPageChange($event)"
|
(pageChange)="onPageChange($event)"
|
||||||
></ngb-pagination>
|
></ngb-pagination>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<table class="table table-striped table-hover table-sm scrollable">
|
<table class="table table-striped table-hover table-sm scrollable">
|
||||||
@ -43,9 +44,12 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr *ngIf="events.length === 0">
|
@if (events.length === 0) {
|
||||||
<td colspan="6">{{t('no-data')}}</td>
|
<tr>
|
||||||
</tr>
|
<td colspan="6">{{t('no-data')}}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
|
||||||
<tr *ngFor="let item of events; let idx = index;">
|
<tr *ngFor="let item of events; let idx = index;">
|
||||||
<td>
|
<td>
|
||||||
{{item.createdUtc | utcToLocalTime | defaultValue}}
|
{{item.createdUtc | utcToLocalTime | defaultValue}}
|
||||||
@ -60,25 +64,28 @@
|
|||||||
<a href="/library/{{item.libraryId}}/series/{{item.seriesId}}" target="_blank">{{item.seriesName}}</a>
|
<a href="/library/{{item.libraryId}}/series/{{item.seriesId}}" target="_blank">{{item.seriesName}}</a>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<ng-container [ngSwitch]="item.scrobbleEventType">
|
@switch (item.scrobbleEventType) {
|
||||||
<ng-container *ngSwitchCase="ScrobbleEventType.ChapterRead">
|
@case (ScrobbleEventType.ChapterRead) {
|
||||||
@if(item.volumeNumber === SpecialVolumeNumber) {
|
@if(item.volumeNumber === LooseLeafOrDefaultNumber) {
|
||||||
{{t('chapter-num', {num: item.volumeNumber})}}
|
{{t('chapter-num', {num: item.chapterNumber})}}
|
||||||
} @else if (item.chapterNumber === LooseLeafOrDefaultNumber) {
|
}
|
||||||
|
@else if (item.chapterNumber === LooseLeafOrDefaultNumber) {
|
||||||
{{t('volume-num', {num: item.volumeNumber})}}
|
{{t('volume-num', {num: item.volumeNumber})}}
|
||||||
} @else if (item.chapterNumber === LooseLeafOrDefaultNumber && item.volumeNumber === SpecialVolumeNumber) {
|
}
|
||||||
|
@else if (item.chapterNumber === LooseLeafOrDefaultNumber && item.volumeNumber === SpecialVolumeNumber) {
|
||||||
} @else {
|
Special
|
||||||
|
}
|
||||||
|
@else {
|
||||||
{{t('volume-and-chapter-num', {v: item.volumeNumber, n: item.chapterNumber})}}
|
{{t('volume-and-chapter-num', {v: item.volumeNumber, n: item.chapterNumber})}}
|
||||||
}
|
}
|
||||||
</ng-container>
|
}
|
||||||
<ng-container *ngSwitchCase="ScrobbleEventType.ScoreUpdated">
|
@case (ScrobbleEventType.ScoreUpdated) {
|
||||||
{{t('rating', {r: item.rating})}}
|
{{t('rating', {r: item.rating})}}
|
||||||
</ng-container>
|
}
|
||||||
<ng-container *ngSwitchDefault>
|
@default {
|
||||||
{{t('not-applicable')}}
|
{{t('not-applicable')}}
|
||||||
</ng-container>
|
}
|
||||||
</ng-container>
|
}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@if(item.isProcessed) {
|
@if(item.isProcessed) {
|
||||||
|
@ -21,7 +21,8 @@ import {LooseLeafOrDefaultNumber, SpecialVolumeNumber} from "../../_models/chapt
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-user-scrobble-history',
|
selector: 'app-user-scrobble-history',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, ScrobbleEventTypePipe, NgbPagination, ReactiveFormsModule, SortableHeader, TranslocoModule, DefaultValuePipe, TranslocoLocaleModule, UtcToLocalTimePipe, NgbTooltip],
|
imports: [CommonModule, ScrobbleEventTypePipe, NgbPagination, ReactiveFormsModule, SortableHeader, TranslocoModule,
|
||||||
|
DefaultValuePipe, TranslocoLocaleModule, UtcToLocalTimePipe, NgbTooltip],
|
||||||
templateUrl: './user-scrobble-history.component.html',
|
templateUrl: './user-scrobble-history.component.html',
|
||||||
styleUrls: ['./user-scrobble-history.component.scss'],
|
styleUrls: ['./user-scrobble-history.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
@ -1,17 +1,13 @@
|
|||||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core';
|
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core';
|
||||||
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
|
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
|
||||||
import {ToastrService} from 'ngx-toastr';
|
import {ToastrService} from 'ngx-toastr';
|
||||||
import {take} from 'rxjs';
|
import {take} from 'rxjs';
|
||||||
import {SettingsService} from '../settings.service';
|
import {SettingsService} from '../settings.service';
|
||||||
import {ServerSettings} from '../_models/server-settings';
|
import {ServerSettings} from '../_models/server-settings';
|
||||||
import {
|
import {
|
||||||
NgbAccordionBody,
|
|
||||||
NgbAccordionButton,
|
|
||||||
NgbAccordionCollapse,
|
|
||||||
NgbAccordionDirective, NgbAccordionHeader, NgbAccordionItem,
|
|
||||||
NgbTooltip
|
NgbTooltip
|
||||||
} from '@ng-bootstrap/ng-bootstrap';
|
} from '@ng-bootstrap/ng-bootstrap';
|
||||||
import {NgForOf, NgIf, NgTemplateOutlet, TitleCasePipe} from '@angular/common';
|
import {NgIf, NgTemplateOutlet, TitleCasePipe} from '@angular/common';
|
||||||
import {translate, TranslocoModule} from "@ngneat/transloco";
|
import {translate, TranslocoModule} from "@ngneat/transloco";
|
||||||
import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe";
|
import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe";
|
||||||
import {ManageAlertsComponent} from "../manage-alerts/manage-alerts.component";
|
import {ManageAlertsComponent} from "../manage-alerts/manage-alerts.component";
|
||||||
@ -23,8 +19,7 @@ import {ManageAlertsComponent} from "../manage-alerts/manage-alerts.component";
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [NgIf, ReactiveFormsModule, NgbTooltip, NgTemplateOutlet, TranslocoModule, SafeHtmlPipe,
|
imports: [NgIf, ReactiveFormsModule, NgbTooltip, NgTemplateOutlet, TranslocoModule, SafeHtmlPipe,
|
||||||
ManageAlertsComponent, NgbAccordionBody, NgbAccordionButton, NgbAccordionCollapse, NgbAccordionDirective,
|
ManageAlertsComponent, TitleCasePipe]
|
||||||
NgbAccordionHeader, NgbAccordionItem, NgForOf, TitleCasePipe]
|
|
||||||
})
|
})
|
||||||
export class ManageEmailSettingsComponent implements OnInit {
|
export class ManageEmailSettingsComponent implements OnInit {
|
||||||
|
|
||||||
|
@ -9,15 +9,19 @@
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span>
|
<span>
|
||||||
<button *ngIf="hasMarkAsUnread" class="btn btn-icon" (click)="executeAction(Action.MarkAsUnread)" [ngbTooltip]="t('mark-as-unread')" placement="bottom">
|
@if (hasMarkAsUnread) {
|
||||||
|
<button class="btn btn-icon" (click)="executeAction(Action.MarkAsUnread)" [ngbTooltip]="t('mark-as-unread')" placement="bottom">
|
||||||
<i class="fa-regular fa-circle-check" aria-hidden="true"></i>
|
<i class="fa-regular fa-circle-check" aria-hidden="true"></i>
|
||||||
<span class="visually-hidden">{{t('mark-as-unread')}}</span>
|
<span class="visually-hidden">{{t('mark-as-unread')}}</span>
|
||||||
</button>
|
</button>
|
||||||
<button *ngIf="hasMarkAsRead" class="btn btn-icon" (click)="executeAction(Action.MarkAsRead)" [ngbTooltip]="t('mark-as-read')" placement="bottom">
|
}
|
||||||
|
@if (hasMarkAsRead) {
|
||||||
|
<button class="btn btn-icon" (click)="executeAction(Action.MarkAsRead)" [ngbTooltip]="t('mark-as-read')" placement="bottom">
|
||||||
<i class="fa-solid fa-circle-check" aria-hidden="true"></i>
|
<i class="fa-solid fa-circle-check" aria-hidden="true"></i>
|
||||||
<span class="visually-hidden">{{t('mark-as-read')}}</span>
|
<span class="visually-hidden">{{t('mark-as-read')}}</span>
|
||||||
</button>
|
</button>
|
||||||
<app-card-actionables [actions]="actions" labelBy="bulk-actions-header" iconClass="fa-ellipsis-h" (actionHandler)="performAction($event)"></app-card-actionables>
|
}
|
||||||
|
<app-card-actionables [actions]="actions" labelBy="bulk-actions-header" iconClass="fa-ellipsis-h" (actionHandler)="performAction($event)"></app-card-actionables>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span id="bulk-actions-header" class="visually-hidden">Bulk Actions</span>
|
<span id="bulk-actions-header" class="visually-hidden">Bulk Actions</span>
|
||||||
|
@ -32,59 +32,6 @@
|
|||||||
|
|
||||||
<app-entity-info-cards [entity]="data" [libraryId]="libraryId"></app-entity-info-cards>
|
<app-entity-info-cards [entity]="data" [libraryId]="libraryId"></app-entity-info-cards>
|
||||||
|
|
||||||
|
|
||||||
<!-- 2 rows to show some tags-->
|
|
||||||
<ng-container *ngIf="chapterMetadata !== undefined">
|
|
||||||
<div class="row g-0 mb-2">
|
|
||||||
<div class="col-md-6 col-sm-12">
|
|
||||||
<h6>{{t('writers-title')}}</h6>
|
|
||||||
<ng-container *ngIf="chapterMetadata.writers.length > 0; else noBadges">
|
|
||||||
<app-badge-expander [items]="chapterMetadata.writers">
|
|
||||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
|
||||||
<app-person-badge [person]="item"></app-person-badge>
|
|
||||||
</ng-template>
|
|
||||||
</app-badge-expander>
|
|
||||||
</ng-container>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 col-sm-12">
|
|
||||||
<h6>{{t('genres-title')}}</h6>
|
|
||||||
<ng-container *ngIf="chapterMetadata.genres.length > 0; else noBadges">
|
|
||||||
<app-badge-expander [items]="chapterMetadata.genres">
|
|
||||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
|
||||||
<app-tag-badge>{{item.title}}</app-tag-badge>
|
|
||||||
</ng-template>
|
|
||||||
</app-badge-expander>
|
|
||||||
</ng-container>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row g-0 mb-2">
|
|
||||||
<div class="col-md-6 col-sm-12">
|
|
||||||
<h6>{{t('publishers-title')}}</h6>
|
|
||||||
<ng-container *ngIf="chapterMetadata.publishers.length > 0; else noBadges">
|
|
||||||
<app-badge-expander [items]="chapterMetadata.publishers">
|
|
||||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
|
||||||
<app-person-badge [person]="item"></app-person-badge>
|
|
||||||
</ng-template>
|
|
||||||
</app-badge-expander>
|
|
||||||
</ng-container>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 col-sm-12">
|
|
||||||
<h6>{{t('tags-title')}}</h6>
|
|
||||||
<ng-container *ngIf="chapterMetadata.tags.length > 0; else noBadges">
|
|
||||||
<app-badge-expander [items]="chapterMetadata.tags">
|
|
||||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
|
||||||
<app-tag-badge>{{item.title}}</app-tag-badge>
|
|
||||||
</ng-template>
|
|
||||||
</app-badge-expander>
|
|
||||||
</ng-container>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ng-template #noBadges>
|
|
||||||
{{t('not-defined')}}
|
|
||||||
</ng-template>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</li>
|
</li>
|
||||||
@ -96,6 +43,13 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<li [ngbNavItem]="tabs[TabID.Progress]">
|
||||||
|
<a ngbNavLink>{{t(tabs[TabID.Progress].title)}}</a>
|
||||||
|
<ng-template ngbNavContent>
|
||||||
|
<app-edit-chapter-progress [chapter]="chapter"></app-edit-chapter-progress>
|
||||||
|
</ng-template>
|
||||||
|
</li>
|
||||||
|
|
||||||
<li [ngbNavItem]="tabs[TabID.Cover]" [disabled]="(isAdmin$ | async) === false">
|
<li [ngbNavItem]="tabs[TabID.Cover]" [disabled]="(isAdmin$ | async) === false">
|
||||||
<a ngbNavLink>{{t(tabs[TabID.Cover].title)}}</a>
|
<a ngbNavLink>{{t(tabs[TabID.Cover].title)}}</a>
|
||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
@ -113,8 +67,7 @@
|
|||||||
}
|
}
|
||||||
<ul class="list-unstyled">
|
<ul class="list-unstyled">
|
||||||
<li class="d-flex my-4" *ngFor="let chapter of chapters">
|
<li class="d-flex my-4" *ngFor="let chapter of chapters">
|
||||||
<!-- TODO: Localize title -->
|
<a (click)="readChapter(chapter)" href="javascript:void(0);" [title]="t('read')">
|
||||||
<a (click)="readChapter(chapter)" href="javascript:void(0);" title="Read">
|
|
||||||
<app-image class="me-2" width="74px" [imageUrl]="imageService.getChapterCoverImage(chapter.id)"></app-image>
|
<app-image class="me-2" width="74px" [imageUrl]="imageService.getChapterCoverImage(chapter.id)"></app-image>
|
||||||
</a>
|
</a>
|
||||||
<div class="flex-grow-1">
|
<div class="flex-grow-1">
|
||||||
|
@ -50,18 +50,20 @@ import {TagBadgeComponent} from "../../shared/tag-badge/tag-badge.component";
|
|||||||
import {PersonBadgeComponent} from "../../shared/person-badge/person-badge.component";
|
import {PersonBadgeComponent} from "../../shared/person-badge/person-badge.component";
|
||||||
import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco";
|
import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco";
|
||||||
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
|
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
|
||||||
|
import {EditChapterProgressComponent} from "../edit-chapter-progress/edit-chapter-progress.component";
|
||||||
|
|
||||||
enum TabID {
|
enum TabID {
|
||||||
General = 0,
|
General = 0,
|
||||||
Metadata = 1,
|
Metadata = 1,
|
||||||
Cover = 2,
|
Cover = 2,
|
||||||
Files = 3
|
Progress = 3,
|
||||||
|
Files = 4
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-card-detail-drawer',
|
selector: 'app-card-detail-drawer',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, EntityTitleComponent, NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, ImageComponent, ReadMoreComponent, EntityInfoCardsComponent, CoverImageChooserComponent, ChapterMetadataDetailComponent, CardActionablesComponent, DefaultDatePipe, BytesPipe, NgbNavOutlet, BadgeExpanderComponent, TagBadgeComponent, PersonBadgeComponent, TranslocoDirective],
|
imports: [CommonModule, EntityTitleComponent, NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, ImageComponent, ReadMoreComponent, EntityInfoCardsComponent, CoverImageChooserComponent, ChapterMetadataDetailComponent, CardActionablesComponent, DefaultDatePipe, BytesPipe, NgbNavOutlet, BadgeExpanderComponent, TagBadgeComponent, PersonBadgeComponent, TranslocoDirective, EditChapterProgressComponent],
|
||||||
templateUrl: './card-detail-drawer.component.html',
|
templateUrl: './card-detail-drawer.component.html',
|
||||||
styleUrls: ['./card-detail-drawer.component.scss'],
|
styleUrls: ['./card-detail-drawer.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
@ -106,6 +108,7 @@ export class CardDetailDrawerComponent implements OnInit {
|
|||||||
{title: 'general-tab', disabled: false},
|
{title: 'general-tab', disabled: false},
|
||||||
{title: 'metadata-tab', disabled: false},
|
{title: 'metadata-tab', disabled: false},
|
||||||
{title: 'cover-tab', disabled: false},
|
{title: 'cover-tab', disabled: false},
|
||||||
|
{title: 'progress-tab', disabled: false},
|
||||||
{title: 'info-tab', disabled: false}
|
{title: 'info-tab', disabled: false}
|
||||||
];
|
];
|
||||||
active = this.tabs[0];
|
active = this.tabs[0];
|
||||||
|
@ -0,0 +1,48 @@
|
|||||||
|
<ng-container *transloco="let t; read: 'edit-chapter-progress'">
|
||||||
|
<table class="table table-striped" [formGroup]="formGroup">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">{{t('user-header')}}</th>
|
||||||
|
<th scope="col">{{t('page-read-header')}}</th>
|
||||||
|
<th scope="col">{{t('date-created-header')}}</th>
|
||||||
|
<th scope="col">{{t('date-updated-header')}}</th>
|
||||||
|
<!-- <th scope="col">{{t('action-header')}}</th>-->
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for(rowForm of items.controls; track rowForm; let idx = $index) {
|
||||||
|
<tr >
|
||||||
|
<td id="progress-event--{{idx}}">
|
||||||
|
{{progressEvents[idx].userName}}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if(editMode[idx]) {
|
||||||
|
<input type="number" formControlName="pagesRead" class="form-control"/>
|
||||||
|
} @else {
|
||||||
|
{{progressEvents[idx].pagesRead}}
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{progressEvents[idx].createdUtc}}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{progressEvents[idx].lastModifiedUtc}}
|
||||||
|
</td>
|
||||||
|
<!-- <td>-->
|
||||||
|
<!-- @if(editMode[idx]) {-->
|
||||||
|
<!-- <button class="btn btn-primary" (click)="save(progressEvents[idx], idx)" attr.aria-labelledby="progress-event--{{idx}}">-->
|
||||||
|
<!-- <i class="fa-solid fa-floppy-disk" aria-hidden="true"></i>-->
|
||||||
|
<!-- <span class="visually-hidden">{{t('save-alt')}}</span>-->
|
||||||
|
<!-- </button>-->
|
||||||
|
<!-- } @else {-->
|
||||||
|
<!-- <button class="btn btn-primary" (click)="edit(progressEvents[idx], idx)" attr.aria-labelledby="progress-event--{{idx}}">-->
|
||||||
|
<!-- <i class="fa-solid fa-pencil" aria-hidden="true"></i>-->
|
||||||
|
<!-- <span class="visually-hidden">{{t('edit-alt')}}</span>-->
|
||||||
|
<!-- </button>-->
|
||||||
|
<!-- }-->
|
||||||
|
<!-- </td>-->
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</ng-container>
|
@ -0,0 +1,80 @@
|
|||||||
|
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core';
|
||||||
|
import {Chapter} from "../../_models/chapter";
|
||||||
|
import {AsyncPipe, NgForOf, TitleCasePipe} from "@angular/common";
|
||||||
|
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
|
||||||
|
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
|
||||||
|
import {FullProgress} from "../../_models/readers/full-progress";
|
||||||
|
import {ReaderService} from "../../_services/reader.service";
|
||||||
|
import {TranslocoDirective} from "@ngneat/transloco";
|
||||||
|
import {FormArray, FormBuilder, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-edit-chapter-progress',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
AsyncPipe,
|
||||||
|
DefaultValuePipe,
|
||||||
|
NgForOf,
|
||||||
|
TitleCasePipe,
|
||||||
|
UtcToLocalTimePipe,
|
||||||
|
TranslocoDirective,
|
||||||
|
ReactiveFormsModule
|
||||||
|
],
|
||||||
|
templateUrl: './edit-chapter-progress.component.html',
|
||||||
|
styleUrl: './edit-chapter-progress.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class EditChapterProgressComponent implements OnInit {
|
||||||
|
|
||||||
|
private readonly readerService = inject(ReaderService);
|
||||||
|
private readonly cdRef = inject(ChangeDetectorRef);
|
||||||
|
private readonly fb = inject(FormBuilder);
|
||||||
|
|
||||||
|
@Input({required: true}) chapter!: Chapter;
|
||||||
|
|
||||||
|
progressEvents: Array<FullProgress> = [];
|
||||||
|
editMode: {[key: number]: boolean} = {};
|
||||||
|
formGroup = this.fb.group({
|
||||||
|
items: this.fb.array([])
|
||||||
|
});
|
||||||
|
|
||||||
|
get items() {
|
||||||
|
return this.formGroup.get('items') as FormArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.readerService.getAllProgressForChapter(this.chapter!.id).subscribe(res => {
|
||||||
|
this.progressEvents = res;
|
||||||
|
this.progressEvents.forEach((v, i) => {
|
||||||
|
this.editMode[i] = false;
|
||||||
|
this.items.push(this.createRowForm(v));
|
||||||
|
});
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createRowForm(progress: FullProgress): FormGroup {
|
||||||
|
return this.fb.group({
|
||||||
|
pagesRead: [progress.pagesRead, [Validators.required, Validators.min(0), Validators.max(this.chapter!.pages)]],
|
||||||
|
created: [progress.createdUtc, [Validators.required]],
|
||||||
|
lastModified: [progress.lastModifiedUtc, [Validators.required]],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
edit(progress: FullProgress, idx: number) {
|
||||||
|
this.editMode[idx] = !this.editMode[idx];
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
save(progress: FullProgress, idx: number) {
|
||||||
|
// todo
|
||||||
|
this.editMode[idx] = !this.editMode[idx];
|
||||||
|
// this.formGroup[idx + ''].patchValue({
|
||||||
|
// pagesRead: progress.pagesRead,
|
||||||
|
// // Patch other form values as needed
|
||||||
|
// });
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -1,6 +1,6 @@
|
|||||||
<ng-container *transloco="let t; read: 'entity-info-cards'">
|
<ng-container *transloco="let t; read: 'entity-info-cards'">
|
||||||
|
|
||||||
<div class="mt-4 mb-3">
|
<div class="mt-3 mb-3">
|
||||||
<div class="row g-0" *ngIf="chapterMetadata ">
|
<div class="row g-0" *ngIf="chapterMetadata ">
|
||||||
<!-- Tags and Characters are used a lot of Hentai and Doujinshi type content, so showing in list item has value add on first glance -->
|
<!-- Tags and Characters are used a lot of Hentai and Doujinshi type content, so showing in list item has value add on first glance -->
|
||||||
<app-metadata-detail [tags]="chapterMetadata.tags" [libraryId]="libraryId" [queryParam]="FilterField.Tags" heading="Tags">
|
<app-metadata-detail [tags]="chapterMetadata.tags" [libraryId]="libraryId" [queryParam]="FilterField.Tags" heading="Tags">
|
||||||
@ -90,11 +90,8 @@
|
|||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<app-icon-and-title [label]="t('links-title')" [clickable]="false" fontClasses="fa-solid fa-link" [title]="t('links-title')">
|
<app-icon-and-title [label]="t('links-title')" [clickable]="false" fontClasses="fa-solid fa-link" [title]="t('links-title')">
|
||||||
<a class="me-1" [href]="link | safeHtml" *ngFor="let link of WebLinks" target="_blank" rel="noopener noreferrer" [title]="link">
|
<a class="me-1" [href]="link | safeHtml" *ngFor="let link of WebLinks" target="_blank" rel="noopener noreferrer" [title]="link">
|
||||||
<img width="24" height="24" #img class="lazyload img-placeholder"
|
<app-image height="24px" width="24px" aria-hidden="true" [imageUrl]="imageService.getWebLinkImage(link)"
|
||||||
src="data:image/gif;base64,R0lGODlhAQABAPAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=="
|
[errorImage]="imageService.errorWebLinkImage"></app-image>
|
||||||
[attr.data-src]="imageService.getWebLinkImage(link)"
|
|
||||||
(error)="imageService.updateErroredWebLinkImage($event)"
|
|
||||||
aria-hidden="true" alt="">
|
|
||||||
</a>
|
</a>
|
||||||
</app-icon-and-title>
|
</app-icon-and-title>
|
||||||
</div>
|
</div>
|
||||||
@ -117,6 +114,15 @@
|
|||||||
</app-icon-and-title>
|
</app-icon-and-title>
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container *ngIf="isChapter">
|
||||||
|
<div class="vr d-none d-lg-block m-2"></div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<app-icon-and-title [label]="t('sort-order-title')" [clickable]="false" fontClasses="fa-solid fa-arrow-down-1-9" [title]="t('sort-order-title')">
|
||||||
|
{{chapter.sortOrder}}
|
||||||
|
</app-icon-and-title>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -10,7 +10,6 @@ import { UtilityService } from 'src/app/shared/_services/utility.service';
|
|||||||
import { Chapter } from 'src/app/_models/chapter';
|
import { Chapter } from 'src/app/_models/chapter';
|
||||||
import { ChapterMetadata } from 'src/app/_models/metadata/chapter-metadata';
|
import { ChapterMetadata } from 'src/app/_models/metadata/chapter-metadata';
|
||||||
import { HourEstimateRange } from 'src/app/_models/series-detail/hour-estimate-range';
|
import { HourEstimateRange } from 'src/app/_models/series-detail/hour-estimate-range';
|
||||||
import { LibraryType } from 'src/app/_models/library/library';
|
|
||||||
import { MangaFormat } from 'src/app/_models/manga-format';
|
import { MangaFormat } from 'src/app/_models/manga-format';
|
||||||
import { AgeRating } from 'src/app/_models/metadata/age-rating';
|
import { AgeRating } from 'src/app/_models/metadata/age-rating';
|
||||||
import { Volume } from 'src/app/_models/volume';
|
import { Volume } from 'src/app/_models/volume';
|
||||||
@ -29,17 +28,27 @@ import {TranslocoModule} from "@ngneat/transloco";
|
|||||||
import {TranslocoLocaleModule} from "@ngneat/transloco-locale";
|
import {TranslocoLocaleModule} from "@ngneat/transloco-locale";
|
||||||
import {FilterField} from "../../_models/metadata/v2/filter-field";
|
import {FilterField} from "../../_models/metadata/v2/filter-field";
|
||||||
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
|
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
|
||||||
|
import {ImageComponent} from "../../shared/image/image.component";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-entity-info-cards',
|
selector: 'app-entity-info-cards',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, IconAndTitleComponent, SafeHtmlPipe, DefaultDatePipe, BytesPipe, CompactNumberPipe, AgeRatingPipe, NgbTooltip, MetadataDetailComponent, TranslocoModule, CompactNumberPipe, TranslocoLocaleModule, UtcToLocalTimePipe],
|
imports: [CommonModule, IconAndTitleComponent, SafeHtmlPipe, DefaultDatePipe, BytesPipe, CompactNumberPipe,
|
||||||
|
AgeRatingPipe, NgbTooltip, MetadataDetailComponent, TranslocoModule, CompactNumberPipe, TranslocoLocaleModule,
|
||||||
|
UtcToLocalTimePipe, ImageComponent],
|
||||||
templateUrl: './entity-info-cards.component.html',
|
templateUrl: './entity-info-cards.component.html',
|
||||||
styleUrls: ['./entity-info-cards.component.scss'],
|
styleUrls: ['./entity-info-cards.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class EntityInfoCardsComponent implements OnInit {
|
export class EntityInfoCardsComponent implements OnInit {
|
||||||
|
|
||||||
|
protected readonly AgeRating = AgeRating;
|
||||||
|
protected readonly MangaFormat = MangaFormat;
|
||||||
|
protected readonly FilterField = FilterField;
|
||||||
|
|
||||||
|
public readonly imageService = inject(ImageService);
|
||||||
|
|
||||||
|
|
||||||
@Input({required: true}) entity!: Volume | Chapter;
|
@Input({required: true}) entity!: Volume | Chapter;
|
||||||
@Input({required: true}) libraryId!: number;
|
@Input({required: true}) libraryId!: number;
|
||||||
/**
|
/**
|
||||||
@ -48,7 +57,7 @@ export class EntityInfoCardsComponent implements OnInit {
|
|||||||
@Input() includeMetadata: boolean = false;
|
@Input() includeMetadata: boolean = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hide more system based fields, like Id or Date Added
|
* Hide more system based fields, like id or Date Added
|
||||||
*/
|
*/
|
||||||
@Input() showExtendedProperties: boolean = true;
|
@Input() showExtendedProperties: boolean = true;
|
||||||
|
|
||||||
@ -62,22 +71,6 @@ export class EntityInfoCardsComponent implements OnInit {
|
|||||||
readingTime: HourEstimateRange = {maxHours: 1, minHours: 1, avgHours: 1};
|
readingTime: HourEstimateRange = {maxHours: 1, minHours: 1, avgHours: 1};
|
||||||
size: number = 0;
|
size: number = 0;
|
||||||
|
|
||||||
imageService = inject(ImageService);
|
|
||||||
|
|
||||||
get LibraryType() {
|
|
||||||
return LibraryType;
|
|
||||||
}
|
|
||||||
|
|
||||||
get MangaFormat() {
|
|
||||||
return MangaFormat;
|
|
||||||
}
|
|
||||||
|
|
||||||
get AgeRating() {
|
|
||||||
return AgeRating;
|
|
||||||
}
|
|
||||||
|
|
||||||
get FilterField() { return FilterField; }
|
|
||||||
|
|
||||||
get WebLinks() {
|
get WebLinks() {
|
||||||
if (this.chapter.webLinks === '') return [];
|
if (this.chapter.webLinks === '') return [];
|
||||||
return this.chapter.webLinks.split(',');
|
return this.chapter.webLinks.split(',');
|
||||||
|
@ -0,0 +1,36 @@
|
|||||||
|
<ng-container *transloco="let t; read: 'import-mal-collection-modal'">
|
||||||
|
|
||||||
|
<div class="modal-header">
|
||||||
|
<h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4>
|
||||||
|
<button type="button" class="btn-close" aria-label="Close" (click)="ngbModal.close()"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body scrollable-modal">
|
||||||
|
<p>{{t('description')}}</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
@for(stack of stacks; track stack.url) {
|
||||||
|
<li>
|
||||||
|
<div><a [href]="stack.url" rel="noreferrer noopener" target="_blank">{{stack.title}}</a></div>
|
||||||
|
<div>by {{stack.author}} • {{t('series-count', {num: stack.seriesCount})}} • <span><i class="fa-solid fa-layer-group me-1" aria-hidden="true"></i>{{t('restack-count', {num: stack.restackCount})}}</span></div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<!-- <div class="col-auto">-->
|
||||||
|
<!-- <a class="btn btn-icon" href="https://wiki.kavitareader.com/en/guides/get-started-using-your-library/reading-lists#creating-a-reading-list-via-cbl" target="_blank" rel="noopener noreferrer">Help</a>-->
|
||||||
|
<!-- </div>-->
|
||||||
|
<!-- <div class="col-auto">-->
|
||||||
|
<!-- <button type="button" class="btn btn-secondary" (click)="close()">{{t('close')}}</button>-->
|
||||||
|
<!-- </div>-->
|
||||||
|
<!-- <div class="col-auto">-->
|
||||||
|
<!-- <button type="button" class="btn btn-primary" (click)="prevStep()" [disabled]="!canMoveToPrevStep()">{{t('prev')}}</button>-->
|
||||||
|
<!-- </div>-->
|
||||||
|
<!-- <div class="col-auto">-->
|
||||||
|
<!-- <button type="button" class="btn btn-primary" (click)="nextStep()" [disabled]="!canMoveToNextStep()">{{t(NextButtonLabel)}}</button>-->
|
||||||
|
<!-- </div>-->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</ng-container>
|
@ -0,0 +1,40 @@
|
|||||||
|
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject} from '@angular/core';
|
||||||
|
import {TranslocoDirective} from "@ngneat/transloco";
|
||||||
|
import {ReactiveFormsModule} from "@angular/forms";
|
||||||
|
import {Select2Module} from "ng-select2-component";
|
||||||
|
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
|
||||||
|
import {CollectionTagService} from "../../../_services/collection-tag.service";
|
||||||
|
import {MalStack} from "../../../_models/collection/mal-stack";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-import-mal-collection-modal',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
TranslocoDirective,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
Select2Module
|
||||||
|
],
|
||||||
|
templateUrl: './import-mal-collection-modal.component.html',
|
||||||
|
styleUrl: './import-mal-collection-modal.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class ImportMalCollectionModalComponent {
|
||||||
|
|
||||||
|
protected readonly ngbModal = inject(NgbActiveModal);
|
||||||
|
protected readonly collectionService = inject(CollectionTagService);
|
||||||
|
protected readonly cdRef = inject(ChangeDetectorRef);
|
||||||
|
|
||||||
|
stacks: Array<MalStack> = [];
|
||||||
|
isLoading = true;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.collectionService.getMalStacks().subscribe(stacks => {
|
||||||
|
this.stacks = stacks;
|
||||||
|
this.isLoading = false;
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
@ -7,6 +7,7 @@
|
|||||||
<h6 subtitle class="subtitle-with-actionables" *ngIf="active.fragment === ''">{{t('common.series-count', {num: pagination.totalItems | number})}} </h6>
|
<h6 subtitle class="subtitle-with-actionables" *ngIf="active.fragment === ''">{{t('common.series-count', {num: pagination.totalItems | number})}} </h6>
|
||||||
</app-side-nav-companion-bar>
|
</app-side-nav-companion-bar>
|
||||||
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
||||||
|
<app-loading [absolute]="true" [loading]="bulkLoader"></app-loading>
|
||||||
<app-card-detail-layout *ngIf="filter"
|
<app-card-detail-layout *ngIf="filter"
|
||||||
[isLoading]="loadingSeries"
|
[isLoading]="loadingSeries"
|
||||||
[items]="series"
|
[items]="series"
|
||||||
|
@ -44,6 +44,7 @@ import {MetadataService} from "../_services/metadata.service";
|
|||||||
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 {CardActionablesComponent} from "../_single-module/card-actionables/card-actionables.component";
|
import {CardActionablesComponent} from "../_single-module/card-actionables/card-actionables.component";
|
||||||
|
import {LoadingComponent} from "../shared/loading/loading.component";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-library-detail',
|
selector: 'app-library-detail',
|
||||||
@ -52,10 +53,14 @@ import {CardActionablesComponent} from "../_single-module/card-actionables/card-
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [SideNavCompanionBarComponent, CardActionablesComponent, NgbNav, NgFor, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent, NgIf
|
imports: [SideNavCompanionBarComponent, CardActionablesComponent, NgbNav, NgFor, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent, NgIf
|
||||||
, CardDetailLayoutComponent, SeriesCardComponent, BulkOperationsComponent, NgbNavOutlet, DecimalPipe, SentenceCasePipe, TranslocoDirective]
|
, CardDetailLayoutComponent, SeriesCardComponent, BulkOperationsComponent, NgbNavOutlet, DecimalPipe, SentenceCasePipe, TranslocoDirective, LoadingComponent]
|
||||||
})
|
})
|
||||||
export class LibraryDetailComponent implements OnInit {
|
export class LibraryDetailComponent implements OnInit {
|
||||||
|
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
private readonly metadataService = inject(MetadataService);
|
||||||
|
private readonly cdRef = inject(ChangeDetectorRef);
|
||||||
|
|
||||||
libraryId!: number;
|
libraryId!: number;
|
||||||
libraryName = '';
|
libraryName = '';
|
||||||
series: Series[] = [];
|
series: Series[] = [];
|
||||||
@ -69,18 +74,16 @@ export class LibraryDetailComponent implements OnInit {
|
|||||||
filterActiveCheck!: SeriesFilterV2;
|
filterActiveCheck!: SeriesFilterV2;
|
||||||
refresh: EventEmitter<void> = new EventEmitter();
|
refresh: EventEmitter<void> = new EventEmitter();
|
||||||
jumpKeys: Array<JumpKey> = [];
|
jumpKeys: Array<JumpKey> = [];
|
||||||
|
bulkLoader: boolean = false;
|
||||||
|
|
||||||
tabs: Array<{title: string, fragment: string, icon: string}> = [
|
tabs: Array<{title: string, fragment: string, icon: string}> = [
|
||||||
{title: 'library-tab', fragment: '', icon: 'fa-landmark'},
|
{title: 'library-tab', fragment: '', icon: 'fa-landmark'},
|
||||||
{title: 'recommended-tab', fragment: 'recommended', icon: 'fa-award'},
|
{title: 'recommended-tab', fragment: 'recommended', icon: 'fa-award'},
|
||||||
];
|
];
|
||||||
active = this.tabs[0];
|
active = this.tabs[0];
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
|
||||||
private readonly metadataService = inject(MetadataService);
|
|
||||||
private readonly cdRef = inject(ChangeDetectorRef);
|
|
||||||
|
|
||||||
|
|
||||||
bulkActionCallback = (action: ActionItem<any>, data: any) => {
|
bulkActionCallback = async (action: ActionItem<any>, data: any) => {
|
||||||
const selectedSeriesIndices = this.bulkSelectionService.getSelectedCardsForSource('series');
|
const selectedSeriesIndices = this.bulkSelectionService.getSelectedCardsForSource('series');
|
||||||
const selectedSeries = this.series.filter((series, index: number) => selectedSeriesIndices.includes(index + ''));
|
const selectedSeries = this.series.filter((series, index: number) => selectedSeriesIndices.includes(index + ''));
|
||||||
|
|
||||||
@ -123,7 +126,14 @@ export class LibraryDetailComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case Action.Delete:
|
case Action.Delete:
|
||||||
this.actionService.deleteMultipleSeries(selectedSeries, (successful) => {
|
if (selectedSeries.length > 25) {
|
||||||
|
this.bulkLoader = true;
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.actionService.deleteMultipleSeries(selectedSeries, (successful) => {
|
||||||
|
this.bulkLoader = false;
|
||||||
|
this.cdRef.markForCheck();
|
||||||
if (!successful) return;
|
if (!successful) return;
|
||||||
this.bulkSelectionService.deselectAll();
|
this.bulkSelectionService.deselectAll();
|
||||||
this.loadPage();
|
this.loadPage();
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
<ng-container *transloco="let t; read: 'import-cbl-modal'">
|
<ng-container *transloco="let t; read: 'import-cbl-modal'">
|
||||||
|
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h4 class="modal-title" id="modal-basic-title">CBL Import</h4>
|
<h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4>
|
||||||
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
|
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body scrollable-modal">
|
<div class="modal-body scrollable-modal">
|
||||||
|
@ -7,7 +7,7 @@ import {
|
|||||||
} from '@ng-bootstrap/ng-bootstrap';
|
} from '@ng-bootstrap/ng-bootstrap';
|
||||||
import { ToastrService } from 'ngx-toastr';
|
import { ToastrService } from 'ngx-toastr';
|
||||||
import { forkJoin } from 'rxjs';
|
import { forkJoin } from 'rxjs';
|
||||||
import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service';
|
import { UtilityService } from 'src/app/shared/_services/utility.service';
|
||||||
import { CblImportResult } from 'src/app/_models/reading-list/cbl/cbl-import-result.enum';
|
import { CblImportResult } from 'src/app/_models/reading-list/cbl/cbl-import-result.enum';
|
||||||
import { CblImportSummary } from 'src/app/_models/reading-list/cbl/cbl-import-summary';
|
import { CblImportSummary } from 'src/app/_models/reading-list/cbl/cbl-import-summary';
|
||||||
import { ReadingListService } from 'src/app/_services/reading-list.service';
|
import { ReadingListService } from 'src/app/_services/reading-list.service';
|
||||||
@ -16,7 +16,7 @@ import {CommonModule} from "@angular/common";
|
|||||||
import {SafeHtmlPipe} from "../../../_pipes/safe-html.pipe";
|
import {SafeHtmlPipe} from "../../../_pipes/safe-html.pipe";
|
||||||
import {CblConflictReasonPipe} from "../../../_pipes/cbl-conflict-reason.pipe";
|
import {CblConflictReasonPipe} from "../../../_pipes/cbl-conflict-reason.pipe";
|
||||||
import {CblImportResultPipe} from "../../../_pipes/cbl-import-result.pipe";
|
import {CblImportResultPipe} from "../../../_pipes/cbl-import-result.pipe";
|
||||||
import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco";
|
import {translate, TranslocoDirective} from "@ngneat/transloco";
|
||||||
|
|
||||||
interface FileStep {
|
interface FileStep {
|
||||||
fileName: string;
|
fileName: string;
|
||||||
|
@ -25,15 +25,17 @@ import {Breakpoint, UtilityService} from "../../../shared/_services/utility.serv
|
|||||||
})
|
})
|
||||||
export class CustomizeDashboardStreamsComponent {
|
export class CustomizeDashboardStreamsComponent {
|
||||||
|
|
||||||
items: DashboardStream[] = [];
|
|
||||||
smartFilters: SmartFilter[] = [];
|
|
||||||
accessibilityMode: boolean = false;
|
|
||||||
|
|
||||||
private readonly dashboardService = inject(DashboardService);
|
private readonly dashboardService = inject(DashboardService);
|
||||||
private readonly filterService = inject(FilterService);
|
private readonly filterService = inject(FilterService);
|
||||||
private readonly cdRef = inject(ChangeDetectorRef);
|
private readonly cdRef = inject(ChangeDetectorRef);
|
||||||
private readonly utilityService = inject(UtilityService);
|
private readonly utilityService = inject(UtilityService);
|
||||||
|
|
||||||
|
items: DashboardStream[] = [];
|
||||||
|
smartFilters: SmartFilter[] = [];
|
||||||
|
accessibilityMode: boolean = false;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
listForm: FormGroup = new FormGroup({
|
listForm: FormGroup = new FormGroup({
|
||||||
'filterQuery': new FormControl('', [])
|
'filterQuery': new FormControl('', [])
|
||||||
});
|
});
|
||||||
|
@ -148,7 +148,6 @@ export class CustomizeSidenavStreamsComponent implements OnDestroy {
|
|||||||
if (this.pageOperationsForm.get('accessibilityMode')?.disabled) return;
|
if (this.pageOperationsForm.get('accessibilityMode')?.disabled) return;
|
||||||
this.pageOperationsForm.get('accessibilityMode')?.disable();
|
this.pageOperationsForm.get('accessibilityMode')?.disable();
|
||||||
} else {
|
} else {
|
||||||
if (this.pageOperationsForm.get('accessibilityMode')?.disabled) return;
|
|
||||||
this.pageOperationsForm.get('accessibilityMode')?.enable();
|
this.pageOperationsForm.get('accessibilityMode')?.enable();
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
<div class="side-nav" [ngClass]="{'closed' : (navService.sideNavCollapsed$ | async), 'hidden': (navService.sideNavVisibility$ | async) === false, 'no-donate': (accountService.hasValidLicense$ | async) === true}" *ngIf="accountService.currentUser$ | async as user">
|
<div class="side-nav" [ngClass]="{'closed' : (navService.sideNavCollapsed$ | async), 'hidden': (navService.sideNavVisibility$ | async) === false, 'no-donate': (accountService.hasValidLicense$ | async) === true}" *ngIf="accountService.currentUser$ | async as user">
|
||||||
<app-side-nav-item icon="fa-home" [title]="t('home')" link="/home/">
|
<app-side-nav-item icon="fa-home" [title]="t('home')" link="/home/">
|
||||||
<ng-container actions>
|
<ng-container actions>
|
||||||
<app-card-actionables [actions]="homeActions" [labelBy]="t('home')" iconClass="fa-ellipsis-v" (actionHandler)="handleHomeActions()"></app-card-actionables>
|
<app-card-actionables [actions]="homeActions" [labelBy]="t('home')" iconClass="fa-ellipsis-v" (actionHandler)="handleHomeActions($event)"></app-card-actionables>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</app-side-nav-item>
|
</app-side-nav-item>
|
||||||
|
|
||||||
|
@ -30,6 +30,9 @@ import {SentenceCasePipe} from "../../../_pipes/sentence-case.pipe";
|
|||||||
import {CustomizeDashboardModalComponent} from "../customize-dashboard-modal/customize-dashboard-modal.component";
|
import {CustomizeDashboardModalComponent} from "../customize-dashboard-modal/customize-dashboard-modal.component";
|
||||||
import {SideNavStream} from "../../../_models/sidenav/sidenav-stream";
|
import {SideNavStream} from "../../../_models/sidenav/sidenav-stream";
|
||||||
import {SideNavStreamType} from "../../../_models/sidenav/sidenav-stream-type.enum";
|
import {SideNavStreamType} from "../../../_models/sidenav/sidenav-stream-type.enum";
|
||||||
|
import {
|
||||||
|
ImportMalCollectionModalComponent
|
||||||
|
} from "../../../collections/_components/import-mal-collection-modal/import-mal-collection-modal.component";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-side-nav',
|
selector: 'app-side-nav',
|
||||||
@ -46,8 +49,12 @@ export class SideNavComponent implements OnInit {
|
|||||||
|
|
||||||
cachedData: SideNavStream[] | null = null;
|
cachedData: SideNavStream[] | null = null;
|
||||||
actions: ActionItem<Library>[] = this.actionFactoryService.getLibraryActions(this.handleAction.bind(this));
|
actions: ActionItem<Library>[] = this.actionFactoryService.getLibraryActions(this.handleAction.bind(this));
|
||||||
readingListActions = [{action: Action.Import, title: 'import-cbl', children: [], requiresAdmin: true, callback: this.importCbl.bind(this)}];
|
readingListActions = [];
|
||||||
homeActions = [{action: Action.Edit, title: 'customize', children: [], requiresAdmin: false, callback: this.handleHomeActions.bind(this)}];
|
homeActions = [
|
||||||
|
{action: Action.Edit, title: 'customize', children: [], requiresAdmin: false, callback: this.openCustomize.bind(this)},
|
||||||
|
{action: Action.Import, title: 'import-cbl', children: [], requiresAdmin: true, callback: this.importCbl.bind(this)},
|
||||||
|
//{action: Action.Import, title: 'import-mal-stack', children: [], requiresAdmin: true, callback: this.importMalCollection.bind(this)}, // This requires the Collection Rework (https://github.com/Kareadita/Kavita/issues/2810)
|
||||||
|
];
|
||||||
|
|
||||||
filterQuery: string = '';
|
filterQuery: string = '';
|
||||||
filterLibrary = (stream: SideNavStream) => {
|
filterLibrary = (stream: SideNavStream) => {
|
||||||
@ -168,7 +175,11 @@ export class SideNavComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleHomeActions() {
|
handleHomeActions(action: ActionItem<void>) {
|
||||||
|
action.callback(action, undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
openCustomize() {
|
||||||
this.ngbModal.open(CustomizeDashboardModalComponent, {size: 'xl', fullscreen: 'md'});
|
this.ngbModal.open(CustomizeDashboardModalComponent, {size: 'xl', fullscreen: 'md'});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -176,6 +187,10 @@ export class SideNavComponent implements OnInit {
|
|||||||
this.ngbModal.open(ImportCblModalComponent, {size: 'xl', fullscreen: 'md'});
|
this.ngbModal.open(ImportCblModalComponent, {size: 'xl', fullscreen: 'md'});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
importMalCollection() {
|
||||||
|
this.ngbModal.open(ImportMalCollectionModalComponent, {size: 'xl', fullscreen: 'md'});
|
||||||
|
}
|
||||||
|
|
||||||
performAction(action: ActionItem<Library>, library: Library) {
|
performAction(action: ActionItem<Library>, library: Library) {
|
||||||
if (typeof action.callback === 'function') {
|
if (typeof action.callback === 'function') {
|
||||||
action.callback(action, library);
|
action.callback(action, library);
|
||||||
|
@ -0,0 +1,13 @@
|
|||||||
|
<ng-container *transloco="let t; read:'generic-list-modal'">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
|
||||||
|
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="modalService.dismiss()"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<ng-container [ngTemplateOutlet]="bodyTemplate"></ng-container>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-primary" (click)="modalService.dismiss()">{{t('close')}}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</ng-container>
|
@ -0,0 +1,27 @@
|
|||||||
|
import {Component, ContentChild, inject, Input, TemplateRef} from '@angular/core';
|
||||||
|
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
|
||||||
|
import {NgTemplateOutlet} from "@angular/common";
|
||||||
|
import {TranslocoDirective} from "@ngneat/transloco";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-generic-table-modal',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
NgTemplateOutlet,
|
||||||
|
TranslocoDirective
|
||||||
|
],
|
||||||
|
templateUrl: './generic-table-modal.component.html',
|
||||||
|
styleUrl: './generic-table-modal.component.scss'
|
||||||
|
})
|
||||||
|
export class GenericTableModalComponent {
|
||||||
|
|
||||||
|
public readonly modalService = inject(NgbActiveModal);
|
||||||
|
|
||||||
|
@Input({required: true}) title: string = '';
|
||||||
|
@Input() bodyTemplate!: TemplateRef<any>;
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
console.log('bodyTemplate: ', this.bodyTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -21,17 +21,19 @@ import {tap} from "rxjs/operators";
|
|||||||
})
|
})
|
||||||
export class DayBreakdownComponent implements OnInit {
|
export class DayBreakdownComponent implements OnInit {
|
||||||
|
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
private readonly cdRef = inject(ChangeDetectorRef);
|
||||||
|
private readonly statService = inject(StatisticsService);
|
||||||
|
|
||||||
@Input() userId = 0;
|
@Input() userId = 0;
|
||||||
|
|
||||||
view: [number, number] = [0,0];
|
view: [number, number] = [0,0];
|
||||||
showLegend: boolean = true;
|
showLegend: boolean = true;
|
||||||
max: number = 1;
|
max: number = 1;
|
||||||
|
|
||||||
formControl: FormControl = new FormControl(true, []);
|
formControl: FormControl = new FormControl(true, []);
|
||||||
dayBreakdown$!: Observable<Array<PieDataItem>>;
|
dayBreakdown$!: Observable<Array<PieDataItem>>;
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
|
||||||
private readonly cdRef = inject(ChangeDetectorRef);
|
|
||||||
|
|
||||||
constructor(private statService: StatisticsService) {}
|
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
const dayOfWeekPipe = new DayOfWeekPipe();
|
const dayOfWeekPipe = new DayOfWeekPipe();
|
||||||
|
@ -23,6 +23,7 @@
|
|||||||
<ng-container *ngIf="formControl.value; else tableLayout">
|
<ng-container *ngIf="formControl.value; else tableLayout">
|
||||||
<ngx-charts-advanced-pie-chart [results]="vizData2$ | async"></ngx-charts-advanced-pie-chart>
|
<ngx-charts-advanced-pie-chart [results]="vizData2$ | async"></ngx-charts-advanced-pie-chart>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-template #tableLayout>
|
<ng-template #tableLayout>
|
||||||
<table class="table table-striped table-striped table-hover table-sm scrollable">
|
<table class="table table-striped table-striped table-hover table-sm scrollable">
|
||||||
<thead>
|
<thead>
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy, ChangeDetectorRef,
|
||||||
Component,
|
Component,
|
||||||
DestroyRef,
|
DestroyRef,
|
||||||
inject,
|
inject,
|
||||||
QueryList,
|
QueryList, TemplateRef, ViewChild,
|
||||||
ViewChildren
|
ViewChildren
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { FormControl, ReactiveFormsModule } from '@angular/forms';
|
import { FormControl, ReactiveFormsModule } from '@angular/forms';
|
||||||
import { PieChartModule } from '@swimlane/ngx-charts';
|
import { PieChartModule } from '@swimlane/ngx-charts';
|
||||||
import { Observable, BehaviorSubject, combineLatest, map, shareReplay } from 'rxjs';
|
import {Observable, BehaviorSubject, combineLatest, map, shareReplay, switchMap} from 'rxjs';
|
||||||
import { StatisticsService } from 'src/app/_services/statistics.service';
|
import { StatisticsService } from 'src/app/_services/statistics.service';
|
||||||
import { SortableHeader, SortEvent, compare } from 'src/app/_single-module/table/_directives/sortable-header.directive';
|
import { SortableHeader, SortEvent, compare } from 'src/app/_single-module/table/_directives/sortable-header.directive';
|
||||||
import { FileExtension, FileExtensionBreakdown } from '../../_models/file-breakdown';
|
import { FileExtension, FileExtensionBreakdown } from '../../_models/file-breakdown';
|
||||||
@ -18,8 +18,10 @@ import { MangaFormatPipe } from '../../../_pipes/manga-format.pipe';
|
|||||||
import { BytesPipe } from '../../../_pipes/bytes.pipe';
|
import { BytesPipe } from '../../../_pipes/bytes.pipe';
|
||||||
import { SortableHeader as SortableHeader_1 } from '../../../_single-module/table/_directives/sortable-header.directive';
|
import { SortableHeader as SortableHeader_1 } from '../../../_single-module/table/_directives/sortable-header.directive';
|
||||||
import { NgIf, NgFor, AsyncPipe, DecimalPipe } from '@angular/common';
|
import { NgIf, NgFor, AsyncPipe, DecimalPipe } from '@angular/common';
|
||||||
import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap';
|
import {NgbModal, NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
|
||||||
import {TranslocoDirective, TranslocoService} from "@ngneat/transloco";
|
import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco";
|
||||||
|
import {filter, tap} from "rxjs/operators";
|
||||||
|
import {GenericTableModalComponent} from "../_modals/generic-table-modal/generic-table-modal.component";
|
||||||
|
|
||||||
export interface StackedBarChartDataItem {
|
export interface StackedBarChartDataItem {
|
||||||
name: string,
|
name: string,
|
||||||
@ -36,7 +38,11 @@ export interface StackedBarChartDataItem {
|
|||||||
})
|
})
|
||||||
export class FileBreakdownStatsComponent {
|
export class FileBreakdownStatsComponent {
|
||||||
|
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
private readonly cdRef = inject(ChangeDetectorRef);
|
||||||
|
|
||||||
@ViewChildren(SortableHeader<PieDataItem>) headers!: QueryList<SortableHeader<PieDataItem>>;
|
@ViewChildren(SortableHeader<PieDataItem>) headers!: QueryList<SortableHeader<PieDataItem>>;
|
||||||
|
@ViewChild('tablelayout') tableTemplate!: TemplateRef<any>;
|
||||||
|
|
||||||
rawData$!: Observable<FileExtensionBreakdown>;
|
rawData$!: Observable<FileExtensionBreakdown>;
|
||||||
files$!: Observable<Array<FileExtension>>;
|
files$!: Observable<Array<FileExtension>>;
|
||||||
@ -45,14 +51,15 @@ export class FileBreakdownStatsComponent {
|
|||||||
currentSort = new BehaviorSubject<SortEvent<FileExtension>>({column: 'extension', direction: 'asc'});
|
currentSort = new BehaviorSubject<SortEvent<FileExtension>>({column: 'extension', direction: 'asc'});
|
||||||
currentSort$: Observable<SortEvent<FileExtension>> = this.currentSort.asObservable();
|
currentSort$: Observable<SortEvent<FileExtension>> = this.currentSort.asObservable();
|
||||||
|
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
|
||||||
|
|
||||||
view: [number, number] = [700, 400];
|
view: [number, number] = [700, 400];
|
||||||
|
|
||||||
formControl: FormControl = new FormControl(true, []);
|
formControl: FormControl = new FormControl(true, []);
|
||||||
|
|
||||||
|
private readonly statService = inject(StatisticsService);
|
||||||
|
private readonly translocoService = inject(TranslocoService);
|
||||||
|
private readonly ngbModal = inject(NgbModal);
|
||||||
|
|
||||||
constructor(private statService: StatisticsService, private translocoService: TranslocoService) {
|
constructor() {
|
||||||
this.rawData$ = this.statService.getFileBreakdown().pipe(takeUntilDestroyed(this.destroyRef), shareReplay());
|
this.rawData$ = this.statService.getFileBreakdown().pipe(takeUntilDestroyed(this.destroyRef), shareReplay());
|
||||||
|
|
||||||
this.files$ = combineLatest([this.currentSort$, this.rawData$]).pipe(
|
this.files$ = combineLatest([this.currentSort$, this.rawData$]).pipe(
|
||||||
@ -73,6 +80,17 @@ export class FileBreakdownStatsComponent {
|
|||||||
this.vizData2$ = this.files$.pipe(takeUntilDestroyed(this.destroyRef), map(data => data.map(d => {
|
this.vizData2$ = this.files$.pipe(takeUntilDestroyed(this.destroyRef), map(data => data.map(d => {
|
||||||
return {name: d.extension || this.translocoService.translate('file-breakdown-stats.not-classified'), value: d.totalFiles, extra: d.totalSize};
|
return {name: d.extension || this.translocoService.translate('file-breakdown-stats.not-classified'), value: d.totalFiles, extra: d.totalSize};
|
||||||
})));
|
})));
|
||||||
|
|
||||||
|
// TODO: See if you can figure this out
|
||||||
|
// this.formControl.valueChanges.pipe(filter(v => !v), takeUntilDestroyed(this.destroyRef), switchMap(_ => {
|
||||||
|
// const ref = this.ngbModal.open(GenericTableModalComponent);
|
||||||
|
// ref.componentInstance.title = translate('file-breakdown-stats.format-title');
|
||||||
|
// ref.componentInstance.bodyTemplate = this.tableTemplate;
|
||||||
|
// return ref.dismissed;
|
||||||
|
// }, tap(_ => {
|
||||||
|
// this.formControl.setValue(true);
|
||||||
|
// this.cdRef.markForCheck();
|
||||||
|
// }))).subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
onSort(evt: SortEvent<FileExtension>) {
|
onSort(evt: SortEvent<FileExtension>) {
|
||||||
|
@ -0,0 +1,38 @@
|
|||||||
|
<ng-container *transloco="let t; read: 'kavitaplus-metadata-breakdown-stats'">
|
||||||
|
<div class="dashboard-card-content">
|
||||||
|
<h4>{{t('title')}}</h4>
|
||||||
|
|
||||||
|
@if(breakdown) {
|
||||||
|
@if(breakdown.totalSeries === 0 || breakdown.seriesCompleted === 0) {
|
||||||
|
<div>{{t('no-data')}}</div>
|
||||||
|
}
|
||||||
|
@if (percentDone >= 1) {
|
||||||
|
<p>{{t('complete') }}</p>
|
||||||
|
} @else {
|
||||||
|
<p>{{t('total-series-progress-label', {percent: percentDone * 100 | percent}) }}</p>
|
||||||
|
|
||||||
|
<div class="day-breakdown-chart">
|
||||||
|
<table class="charts-css pie show-labels">
|
||||||
|
<tbody>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{{t('completed-series-label')}}</th>
|
||||||
|
<td class="completed" style="--start: 0; --end: ' + {{ completedEnd }}">
|
||||||
|
<span class="data">{{ breakdown.seriesCompleted }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{{t('errored-series-label')}}</th>
|
||||||
|
<td class="error" style="--start: ' + {{ errorStart }}; --end: {{ errorEnd }}'">
|
||||||
|
<span class="data">{{ breakdown.erroredSeries }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
@ -0,0 +1,18 @@
|
|||||||
|
.dashboard-card-content {
|
||||||
|
max-width: 400px;
|
||||||
|
height: auto;
|
||||||
|
box-sizing:border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-breakdown-chart {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
.completed {
|
||||||
|
color: var(--color-5);
|
||||||
|
}
|
@ -0,0 +1,40 @@
|
|||||||
|
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject} from '@angular/core';
|
||||||
|
import {StatisticsService} from "../../../_services/statistics.service";
|
||||||
|
import {KavitaPlusMetadataBreakdown} from "../../_models/kavitaplus-metadata-breakdown";
|
||||||
|
import {TranslocoDirective} from "@ngneat/transloco";
|
||||||
|
import {PercentPipe} from "@angular/common";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-kavitaplus-metadata-breakdown-stats',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
TranslocoDirective,
|
||||||
|
PercentPipe
|
||||||
|
],
|
||||||
|
templateUrl: './kavitaplus-metadata-breakdown-stats.component.html',
|
||||||
|
styleUrl: './kavitaplus-metadata-breakdown-stats.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class KavitaplusMetadataBreakdownStatsComponent {
|
||||||
|
private readonly cdRef = inject(ChangeDetectorRef);
|
||||||
|
private readonly statsService = inject(StatisticsService);
|
||||||
|
|
||||||
|
breakdown: KavitaPlusMetadataBreakdown | undefined;
|
||||||
|
completedStart!: number;
|
||||||
|
completedEnd!: number;
|
||||||
|
errorStart!: number;
|
||||||
|
errorEnd!: number;
|
||||||
|
percentDone!: number;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.statsService.getKavitaPlusMetadataBreakdown().subscribe(res => {
|
||||||
|
this.breakdown = res;
|
||||||
|
this.completedStart = 0;
|
||||||
|
this.completedEnd = ((res.seriesCompleted - res.erroredSeries) / res.totalSeries);
|
||||||
|
this.errorStart = this.completedEnd;
|
||||||
|
this.errorEnd = Math.max(1, ((res.seriesCompleted) / res.totalSeries));
|
||||||
|
this.percentDone = res.seriesCompleted / res.totalSeries;
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -113,9 +113,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-0 pt-4 pb-2">
|
<div class="row g-0 pt-4 pb-2">
|
||||||
<div class="col-md-12 col-sm-12 mt-4 pt-2">
|
<div class="col-md-6 col-sm-12 mt-4 pt-2">
|
||||||
<app-day-breakdown></app-day-breakdown>
|
<app-day-breakdown></app-day-breakdown>
|
||||||
</div>
|
</div>
|
||||||
|
@if (accountService.hasValidLicense$ | async) {
|
||||||
|
<div class="col-md-4 col-md-offset-2 col-sm-12 mt-4 pt-2">
|
||||||
|
<app-kavitaplus-metadata-breakdown-stats></app-kavitaplus-metadata-breakdown-stats>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
|
@ -26,6 +26,10 @@ import {AsyncPipe, DecimalPipe, NgIf} from '@angular/common';
|
|||||||
import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco";
|
import {translate, TranslocoDirective, TranslocoService} from "@ngneat/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 {
|
||||||
|
KavitaplusMetadataBreakdownStatsComponent
|
||||||
|
} from "../kavitaplus-metadata-breakdown-stats/kavitaplus-metadata-breakdown-stats.component";
|
||||||
|
import {AccountService} from "../../../_services/account.service";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-server-stats',
|
selector: 'app-server-stats',
|
||||||
@ -33,12 +37,15 @@ import {FilterField} from "../../../_models/metadata/v2/filter-field";
|
|||||||
styleUrls: ['./server-stats.component.scss'],
|
styleUrls: ['./server-stats.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [NgIf, IconAndTitleComponent, StatListComponent, TopReadersComponent, FileBreakdownStatsComponent,
|
imports: [NgIf, IconAndTitleComponent, StatListComponent, TopReadersComponent, FileBreakdownStatsComponent,
|
||||||
PublicationStatusStatsComponent, ReadingActivityComponent, DayBreakdownComponent, AsyncPipe, DecimalPipe,
|
PublicationStatusStatsComponent, ReadingActivityComponent, DayBreakdownComponent, AsyncPipe, DecimalPipe,
|
||||||
CompactNumberPipe, TimeDurationPipe, BytesPipe, TranslocoDirective]
|
CompactNumberPipe, TimeDurationPipe, BytesPipe, TranslocoDirective, KavitaplusMetadataBreakdownStatsComponent]
|
||||||
})
|
})
|
||||||
export class ServerStatsComponent {
|
export class ServerStatsComponent {
|
||||||
|
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
protected readonly accountService = inject(AccountService);
|
||||||
|
|
||||||
releaseYears$!: Observable<Array<PieDataItem>>;
|
releaseYears$!: Observable<Array<PieDataItem>>;
|
||||||
mostActiveUsers$!: Observable<Array<PieDataItem>>;
|
mostActiveUsers$!: Observable<Array<PieDataItem>>;
|
||||||
mostActiveLibrary$!: Observable<Array<PieDataItem>>;
|
mostActiveLibrary$!: Observable<Array<PieDataItem>>;
|
||||||
@ -54,7 +61,7 @@ export class ServerStatsComponent {
|
|||||||
breakpointSubject = new ReplaySubject<Breakpoint>(1);
|
breakpointSubject = new ReplaySubject<Breakpoint>(1);
|
||||||
breakpoint$: Observable<Breakpoint> = this.breakpointSubject.asObservable();
|
breakpoint$: Observable<Breakpoint> = this.breakpointSubject.asObservable();
|
||||||
|
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
|
||||||
|
|
||||||
@HostListener('window:resize', ['$event'])
|
@HostListener('window:resize', ['$event'])
|
||||||
@HostListener('window:orientationchange', ['$event'])
|
@HostListener('window:orientationchange', ['$event'])
|
||||||
|
@ -0,0 +1,5 @@
|
|||||||
|
export interface KavitaPlusMetadataBreakdown {
|
||||||
|
totalSeries: number;
|
||||||
|
erroredSeries: number;
|
||||||
|
seriesCompleted: number;
|
||||||
|
}
|
@ -1,64 +0,0 @@
|
|||||||
<ng-container *transloco="let t; read:'scrobbling-providers'">
|
|
||||||
<div class="card mt-2">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="card-title">
|
|
||||||
<div class="container-fluid row mb-2">
|
|
||||||
<div class="col-10 col-sm-11">
|
|
||||||
<h4 id="anilist-token-header">{{t('title')}}
|
|
||||||
@if(!tokenExpired) {
|
|
||||||
<i class="fa-solid fa-circle-check ms-1 confirm-icon" aria-hidden="true" [ngbTooltip]="t('token-valid')"></i>
|
|
||||||
<span class="visually-hidden">{{t('token-valid')}}</span>
|
|
||||||
} @else {
|
|
||||||
<i class="fa-solid fa-circle ms-1 confirm-icon error" aria-hidden="true" [ngbTooltip]="t('token-expired')"></i>
|
|
||||||
<span class="visually-hidden">{{t('token-expired')}}</span>
|
|
||||||
}
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<div class="col-1 text-end">
|
|
||||||
<button class="btn btn-primary btn-sm" [disabled]="!hasValidLicense" (click)="toggleViewMode()">{{isViewMode ? t('edit') : t('cancel')}}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ng-container *ngIf="isViewMode">
|
|
||||||
<div class="container-fluid row">
|
|
||||||
<span class="col-12">
|
|
||||||
<ng-container *ngIf="!hasValidLicense; else showToken">
|
|
||||||
{{t('requires', {product: 'Kavita+'})}}
|
|
||||||
</ng-container>
|
|
||||||
<ng-template #showToken>
|
|
||||||
<ng-container *ngIf="token && token.length > 0; else noToken">
|
|
||||||
<img class="me-2" width="32" height="32" ngSrc="assets/images/ExternalServices/AniList.png" alt="AniList" ngbTooltip="AniList"> {{t('token-set')}}
|
|
||||||
<i class="error fa-solid fa-exclamation-circle" [ngbTooltip]="t('token-expired')" *ngIf="tokenExpired">
|
|
||||||
<span class="visually-hidden">{{t('token-expired')}}</span>
|
|
||||||
</i>
|
|
||||||
</ng-container>
|
|
||||||
<ng-template #noToken>{{t('no-token-set')}}</ng-template>
|
|
||||||
</ng-template>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<div #collapse="ngbCollapse" [(ngbCollapse)]="isViewMode">
|
|
||||||
<p>{{t('instructions', {service: 'AniList'})}}</p>
|
|
||||||
<form [formGroup]="formGroup">
|
|
||||||
<div class="form-group mb-3">
|
|
||||||
<label for="anilist-token">{{t('token-input-label', {service: 'AniList'})}}</label>
|
|
||||||
<textarea id="anilist-token" rows="2" cols="3" class="form-control" formControlName="aniListToken"></textarea>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
|
|
||||||
<a class="flex-fill btn btn-secondary me-2"
|
|
||||||
href="https://anilist.co/api/v2/oauth/authorize?client_id=12809&redirect_url=https://anilist.co/api/v2/oauth/pin&response_type=token"
|
|
||||||
target="_blank" rel="noopener noreferrer">{{t('generate')}}</a>
|
|
||||||
<button type="submit" class="flex-fill btn btn-primary" aria-describedby="anilist-token-header" (click)="saveForm()">{{t('save')}}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
</ng-container>
|
|
@ -1,9 +0,0 @@
|
|||||||
.error {
|
|
||||||
color: var(--error-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-icon {
|
|
||||||
color: var(--primary-color);
|
|
||||||
font-size: 14px;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
@ -1,80 +0,0 @@
|
|||||||
import {
|
|
||||||
ChangeDetectionStrategy,
|
|
||||||
ChangeDetectorRef,
|
|
||||||
Component,
|
|
||||||
DestroyRef,
|
|
||||||
inject,
|
|
||||||
OnInit
|
|
||||||
} from '@angular/core';
|
|
||||||
import { FormControl, FormGroup, Validators, ReactiveFormsModule } from "@angular/forms";
|
|
||||||
import {ToastrService} from "ngx-toastr";
|
|
||||||
import {ScrobbleProvider, ScrobblingService} from "../../_services/scrobbling.service";
|
|
||||||
import {AccountService} from "../../_services/account.service";
|
|
||||||
import { NgbTooltip, NgbCollapse } from '@ng-bootstrap/ng-bootstrap';
|
|
||||||
import { NgIf, NgOptimizedImage } from '@angular/common';
|
|
||||||
import {translate, TranslocoDirective} from "@ngneat/transloco";
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-anilist-key',
|
|
||||||
templateUrl: './anilist-key.component.html',
|
|
||||||
styleUrls: ['./anilist-key.component.scss'],
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
||||||
standalone: true,
|
|
||||||
imports: [NgIf, NgOptimizedImage, NgbTooltip, NgbCollapse, ReactiveFormsModule, TranslocoDirective]
|
|
||||||
})
|
|
||||||
export class AnilistKeyComponent implements OnInit {
|
|
||||||
|
|
||||||
hasValidLicense: boolean = false;
|
|
||||||
|
|
||||||
formGroup: FormGroup = new FormGroup({});
|
|
||||||
token: string = '';
|
|
||||||
isViewMode: boolean = true;
|
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
|
||||||
tokenExpired: boolean = false;
|
|
||||||
|
|
||||||
|
|
||||||
constructor(public accountService: AccountService, private scrobblingService: ScrobblingService, private toastr: ToastrService, private readonly cdRef: ChangeDetectorRef) {
|
|
||||||
this.accountService.hasValidLicense().subscribe(res => {
|
|
||||||
this.hasValidLicense = res;
|
|
||||||
this.cdRef.markForCheck();
|
|
||||||
if (this.hasValidLicense) {
|
|
||||||
this.scrobblingService.getAniListToken().subscribe(token => {
|
|
||||||
this.token = token;
|
|
||||||
this.formGroup.get('aniListToken')?.setValue(token);
|
|
||||||
this.cdRef.markForCheck();
|
|
||||||
});
|
|
||||||
this.scrobblingService.hasTokenExpired(ScrobbleProvider.AniList).subscribe(hasExpired => {
|
|
||||||
this.tokenExpired = hasExpired;
|
|
||||||
this.cdRef.markForCheck();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.formGroup.addControl('aniListToken', new FormControl('', [Validators.required]));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
resetForm() {
|
|
||||||
this.formGroup.get('aniListToken')?.setValue('');
|
|
||||||
this.cdRef.markForCheck();
|
|
||||||
}
|
|
||||||
|
|
||||||
saveForm() {
|
|
||||||
this.scrobblingService.updateAniListToken(this.formGroup.get('aniListToken')!.value).subscribe(() => {
|
|
||||||
this.toastr.success(translate('toasts.anilist-token-updated'));
|
|
||||||
this.token = this.formGroup.get('aniListToken')!.value;
|
|
||||||
this.resetForm();
|
|
||||||
this.isViewMode = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleViewMode() {
|
|
||||||
this.isViewMode = !this.isViewMode;
|
|
||||||
this.resetForm();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
@ -0,0 +1,151 @@
|
|||||||
|
<ng-container *transloco="let t; read:'scrobbling-providers'">
|
||||||
|
<div class="card mt-2">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="card-title">
|
||||||
|
<div class="container-fluid row mb-2">
|
||||||
|
<div class="col-10 col-sm-11">
|
||||||
|
<h4>{{t('title')}}
|
||||||
|
@if(!aniListTokenExpired) {
|
||||||
|
<i class="fa-solid fa-circle-check ms-1 confirm-icon" aria-hidden="true" [ngbTooltip]="t('token-valid')"></i>
|
||||||
|
<span class="visually-hidden">{{t('token-valid')}}</span>
|
||||||
|
} @else {
|
||||||
|
<i class="fa-solid fa-circle ms-1 confirm-icon error" aria-hidden="true" [ngbTooltip]="t('token-expired')"></i>
|
||||||
|
<span class="visually-hidden">{{t('token-expired')}}</span>
|
||||||
|
}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div class="col-1 text-end">
|
||||||
|
<button class="btn btn-primary btn-sm" [disabled]="!hasValidLicense" (click)="toggleViewMode()">{{isViewMode ? t('edit') : t('cancel')}}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if(loaded) {
|
||||||
|
<ng-container *ngIf="isViewMode">
|
||||||
|
<div class="container-fluid row">
|
||||||
|
<span class="col-12">
|
||||||
|
@if(!hasValidLicense) {
|
||||||
|
{{t('requires', {product: 'Kavita+'})}}
|
||||||
|
} @else {
|
||||||
|
<span>
|
||||||
|
<img class="me-2" width="32" height="32" ngSrc="assets/images/ExternalServices/AniList.png" alt="AniList" ngbTooltip="AniList">
|
||||||
|
@if(aniListToken && aniListToken.length > 0) {
|
||||||
|
{{t('token-set')}}
|
||||||
|
} @else {
|
||||||
|
{{t('no-token-set')}}
|
||||||
|
}
|
||||||
|
@if(aniListTokenExpired) {
|
||||||
|
<i class="error fa-solid fa-exclamation-circle" [ngbTooltip]="t('token-expired')">
|
||||||
|
<span class="visually-hidden">{{t('token-expired')}}</span>
|
||||||
|
</i>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="ms-2">
|
||||||
|
<img class="me-2" width="32" height="32" ngSrc="assets/images/ExternalServices/MAL.png" alt="MAL" ngbTooltip="MAL">
|
||||||
|
@if (malToken && malToken.length > 0) {
|
||||||
|
{{t('token-set')}}
|
||||||
|
}
|
||||||
|
@else {
|
||||||
|
{{t('no-token-set')}}
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
@if(malTokenExpired) {
|
||||||
|
<i class="error fa-solid fa-exclamation-circle" [ngbTooltip]="t('token-expired')">
|
||||||
|
<span class="visually-hidden">{{t('token-expired')}}</span>
|
||||||
|
</i>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!aniListToken && !malToken) {
|
||||||
|
{{t('no-token-set')}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<div [(ngbCollapse)]="isViewMode">
|
||||||
|
<p>{{t('generic-instructions')}}</p>
|
||||||
|
<form [formGroup]="formGroup">
|
||||||
|
|
||||||
|
<div class="mt-3" ngbAccordion #accordion [destroyOnHide]="false" [closeOthers]="true">
|
||||||
|
<div ngbAccordionItem="anilist">
|
||||||
|
<h2 ngbAccordionHeader>
|
||||||
|
<button ngbAccordionButton id="anilist-token-header">
|
||||||
|
AniList
|
||||||
|
@if(!aniListTokenExpired) {
|
||||||
|
<i class="fa-solid fa-circle-check ms-1 confirm-icon" aria-hidden="true" [ngbTooltip]="t('token-valid')"></i>
|
||||||
|
<span class="visually-hidden">{{t('token-valid')}}</span>
|
||||||
|
} @else {
|
||||||
|
<i class="fa-solid fa-circle ms-1 confirm-icon error" aria-hidden="true" [ngbTooltip]="t('token-expired')"></i>
|
||||||
|
<span class="visually-hidden">{{t('token-expired')}}</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div ngbAccordionCollapse>
|
||||||
|
<div ngbAccordionBody>
|
||||||
|
<ng-template>
|
||||||
|
<p>{{t('instructions', {service: 'AniList'})}}</p>
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<label for="anilist-token">{{t('token-input-label', {service: 'AniList'})}}</label>
|
||||||
|
<textarea id="anilist-token" rows="2" cols="3" class="form-control" formControlName="aniListToken"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
|
||||||
|
<a class="flex-fill btn btn-secondary me-2"
|
||||||
|
href="https://anilist.co/api/v2/oauth/authorize?client_id=12809&redirect_url=https://anilist.co/api/v2/oauth/pin&response_type=token"
|
||||||
|
target="_blank" rel="noopener noreferrer">{{t('generate')}}</a>
|
||||||
|
<button type="button" class="flex-fill btn btn-primary" aria-describedby="anilist-token-header" (click)="saveAniListForm()">{{t('save')}}</button>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ngbAccordionItem="mal">
|
||||||
|
<h2 ngbAccordionHeader>
|
||||||
|
<button ngbAccordionButton id="mal-token-header">
|
||||||
|
MAL
|
||||||
|
@if(!malTokenExpired) {
|
||||||
|
<i class="fa-solid fa-circle-check ms-1 confirm-icon" aria-hidden="true" [ngbTooltip]="t('token-valid')"></i>
|
||||||
|
<span class="visually-hidden">{{t('token-valid')}}</span>
|
||||||
|
} @else {
|
||||||
|
<i class="fa-solid fa-circle ms-1 confirm-icon error" aria-hidden="true" [ngbTooltip]="t('token-expired')"></i>
|
||||||
|
<span class="visually-hidden">{{t('token-expired')}}</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div ngbAccordionCollapse>
|
||||||
|
<div ngbAccordionBody>
|
||||||
|
<ng-template>
|
||||||
|
<p>{{t('mal-instructions', {service: 'MAL'})}}</p>
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<label for="mal-token">{{t('mal-token-input-label')}}</label>
|
||||||
|
<input type="text" id="mal-token" class="form-control" formControlName="malClientId"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<label for="mal-username">{{t('mal-username-input-label')}}</label>
|
||||||
|
<input type="text" id="mal-username" class="form-control" formControlName="malUsername"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
|
||||||
|
<button type="button" class="flex-fill btn btn-primary" aria-describedby="mal-token-header" (click)="saveMalForm()">{{t('save')}}</button>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<app-loading [loading]="!loaded" [message]="t('loading')"></app-loading>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</ng-container>
|
@ -0,0 +1,126 @@
|
|||||||
|
import {ChangeDetectorRef, Component, ContentChild, DestroyRef, ElementRef, inject, OnInit} from '@angular/core';
|
||||||
|
import {NgIf, NgOptimizedImage} from "@angular/common";
|
||||||
|
import {
|
||||||
|
NgbAccordionBody,
|
||||||
|
NgbAccordionButton,
|
||||||
|
NgbAccordionCollapse, NgbAccordionDirective, NgbAccordionHeader, NgbAccordionItem,
|
||||||
|
NgbCollapse,
|
||||||
|
NgbTooltip
|
||||||
|
} from "@ng-bootstrap/ng-bootstrap";
|
||||||
|
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
|
||||||
|
import {Select2Module} from "ng-select2-component";
|
||||||
|
import {translate, TranslocoDirective} from "@ngneat/transloco";
|
||||||
|
import {AccountService} from "../../_services/account.service";
|
||||||
|
import {ScrobbleProvider, ScrobblingService} from "../../_services/scrobbling.service";
|
||||||
|
import {ToastrService} from "ngx-toastr";
|
||||||
|
import {ManageAlertsComponent} from "../../admin/manage-alerts/manage-alerts.component";
|
||||||
|
import {LoadingComponent} from "../../shared/loading/loading.component";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-manage-scrobbling-providers',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
NgIf,
|
||||||
|
NgOptimizedImage,
|
||||||
|
NgbTooltip,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
Select2Module,
|
||||||
|
TranslocoDirective,
|
||||||
|
NgbCollapse,
|
||||||
|
ManageAlertsComponent,
|
||||||
|
NgbAccordionBody,
|
||||||
|
NgbAccordionButton,
|
||||||
|
NgbAccordionCollapse,
|
||||||
|
NgbAccordionDirective,
|
||||||
|
NgbAccordionHeader,
|
||||||
|
NgbAccordionItem,
|
||||||
|
LoadingComponent,
|
||||||
|
],
|
||||||
|
templateUrl: './manage-scrobbling-providers.component.html',
|
||||||
|
styleUrl: './manage-scrobbling-providers.component.scss'
|
||||||
|
})
|
||||||
|
export class ManageScrobblingProvidersComponent implements OnInit {
|
||||||
|
public readonly accountService = inject(AccountService);
|
||||||
|
private readonly scrobblingService = inject(ScrobblingService);
|
||||||
|
private readonly toastr = inject(ToastrService);
|
||||||
|
private readonly cdRef = inject(ChangeDetectorRef);
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
|
hasValidLicense: boolean = false;
|
||||||
|
|
||||||
|
formGroup: FormGroup = new FormGroup({});
|
||||||
|
aniListToken: string = '';
|
||||||
|
malToken: string = '';
|
||||||
|
malUsername: string = '';
|
||||||
|
|
||||||
|
aniListTokenExpired: boolean = false;
|
||||||
|
malTokenExpired: boolean = false;
|
||||||
|
|
||||||
|
isViewMode: boolean = true;
|
||||||
|
loaded: boolean = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.accountService.hasValidLicense().subscribe(res => {
|
||||||
|
this.hasValidLicense = res;
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
if (this.hasValidLicense) {
|
||||||
|
this.scrobblingService.getAniListToken().subscribe(token => {
|
||||||
|
this.aniListToken = token;
|
||||||
|
this.formGroup.get('aniListToken')?.setValue(token);
|
||||||
|
this.loaded = true;
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
});
|
||||||
|
this.scrobblingService.getMalToken().subscribe(dto => {
|
||||||
|
this.malToken = dto.accessToken;
|
||||||
|
this.malUsername = dto.username;
|
||||||
|
this.formGroup.get('malToken')?.setValue(this.malToken);
|
||||||
|
this.formGroup.get('malUsername')?.setValue(this.malUsername);
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
});
|
||||||
|
this.scrobblingService.hasTokenExpired(ScrobbleProvider.AniList).subscribe(hasExpired => {
|
||||||
|
this.aniListTokenExpired = hasExpired;
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.formGroup.addControl('aniListToken', new FormControl('', [Validators.required]));
|
||||||
|
this.formGroup.addControl('malClientId', new FormControl('', [Validators.required]));
|
||||||
|
this.formGroup.addControl('malUsername', new FormControl('', [Validators.required]));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
resetForm() {
|
||||||
|
this.formGroup.get('aniListToken')?.setValue('');
|
||||||
|
this.formGroup.get('malClientId')?.setValue('');
|
||||||
|
this.formGroup.get('malUsername')?.setValue('');
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
saveAniListForm() {
|
||||||
|
this.scrobblingService.updateAniListToken(this.formGroup.get('aniListToken')!.value).subscribe(() => {
|
||||||
|
this.toastr.success(translate('toasts.anilist-token-updated'));
|
||||||
|
this.aniListToken = this.formGroup.get('aniListToken')!.value;
|
||||||
|
this.resetForm();
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
saveMalForm() {
|
||||||
|
this.scrobblingService.updateMalToken(this.formGroup.get('malUsername')!.value, this.formGroup.get('malClientId')!.value).subscribe(() => {
|
||||||
|
this.toastr.success(translate('toasts.mal-clientId-updated'));
|
||||||
|
this.malToken = this.formGroup.get('malClientId')!.value;
|
||||||
|
this.malUsername = this.formGroup.get('malUsername')!.value;
|
||||||
|
this.resetForm();
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleViewMode() {
|
||||||
|
this.isViewMode = !this.isViewMode;
|
||||||
|
this.resetForm();
|
||||||
|
}
|
||||||
|
}
|
@ -13,7 +13,7 @@
|
|||||||
<app-change-email></app-change-email>
|
<app-change-email></app-change-email>
|
||||||
<app-change-password></app-change-password>
|
<app-change-password></app-change-password>
|
||||||
<app-change-age-restriction></app-change-age-restriction>
|
<app-change-age-restriction></app-change-age-restriction>
|
||||||
<app-anilist-key></app-anilist-key>
|
<app-manage-scrobbling-providers></app-manage-scrobbling-providers>
|
||||||
}
|
}
|
||||||
|
|
||||||
@defer (when tab.fragment === FragmentID.Preferences; prefetch on idle) {
|
@defer (when tab.fragment === FragmentID.Preferences; prefetch on idle) {
|
||||||
|
@ -9,7 +9,7 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
|
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||||
import { ToastrService } from 'ngx-toastr';
|
import { ToastrService } from 'ngx-toastr';
|
||||||
import {take, tap} from 'rxjs/operators';
|
import {take} from 'rxjs/operators';
|
||||||
import { Title } from '@angular/platform-browser';
|
import { Title } from '@angular/platform-browser';
|
||||||
import {
|
import {
|
||||||
readingDirections,
|
readingDirections,
|
||||||
@ -39,7 +39,6 @@ import { ManageDevicesComponent } from '../manage-devices/manage-devices.compone
|
|||||||
import { ThemeManagerComponent } from '../theme-manager/theme-manager.component';
|
import { ThemeManagerComponent } from '../theme-manager/theme-manager.component';
|
||||||
import { ApiKeyComponent } from '../api-key/api-key.component';
|
import { ApiKeyComponent } from '../api-key/api-key.component';
|
||||||
import { ColorPickerModule } from 'ngx-color-picker';
|
import { ColorPickerModule } from 'ngx-color-picker';
|
||||||
import { AnilistKeyComponent } from '../anilist-key/anilist-key.component';
|
|
||||||
import { ChangeAgeRestrictionComponent } from '../change-age-restriction/change-age-restriction.component';
|
import { ChangeAgeRestrictionComponent } from '../change-age-restriction/change-age-restriction.component';
|
||||||
import { ChangePasswordComponent } from '../change-password/change-password.component';
|
import { ChangePasswordComponent } from '../change-password/change-password.component';
|
||||||
import { ChangeEmailComponent } from '../change-email/change-email.component';
|
import { ChangeEmailComponent } from '../change-email/change-email.component';
|
||||||
@ -50,6 +49,7 @@ import {LocalizationService} from "../../_services/localization.service";
|
|||||||
import {Language} from "../../_models/metadata/language";
|
import {Language} from "../../_models/metadata/language";
|
||||||
import {translate, TranslocoDirective} from "@ngneat/transloco";
|
import {translate, TranslocoDirective} from "@ngneat/transloco";
|
||||||
import {LoadingComponent} from "../../shared/loading/loading.component";
|
import {LoadingComponent} from "../../shared/loading/loading.component";
|
||||||
|
import {ManageScrobblingProvidersComponent} from "../manage-scrobbling-providers/manage-scrobbling-providers.component";
|
||||||
|
|
||||||
enum AccordionPanelID {
|
enum AccordionPanelID {
|
||||||
ImageReader = 'image-reader',
|
ImageReader = 'image-reader',
|
||||||
@ -74,10 +74,10 @@ enum FragmentID {
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [SideNavCompanionBarComponent, NgbNav, NgFor, NgbNavItem, NgbNavItemRole, NgbNavLink, RouterLink, NgbNavContent, NgIf, ChangeEmailComponent,
|
imports: [SideNavCompanionBarComponent, NgbNav, NgFor, NgbNavItem, NgbNavItemRole, NgbNavLink, RouterLink, NgbNavContent, NgIf, ChangeEmailComponent,
|
||||||
ChangePasswordComponent, ChangeAgeRestrictionComponent, AnilistKeyComponent, ReactiveFormsModule, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader,
|
ChangePasswordComponent, ChangeAgeRestrictionComponent, ReactiveFormsModule, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader,
|
||||||
NgbAccordionToggle, NgbAccordionButton, NgbCollapse, NgbAccordionCollapse, NgbAccordionBody, NgbTooltip, NgTemplateOutlet, ColorPickerModule, ApiKeyComponent,
|
NgbAccordionToggle, NgbAccordionButton, NgbCollapse, NgbAccordionCollapse, NgbAccordionBody, NgbTooltip, NgTemplateOutlet, ColorPickerModule, ApiKeyComponent,
|
||||||
ThemeManagerComponent, ManageDevicesComponent, UserStatsComponent, UserScrobbleHistoryComponent, UserHoldsComponent, NgbNavOutlet, TitleCasePipe, SentenceCasePipe,
|
ThemeManagerComponent, ManageDevicesComponent, UserStatsComponent, UserScrobbleHistoryComponent, UserHoldsComponent, NgbNavOutlet, TitleCasePipe, SentenceCasePipe,
|
||||||
TranslocoDirective, LoadingComponent],
|
TranslocoDirective, LoadingComponent, ManageScrobblingProvidersComponent],
|
||||||
})
|
})
|
||||||
export class UserPreferencesComponent implements OnInit, OnDestroy {
|
export class UserPreferencesComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
@ -166,7 +166,6 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
this.route.fragment.subscribe(frag => {
|
this.route.fragment.subscribe(frag => {
|
||||||
const tab = this.tabs.filter(item => item.fragment === frag);
|
const tab = this.tabs.filter(item => item.fragment === frag);
|
||||||
console.log('tab: ', tab);
|
|
||||||
if (tab.length > 0) {
|
if (tab.length > 0) {
|
||||||
this.active = tab[0];
|
this.active = tab[0];
|
||||||
} else {
|
} else {
|
||||||
|
@ -294,11 +294,17 @@
|
|||||||
"no-token-set": "No Token Set",
|
"no-token-set": "No Token Set",
|
||||||
"token-set": "Token Set",
|
"token-set": "Token Set",
|
||||||
"generate": "Generate",
|
"generate": "Generate",
|
||||||
|
"generic-instructions": "Fill out information about different External Services you have to allow Kavita+ to interact with them.",
|
||||||
"instructions": "First time users should click on \"{{scrobbling-providers.generate}}\" below to allow Kavita+ to talk with {{service}}. Once you authorize the program, copy and paste the token in the input below. You can regenerate your token at any time.",
|
"instructions": "First time users should click on \"{{scrobbling-providers.generate}}\" below to allow Kavita+ to talk with {{service}}. Once you authorize the program, copy and paste the token in the input below. You can regenerate your token at any time.",
|
||||||
|
"mal-instructions": "Kavita uses a MAL Client Id for authentication. Create a new Client for Kavita and once approved, supply the client Id and your username.",
|
||||||
|
"scrobbling-applicable-label": "Scrobbling Applicable",
|
||||||
"token-input-label": "{{service}} Token Goes Here",
|
"token-input-label": "{{service}} Token Goes Here",
|
||||||
|
"mal-token-input-label": "MAL Client Id",
|
||||||
|
"mal-username-input-label": "MAL Username",
|
||||||
"edit": "{{common.edit}}",
|
"edit": "{{common.edit}}",
|
||||||
"cancel": "{{common.cancel}}",
|
"cancel": "{{common.cancel}}",
|
||||||
"save": "{{common.save}}"
|
"save": "{{common.save}}",
|
||||||
|
"loading": "{{common.loading}}"
|
||||||
},
|
},
|
||||||
|
|
||||||
"typeahead": {
|
"typeahead": {
|
||||||
@ -937,6 +943,7 @@
|
|||||||
"metadata-tab": "Metadata",
|
"metadata-tab": "Metadata",
|
||||||
"cover-tab": "Cover",
|
"cover-tab": "Cover",
|
||||||
"info-tab": "Info",
|
"info-tab": "Info",
|
||||||
|
"progress-tab": "Progress",
|
||||||
"no-summary": "No Summary available.",
|
"no-summary": "No Summary available.",
|
||||||
"writers-title": "{{series-metadata-detail.writers-title}}",
|
"writers-title": "{{series-metadata-detail.writers-title}}",
|
||||||
"genres-title": "{{series-metadata-detail.genres-title}}",
|
"genres-title": "{{series-metadata-detail.genres-title}}",
|
||||||
@ -1025,6 +1032,7 @@
|
|||||||
"id-title": "ID",
|
"id-title": "ID",
|
||||||
"links-title": "{{series-metadata-detail.links-title}}",
|
"links-title": "{{series-metadata-detail.links-title}}",
|
||||||
"isbn-title": "ISBN",
|
"isbn-title": "ISBN",
|
||||||
|
"sort-order-title": "Sort Order",
|
||||||
"last-read-title": "Last Read",
|
"last-read-title": "Last Read",
|
||||||
"less-than-hour": "<1 Hour",
|
"less-than-hour": "<1 Hour",
|
||||||
"range-hours": "{{value}} {{hourWord}}",
|
"range-hours": "{{value}} {{hourWord}}",
|
||||||
@ -1551,6 +1559,23 @@
|
|||||||
"promote-tooltip": "Promotion means that the tag can be seen server-wide, not just for admin users. All series that have this tag will still have user-access restrictions placed on them."
|
"promote-tooltip": "Promotion means that the tag can be seen server-wide, not just for admin users. All series that have this tag will still have user-access restrictions placed on them."
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"import-mal-collection-modal": {
|
||||||
|
"close": "{{common.close}}",
|
||||||
|
"title": "MAL Interest Stack Import",
|
||||||
|
"description": "Import your MAL Interest Stacks and create Collections within Kavita",
|
||||||
|
"series-count": "{{common.series-count}}",
|
||||||
|
"restack-count": "{{num}} Restacks"
|
||||||
|
},
|
||||||
|
|
||||||
|
"edit-chapter-progress": {
|
||||||
|
"user-header": "User",
|
||||||
|
"page-read-header": "Pages Read",
|
||||||
|
"date-created-header": "Created (UTC)",
|
||||||
|
"date-updated-header": "Last Updated (UTC)",
|
||||||
|
"action-header": "{{common.edit}}",
|
||||||
|
"edit-alt": "{{common.edit}}"
|
||||||
|
},
|
||||||
|
|
||||||
"import-cbl-modal": {
|
"import-cbl-modal": {
|
||||||
"close": "{{common.close}}",
|
"close": "{{common.close}}",
|
||||||
"title": "CBL Import",
|
"title": "CBL Import",
|
||||||
@ -1759,6 +1784,15 @@
|
|||||||
"y-axis-label": "Reading Events"
|
"y-axis-label": "Reading Events"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"kavitaplus-metadata-breakdown-stats": {
|
||||||
|
"title": "Kavita+ Metadata Breakdown",
|
||||||
|
"no-data": "No data",
|
||||||
|
"errored-series-label": "Errored Series",
|
||||||
|
"completed-series-label": "Completed Series",
|
||||||
|
"total-series-progress-label": "Series Processed: {{percent}}",
|
||||||
|
"complete": "All Series have metadata"
|
||||||
|
},
|
||||||
|
|
||||||
"file-breakdown-stats": {
|
"file-breakdown-stats": {
|
||||||
"format-title": "Format",
|
"format-title": "Format",
|
||||||
"format-tooltip": "Not Classified means Kavita has not scanned some files. This occurs on old files existing prior to v0.7. You may need to run a forced scan via Library settings modal.",
|
"format-tooltip": "Not Classified means Kavita has not scanned some files. This occurs on old files existing prior to v0.7. You may need to run a forced scan via Library settings modal.",
|
||||||
@ -2125,6 +2159,7 @@
|
|||||||
"view-series": "View Series",
|
"view-series": "View Series",
|
||||||
"clear": "{{common.clear}}",
|
"clear": "{{common.clear}}",
|
||||||
"import-cbl": "Import CBL",
|
"import-cbl": "Import CBL",
|
||||||
|
"import-mal-stack": "Import MAL Stack",
|
||||||
"read": "Read",
|
"read": "Read",
|
||||||
"add-rule-group-and": "Add Rule Group (AND)",
|
"add-rule-group-and": "Add Rule Group (AND)",
|
||||||
"add-rule-group-or": "Add Rule Group (OR)",
|
"add-rule-group-or": "Add Rule Group (OR)",
|
||||||
|
@ -1,10 +1,9 @@
|
|||||||
.progress {
|
.progress {
|
||||||
background-color: var(--progress-bg-color);
|
background-color: var(--progress-bg-color);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar {
|
.progress-bar {
|
||||||
background-color: var(--progress-bar-color);
|
background-color: var(--progress-bar-color) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar-striped {
|
.progress-bar-striped {
|
||||||
|
364
openapi.json
364
openapi.json
@ -7,7 +7,7 @@
|
|||||||
"name": "GPL-3.0",
|
"name": "GPL-3.0",
|
||||||
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
||||||
},
|
},
|
||||||
"version": "0.7.14.6"
|
"version": "0.7.14.8"
|
||||||
},
|
},
|
||||||
"servers": [
|
"servers": [
|
||||||
{
|
{
|
||||||
@ -886,6 +886,55 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/Admin/update-chapter-progress": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Admin"
|
||||||
|
],
|
||||||
|
"summary": "Set the progress information for a particular user",
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/UpdateUserProgressDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"text/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/UpdateUserProgressDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"application/*+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/UpdateUserProgressDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success",
|
||||||
|
"content": {
|
||||||
|
"text/plain": {
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"text/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/Book/{chapterId}/book-info": {
|
"/api/Book/{chapterId}/book-info": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@ -1507,6 +1556,45 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/Collection/mal-stacks": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Collection"
|
||||||
|
],
|
||||||
|
"summary": "For the authenticated user, if they have an active Kavita+ subscription and a MAL username on record,\r\nfetch their Mal interest stacks (including restacks)",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success",
|
||||||
|
"content": {
|
||||||
|
"text/plain": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/MalStackDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/MalStackDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"text/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/MalStackDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/Device/create": {
|
"/api/Device/create": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@ -6126,6 +6214,56 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/Reader/all-chapter-progress": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Reader"
|
||||||
|
],
|
||||||
|
"summary": "Get all progress events for a given chapter",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "chapterId",
|
||||||
|
"in": "query",
|
||||||
|
"description": "",
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success",
|
||||||
|
"content": {
|
||||||
|
"text/plain": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/FullProgressDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/FullProgressDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"text/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/FullProgressDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/ReadingList": {
|
"/api/ReadingList": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@ -7350,6 +7488,35 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/Scrobbling/mal-token": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Scrobbling"
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success",
|
||||||
|
"content": {
|
||||||
|
"text/plain": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/MalUserInfoDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/MalUserInfoDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"text/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/MalUserInfoDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/Scrobbling/update-anilist-token": {
|
"/api/Scrobbling/update-anilist-token": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@ -7383,6 +7550,39 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/Scrobbling/update-mal-token": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Scrobbling"
|
||||||
|
],
|
||||||
|
"summary": "Update the current user's MAL token (Client ID) and Username",
|
||||||
|
"requestBody": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/MalUserInfoDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"text/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/MalUserInfoDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"application/*+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/MalUserInfoDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/Scrobbling/token-expired": {
|
"/api/Scrobbling/token-expired": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@ -10832,6 +11032,45 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/Stats/kavitaplus-metadata-breakdown": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Stats"
|
||||||
|
],
|
||||||
|
"summary": "Returns for Kavita+ the number of Series that have been processed, errored, and not processed",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success",
|
||||||
|
"content": {
|
||||||
|
"text/plain": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/Int32StatCount"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/Int32StatCount"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"text/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/Int32StatCount"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/Stream/dashboard": {
|
"/api/Stream/dashboard": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@ -12754,6 +12993,16 @@
|
|||||||
"description": "The JWT for the user's AniList account. Expires after a year.",
|
"description": "The JWT for the user's AniList account. Expires after a year.",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
},
|
},
|
||||||
|
"malUserName": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The Username of the MAL user",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"malAccessToken": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The Client ID for the user's MAL account. User should create a client on MAL for this.",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
"scrobbleHolds": {
|
"scrobbleHolds": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
@ -15766,6 +16015,49 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
"FullProgressDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"chapterId": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"pagesRead": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"lastModified": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"lastModifiedUtc": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"created": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"createdUtc": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"appUserId": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"userName": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "A full progress Record from the DB (not all data, only what's needed for API)"
|
||||||
|
},
|
||||||
"Genre": {
|
"Genre": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -16277,6 +16569,58 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
"MalStackDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"stackId": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"author": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"seriesCount": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"restackCount": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"existingId": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "If an existing collection exists within Kavita",
|
||||||
|
"format": "int32"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "Represents an Interest Stack from MAL"
|
||||||
|
},
|
||||||
|
"MalUserInfoDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"username": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"accessToken": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "This is actually the Client Id",
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "Information about a User's MAL connection"
|
||||||
|
},
|
||||||
"MangaFile": {
|
"MangaFile": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -20133,6 +20477,24 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
"UpdateUserProgressDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"pageNum": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"lastModifiedUtc": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"createdUtc": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
"UpdateUserReviewDto": {
|
"UpdateUserReviewDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user