Misc bunch of changes (#2815)

This commit is contained in:
Joe Milazzo 2024-03-23 16:20:16 -05:00 committed by GitHub
parent 18792b7b56
commit 63c9bff32e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
81 changed files with 4567 additions and 339 deletions

View File

@ -7,6 +7,7 @@ using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Progress;
using API.DTOs.Reader;
using API.Entities;
using API.Entities.Enums;

View File

@ -1,5 +1,6 @@
using System.Threading.Tasks;
using API.Data.ManualMigrations;
using API.DTOs.Progress;
using API.Entities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
@ -29,4 +30,15 @@ public class AdminController : BaseApiController
var users = await _userManager.GetUsersInRoleAsync("Admin");
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));
}
}

View File

@ -3,10 +3,12 @@ using System.Collections.Generic;
using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.DTOs.Collection;
using API.DTOs.CollectionTags;
using API.Entities.Metadata;
using API.Extensions;
using API.Services;
using API.Services.Plus;
using Kavita.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@ -23,14 +25,16 @@ public class CollectionController : BaseApiController
private readonly IUnitOfWork _unitOfWork;
private readonly ICollectionTagService _collectionService;
private readonly ILocalizationService _localizationService;
private readonly IExternalMetadataService _externalMetadataService;
/// <inheritdoc />
public CollectionController(IUnitOfWork unitOfWork, ICollectionTagService collectionService,
ILocalizationService localizationService)
ILocalizationService localizationService, IExternalMetadataService externalMetadataService)
{
_unitOfWork = unitOfWork;
_collectionService = collectionService;
_localizationService = localizationService;
_externalMetadataService = externalMetadataService;
}
/// <summary>
@ -168,4 +172,15 @@ public class CollectionController : BaseApiController
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()));
}
}

View File

@ -13,6 +13,7 @@ using API.DTOs.CollectionTags;
using API.DTOs.Filtering;
using API.DTOs.Filtering.v2;
using API.DTOs.OPDS;
using API.DTOs.Progress;
using API.DTOs.Search;
using API.Entities;
using API.Entities.Enums;

View File

@ -1,6 +1,7 @@
using System.Threading.Tasks;
using API.Data;
using API.DTOs;
using API.DTOs.Progress;
using API.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

View File

@ -7,8 +7,8 @@ using API.Constants;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Filtering;
using API.DTOs.Filtering.v2;
using API.DTOs.Progress;
using API.DTOs.Reader;
using API.Entities;
using API.Entities.Enums;
@ -880,4 +880,21 @@ public class ReaderController : BaseApiController
await _unitOfWork.CommitAsync();
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()));
}
}

View File

@ -52,6 +52,23 @@ public class ScrobblingController : BaseApiController
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>
/// Update the current user's AniList token
/// </summary>
@ -76,6 +93,26 @@ public class ScrobblingController : BaseApiController
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>
/// Checks if the current Scrobbling token for the given Provider has expired for the current user
/// </summary>

View File

@ -457,6 +457,7 @@ public class SettingsController : BaseApiController
}
}
/// <summary>
/// All values allowed for Task Scheduling APIs. A custom cron job is not included. Disabled is not applicable for Cleanup.
/// </summary>

View File

@ -8,6 +8,7 @@ using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Services;
using API.Services.Plus;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
@ -22,14 +23,16 @@ public class StatsController : BaseApiController
private readonly IUnitOfWork _unitOfWork;
private readonly UserManager<AppUser> _userManager;
private readonly ILocalizationService _localizationService;
private readonly ILicenseService _licenseService;
public StatsController(IStatisticService statService, IUnitOfWork unitOfWork,
UserManager<AppUser> userManager, ILocalizationService localizationService)
UserManager<AppUser> userManager, ILocalizationService localizationService, ILicenseService licenseService)
{
_statService = statService;
_unitOfWork = unitOfWork;
_userManager = userManager;
_localizationService = localizationService;
_licenseService = licenseService;
}
[HttpGet("user/{userId}/read")]
@ -181,6 +184,18 @@ public class StatsController : BaseApiController
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());
}
}

View 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; }
}

View 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; }
}

View File

@ -1,7 +1,7 @@
using System;
using System.ComponentModel.DataAnnotations;
namespace API.DTOs;
namespace API.DTOs.Progress;
#nullable enable
public class ProgressDto

View 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; }
}

View 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; }
}

View 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; }
}

File diff suppressed because it is too large Load Diff

View 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");
}
}
}

View File

@ -97,6 +97,12 @@ namespace API.Data.Migrations
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("MalAccessToken")
.HasColumnType("TEXT");
b.Property<string>("MalUserName")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");

View File

@ -5,8 +5,10 @@ using System.Text;
using System.Threading.Tasks;
using API.Data.ManualMigrations;
using API.DTOs;
using API.DTOs.Progress;
using API.Entities;
using API.Entities.Enums;
using API.Extensions.QueryExtensions;
using API.Services.Tasks.Scanner.Parser;
using AutoMapper;
using AutoMapper.QueryableExtensions;
@ -36,6 +38,7 @@ public interface IAppUserProgressRepository
Task<DateTime?> GetLatestProgressForSeries(int seriesId, int userId);
Task<DateTime?> GetFirstProgressForSeries(int seriesId, int userId);
Task UpdateAllProgressThatAreMoreThanChapterPages();
Task<IList<FullProgressDto>> GetUserProgressForChapter(int chapterId, int userId = 0);
}
#nullable disable
public class AppUserProgressRepository : IAppUserProgressRepository
@ -233,6 +236,33 @@ public class AppUserProgressRepository : IAppUserProgressRepository
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
public async Task<AppUserProgress?> GetUserProgressAsync(int chapterId, int userId)
{

View File

@ -63,6 +63,15 @@ public class AppUser : IdentityUser<int>, IHasConcurrencyToken
/// <remarks>Requires Kavita+ Subscription</remarks>
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>
/// A list of Series the user doesn't want scrobbling for
/// </summary>

View File

@ -1,5 +1,7 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using API.Entities.Metadata;
using API.Services.Plus;
using Microsoft.EntityFrameworkCore;
namespace API.Entities;
@ -41,6 +43,21 @@ public class CollectionTag
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>
/// Not Used due to not using concurrency update
/// </summary>

View File

@ -10,6 +10,7 @@ using API.DTOs.Filtering;
using API.DTOs.Filtering.v2;
using API.DTOs.MediaErrors;
using API.DTOs.Metadata;
using API.DTOs.Progress;
using API.DTOs.Reader;
using API.DTOs.ReadingLists;
using API.DTOs.Recommendation;

View File

@ -6,6 +6,7 @@ using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Collection;
using API.DTOs.Recommendation;
using API.DTOs.Scrobbling;
using API.DTOs.SeriesDetail;
@ -61,6 +62,8 @@ public interface IExternalMetadataService
/// <param name="libraryType"></param>
/// <returns></returns>
Task GetNewSeriesData(int seriesId, LibraryType libraryType);
Task<IList<MalStackDto>> GetStacksForUser(int userId);
}
public class ExternalMetadataService : IExternalMetadataService
@ -70,7 +73,8 @@ public class ExternalMetadataService : IExternalMetadataService
private readonly IMapper _mapper;
private readonly ILicenseService _licenseService;
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()
{
Recommendations = null,
@ -137,12 +141,15 @@ public class ExternalMetadataService : IExternalMetadataService
public async Task ForceKavitaPlusRefresh(int seriesId)
{
if (!await _licenseService.HasActiveLicense()) return;
// Remove from Blacklist if applicable
var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeBySeriesIdAsync(seriesId);
if (!IsPlusEligible(libraryType)) return;
// Remove from Blacklist if applicable
await _unitOfWork.ExternalSeriesMetadataRepository.RemoveFromBlacklist(seriesId);
var metadata = await _unitOfWork.ExternalSeriesMetadataRepository.GetExternalSeriesMetadata(seriesId);
if (metadata == null) return;
metadata.ValidUntilUtc = DateTime.UtcNow.Subtract(_externalSeriesMetadataCache);
await _unitOfWork.CommitAsync();
}
@ -170,10 +177,50 @@ public class ExternalMetadataService : IExternalMetadataService
// Prefetch SeriesDetail data
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>
/// Retrieves Metadata about a Recommended External Series
/// </summary>

View File

@ -94,6 +94,7 @@ public class ScrobblingService : IScrobblingService
ScrobbleProvider.AniList
};
private const string UnknownSeriesErrorMessage = "Series cannot be matched for 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),
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);
await _unitOfWork.CommitAsync();
_logger.LogDebug("Added Scrobbling Read update on {SeriesName} with Userid {UserId} ", series.Name, userId);
@ -826,6 +819,20 @@ public class ScrobblingService : IScrobblingService
try
{
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);
evt.IsProcessed = true;
evt.ProcessDateUtc = DateTime.UtcNow;
@ -870,6 +877,7 @@ public class ScrobblingService : IScrobblingService
}
}
private static bool DoesUserHaveProviderAndValid(ScrobbleEvent readEvent)
{
var userProviders = GetUserProviders(readEvent.AppUser);

View File

@ -9,6 +9,7 @@ using API.Comparators;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Progress;
using API.DTOs.Reader;
using API.Entities;
using API.Entities.Enums;

View File

@ -9,6 +9,7 @@ using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Extensions.QueryExtensions;
using API.Services.Plus;
using API.Services.Tasks.Scanner.Parser;
using AutoMapper;
using AutoMapper.QueryableExtensions;
@ -33,6 +34,7 @@ public interface IStatisticService
IEnumerable<StatCount<int>> GetWordsReadCountByYear(int userId = 0);
Task UpdateServerStatistics();
Task<long> TimeSpentReadingForUsersAsync(IList<int> userIds, IList<int> libraryIds);
Task<KavitaPlusMetadataBreakdownDto> GetKavitaPlusMetadataBreakdown();
}
/// <summary>
@ -531,6 +533,29 @@ public class StatisticService : IStatisticService
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)
{
var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList();

View File

@ -9,6 +9,7 @@ using API.Entities.Enums;
using API.Extensions;
using API.Services.Tasks.Scanner.Parser;
using API.SignalR;
using ExCSS;
using Kavita.Common.Helpers;
using Microsoft.Extensions.Logging;
@ -448,22 +449,42 @@ public class ParseScannedFiles
var infos = scannedSeries[series].Where(info => info.Volumes == volume.Key).ToList();
IList<ParserInfo> chapters;
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
.OrderBy(info => info.SpecialIndex)
.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
.OrderByNatural(info => info.Chapters)
.ToList();
foreach (var chapter in chapters)
{
chapter.IssueOrder = counter;
counter++;
}
return;
}
var counter = 0f;
counter = 0f;
var prevIssue = string.Empty;
foreach (var chapter in chapters)
{

View File

@ -95,6 +95,11 @@ public class BasicParser(IDirectoryService directoryService, IDefaultParser imag
// Patch in other information from ComicInfo
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
if (ret.IsSpecial)
{

View File

@ -22,6 +22,7 @@ public class BookParser(IDirectoryService directoryService, IBookService bookSer
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
info.Series = Parser.ParseSeries(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);
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;
}
}
}

View File

@ -0,0 +1,8 @@
export interface MalStack {
title: string;
stackId: number;
url: string;
author?: string;
seriesCount: number;
restackCount: number;
}

View 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;
}

View File

@ -5,6 +5,7 @@ import { environment } from 'src/environments/environment';
import { CollectionTag } from '../_models/collection-tag';
import { TextResonse } from '../_types/text-response';
import { ImageService } from './image.service';
import {MalStack} from "../_models/collection/mal-stack";
@Injectable({
providedIn: 'root'
@ -45,4 +46,8 @@ export class CollectionTagService {
deleteTag(tagId: number) {
return this.httpClient.delete<string>(this.baseUrl + 'collection?tagId=' + tagId, TextResonse);
}
getMalStacks() {
return this.httpClient.get<Array<MalStack>>(this.baseUrl + 'collection/mal-stacks');
}
}

View File

@ -23,7 +23,9 @@ export class LibraryService {
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),
tap((e) => {
this.libraryNames = undefined;
console.log('LibraryModified event came in, clearing library name cache');
this.libraryNames = undefined;
this.libraryTypes = undefined;
})).subscribe();
}

View File

@ -18,6 +18,7 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {PersonalToC} from "../_models/readers/personal-toc";
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
import NoSleep from 'nosleep.js';
import {FullProgress} from "../_models/readers/full-progress";
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});
}
getAllProgressForChapter(chapterId: number) {
return this.httpClient.get<Array<FullProgress>>(this.baseUrl + 'reader/all-chapter-progress?chapterId=' + chapterId);
}
markVolumeRead(seriesId: number, volumeId: number) {
return this.httpClient.post(this.baseUrl + 'reader/mark-volume-read', {seriesId, volumeId});
}

View File

@ -36,10 +36,18 @@ export class ScrobblingService {
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() {
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() {
return this.httpClient.get<Array<ScrobbleError>>(this.baseUrl + 'scrobbling/scrobble-errors');
}

View File

@ -14,6 +14,7 @@ import { PublicationStatus } from '../_models/metadata/publication-status';
import { MangaFormat } from '../_models/manga-format';
import { TextResonse } from '../_types/text-response';
import {TranslocoService} from "@ngneat/transloco";
import {KavitaPlusMetadataBreakdown} from "../statistics/_models/kavitaplus-metadata-breakdown";
export enum DayOfWeek
{
@ -115,4 +116,8 @@ export class StatisticsService {
getDayBreakdown( userId = 0) {
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');
}
}

View File

@ -11,12 +11,13 @@
</form>
</div>
<div class="col-md-2 mt-4">
<ngb-pagination *ngIf="pagination"
[(page)]="pagination.currentPage"
[pageSize]="pagination.itemsPerPage"
[collectionSize]="pagination.totalItems"
(pageChange)="onPageChange($event)"
></ngb-pagination>
@if(pagination) {
<ngb-pagination [(page)]="pagination.currentPage"
[pageSize]="pagination.itemsPerPage"
[collectionSize]="pagination.totalItems"
(pageChange)="onPageChange($event)"
></ngb-pagination>
}
</div>
</div>
<table class="table table-striped table-hover table-sm scrollable">
@ -43,9 +44,12 @@
</tr>
</thead>
<tbody>
<tr *ngIf="events.length === 0">
<td colspan="6">{{t('no-data')}}</td>
</tr>
@if (events.length === 0) {
<tr>
<td colspan="6">{{t('no-data')}}</td>
</tr>
}
<tr *ngFor="let item of events; let idx = index;">
<td>
{{item.createdUtc | utcToLocalTime | defaultValue}}
@ -60,25 +64,28 @@
<a href="/library/{{item.libraryId}}/series/{{item.seriesId}}" target="_blank">{{item.seriesName}}</a>
</td>
<td>
<ng-container [ngSwitch]="item.scrobbleEventType">
<ng-container *ngSwitchCase="ScrobbleEventType.ChapterRead">
@if(item.volumeNumber === SpecialVolumeNumber) {
{{t('chapter-num', {num: item.volumeNumber})}}
} @else if (item.chapterNumber === LooseLeafOrDefaultNumber) {
@switch (item.scrobbleEventType) {
@case (ScrobbleEventType.ChapterRead) {
@if(item.volumeNumber === LooseLeafOrDefaultNumber) {
{{t('chapter-num', {num: item.chapterNumber})}}
}
@else if (item.chapterNumber === LooseLeafOrDefaultNumber) {
{{t('volume-num', {num: item.volumeNumber})}}
} @else if (item.chapterNumber === LooseLeafOrDefaultNumber && item.volumeNumber === SpecialVolumeNumber) {
} @else {
}
@else if (item.chapterNumber === LooseLeafOrDefaultNumber && item.volumeNumber === SpecialVolumeNumber) {
Special
}
@else {
{{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})}}
</ng-container>
<ng-container *ngSwitchDefault>
}
@default {
{{t('not-applicable')}}
</ng-container>
</ng-container>
}
}
</td>
<td>
@if(item.isProcessed) {

View File

@ -21,7 +21,8 @@ import {LooseLeafOrDefaultNumber, SpecialVolumeNumber} from "../../_models/chapt
@Component({
selector: 'app-user-scrobble-history',
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',
styleUrls: ['./user-scrobble-history.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush

View File

@ -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 {ToastrService} from 'ngx-toastr';
import {take} from 'rxjs';
import {SettingsService} from '../settings.service';
import {ServerSettings} from '../_models/server-settings';
import {
NgbAccordionBody,
NgbAccordionButton,
NgbAccordionCollapse,
NgbAccordionDirective, NgbAccordionHeader, NgbAccordionItem,
NgbTooltip
} 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 {SafeHtmlPipe} from "../../_pipes/safe-html.pipe";
import {ManageAlertsComponent} from "../manage-alerts/manage-alerts.component";
@ -23,8 +19,7 @@ import {ManageAlertsComponent} from "../manage-alerts/manage-alerts.component";
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgIf, ReactiveFormsModule, NgbTooltip, NgTemplateOutlet, TranslocoModule, SafeHtmlPipe,
ManageAlertsComponent, NgbAccordionBody, NgbAccordionButton, NgbAccordionCollapse, NgbAccordionDirective,
NgbAccordionHeader, NgbAccordionItem, NgForOf, TitleCasePipe]
ManageAlertsComponent, TitleCasePipe]
})
export class ManageEmailSettingsComponent implements OnInit {

View File

@ -9,15 +9,19 @@
</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>
<span class="visually-hidden">{{t('mark-as-unread')}}</span>
</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>
<span class="visually-hidden">{{t('mark-as-read')}}</span>
</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 id="bulk-actions-header" class="visually-hidden">Bulk Actions</span>

View File

@ -32,59 +32,6 @@
<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>
</ng-template>
</li>
@ -96,6 +43,13 @@
</ng-template>
</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">
<a ngbNavLink>{{t(tabs[TabID.Cover].title)}}</a>
<ng-template ngbNavContent>
@ -113,8 +67,7 @@
}
<ul class="list-unstyled">
<li class="d-flex my-4" *ngFor="let chapter of chapters">
<!-- TODO: Localize title -->
<a (click)="readChapter(chapter)" href="javascript:void(0);" title="Read">
<a (click)="readChapter(chapter)" href="javascript:void(0);" [title]="t('read')">
<app-image class="me-2" width="74px" [imageUrl]="imageService.getChapterCoverImage(chapter.id)"></app-image>
</a>
<div class="flex-grow-1">

View File

@ -50,18 +50,20 @@ import {TagBadgeComponent} from "../../shared/tag-badge/tag-badge.component";
import {PersonBadgeComponent} from "../../shared/person-badge/person-badge.component";
import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco";
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
import {EditChapterProgressComponent} from "../edit-chapter-progress/edit-chapter-progress.component";
enum TabID {
General = 0,
Metadata = 1,
Cover = 2,
Files = 3
Progress = 3,
Files = 4
}
@Component({
selector: 'app-card-detail-drawer',
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',
styleUrls: ['./card-detail-drawer.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
@ -106,6 +108,7 @@ export class CardDetailDrawerComponent implements OnInit {
{title: 'general-tab', disabled: false},
{title: 'metadata-tab', disabled: false},
{title: 'cover-tab', disabled: false},
{title: 'progress-tab', disabled: false},
{title: 'info-tab', disabled: false}
];
active = this.tabs[0];

View File

@ -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&#45;&#45;{{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&#45;&#45;{{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>

View File

@ -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();
}
}

View File

@ -1,6 +1,6 @@
<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 ">
<!-- 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">
@ -90,11 +90,8 @@
<div class="col-auto">
<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">
<img width="24" height="24" #img class="lazyload img-placeholder"
src="data:image/gif;base64,R0lGODlhAQABAPAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=="
[attr.data-src]="imageService.getWebLinkImage(link)"
(error)="imageService.updateErroredWebLinkImage($event)"
aria-hidden="true" alt="">
<app-image height="24px" width="24px" aria-hidden="true" [imageUrl]="imageService.getWebLinkImage(link)"
[errorImage]="imageService.errorWebLinkImage"></app-image>
</a>
</app-icon-and-title>
</div>
@ -117,6 +114,15 @@
</app-icon-and-title>
</div>
</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>
</div>
</div>

View File

@ -10,7 +10,6 @@ import { UtilityService } from 'src/app/shared/_services/utility.service';
import { Chapter } from 'src/app/_models/chapter';
import { ChapterMetadata } from 'src/app/_models/metadata/chapter-metadata';
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 { AgeRating } from 'src/app/_models/metadata/age-rating';
import { Volume } from 'src/app/_models/volume';
@ -29,17 +28,27 @@ import {TranslocoModule} from "@ngneat/transloco";
import {TranslocoLocaleModule} from "@ngneat/transloco-locale";
import {FilterField} from "../../_models/metadata/v2/filter-field";
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
import {ImageComponent} from "../../shared/image/image.component";
@Component({
selector: 'app-entity-info-cards',
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',
styleUrls: ['./entity-info-cards.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
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}) libraryId!: number;
/**
@ -48,7 +57,7 @@ export class EntityInfoCardsComponent implements OnInit {
@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;
@ -62,22 +71,6 @@ export class EntityInfoCardsComponent implements OnInit {
readingTime: HourEstimateRange = {maxHours: 1, minHours: 1, avgHours: 1};
size: number = 0;
imageService = inject(ImageService);
get LibraryType() {
return LibraryType;
}
get MangaFormat() {
return MangaFormat;
}
get AgeRating() {
return AgeRating;
}
get FilterField() { return FilterField; }
get WebLinks() {
if (this.chapter.webLinks === '') return [];
return this.chapter.webLinks.split(',');

View File

@ -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>

View File

@ -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();
})
}
}

View File

@ -7,6 +7,7 @@
<h6 subtitle class="subtitle-with-actionables" *ngIf="active.fragment === ''">{{t('common.series-count', {num: pagination.totalItems | number})}} </h6>
</app-side-nav-companion-bar>
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
<app-loading [absolute]="true" [loading]="bulkLoader"></app-loading>
<app-card-detail-layout *ngIf="filter"
[isLoading]="loadingSeries"
[items]="series"

View File

@ -44,6 +44,7 @@ import {MetadataService} from "../_services/metadata.service";
import {FilterComparison} from "../_models/metadata/v2/filter-comparison";
import {FilterField} from "../_models/metadata/v2/filter-field";
import {CardActionablesComponent} from "../_single-module/card-actionables/card-actionables.component";
import {LoadingComponent} from "../shared/loading/loading.component";
@Component({
selector: 'app-library-detail',
@ -52,10 +53,14 @@ import {CardActionablesComponent} from "../_single-module/card-actionables/card-
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
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 {
private readonly destroyRef = inject(DestroyRef);
private readonly metadataService = inject(MetadataService);
private readonly cdRef = inject(ChangeDetectorRef);
libraryId!: number;
libraryName = '';
series: Series[] = [];
@ -69,18 +74,16 @@ export class LibraryDetailComponent implements OnInit {
filterActiveCheck!: SeriesFilterV2;
refresh: EventEmitter<void> = new EventEmitter();
jumpKeys: Array<JumpKey> = [];
bulkLoader: boolean = false;
tabs: Array<{title: string, fragment: string, icon: string}> = [
{title: 'library-tab', fragment: '', icon: 'fa-landmark'},
{title: 'recommended-tab', fragment: 'recommended', icon: 'fa-award'},
];
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 selectedSeries = this.series.filter((series, index: number) => selectedSeriesIndices.includes(index + ''));
@ -123,7 +126,14 @@ export class LibraryDetailComponent implements OnInit {
});
break;
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;
this.bulkSelectionService.deselectAll();
this.loadPage();

View File

@ -1,7 +1,7 @@
<ng-container *transloco="let t; read: 'import-cbl-modal'">
<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>
</div>
<div class="modal-body scrollable-modal">

View File

@ -7,7 +7,7 @@ import {
} from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
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 { CblImportSummary } from 'src/app/_models/reading-list/cbl/cbl-import-summary';
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 {CblConflictReasonPipe} from "../../../_pipes/cbl-conflict-reason.pipe";
import {CblImportResultPipe} from "../../../_pipes/cbl-import-result.pipe";
import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco";
import {translate, TranslocoDirective} from "@ngneat/transloco";
interface FileStep {
fileName: string;

View File

@ -25,15 +25,17 @@ import {Breakpoint, UtilityService} from "../../../shared/_services/utility.serv
})
export class CustomizeDashboardStreamsComponent {
items: DashboardStream[] = [];
smartFilters: SmartFilter[] = [];
accessibilityMode: boolean = false;
private readonly dashboardService = inject(DashboardService);
private readonly filterService = inject(FilterService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly utilityService = inject(UtilityService);
items: DashboardStream[] = [];
smartFilters: SmartFilter[] = [];
accessibilityMode: boolean = false;
listForm: FormGroup = new FormGroup({
'filterQuery': new FormControl('', [])
});

View File

@ -148,7 +148,6 @@ export class CustomizeSidenavStreamsComponent implements OnDestroy {
if (this.pageOperationsForm.get('accessibilityMode')?.disabled) return;
this.pageOperationsForm.get('accessibilityMode')?.disable();
} else {
if (this.pageOperationsForm.get('accessibilityMode')?.disabled) return;
this.pageOperationsForm.get('accessibilityMode')?.enable();
}
}),

View File

@ -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">
<app-side-nav-item icon="fa-home" [title]="t('home')" link="/home/">
<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>
</app-side-nav-item>

View File

@ -30,6 +30,9 @@ import {SentenceCasePipe} from "../../../_pipes/sentence-case.pipe";
import {CustomizeDashboardModalComponent} from "../customize-dashboard-modal/customize-dashboard-modal.component";
import {SideNavStream} from "../../../_models/sidenav/sidenav-stream";
import {SideNavStreamType} from "../../../_models/sidenav/sidenav-stream-type.enum";
import {
ImportMalCollectionModalComponent
} from "../../../collections/_components/import-mal-collection-modal/import-mal-collection-modal.component";
@Component({
selector: 'app-side-nav',
@ -46,8 +49,12 @@ export class SideNavComponent implements OnInit {
cachedData: SideNavStream[] | null = null;
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)}];
homeActions = [{action: Action.Edit, title: 'customize', children: [], requiresAdmin: false, callback: this.handleHomeActions.bind(this)}];
readingListActions = [];
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 = '';
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'});
}
@ -176,6 +187,10 @@ export class SideNavComponent implements OnInit {
this.ngbModal.open(ImportCblModalComponent, {size: 'xl', fullscreen: 'md'});
}
importMalCollection() {
this.ngbModal.open(ImportMalCollectionModalComponent, {size: 'xl', fullscreen: 'md'});
}
performAction(action: ActionItem<Library>, library: Library) {
if (typeof action.callback === 'function') {
action.callback(action, library);

View File

@ -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>

View File

@ -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)
}
}

View File

@ -21,17 +21,19 @@ import {tap} from "rxjs/operators";
})
export class DayBreakdownComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly statService = inject(StatisticsService);
@Input() userId = 0;
view: [number, number] = [0,0];
showLegend: boolean = true;
max: number = 1;
formControl: FormControl = new FormControl(true, []);
dayBreakdown$!: Observable<Array<PieDataItem>>;
private readonly destroyRef = inject(DestroyRef);
private readonly cdRef = inject(ChangeDetectorRef);
constructor(private statService: StatisticsService) {}
ngOnInit() {
const dayOfWeekPipe = new DayOfWeekPipe();

View File

@ -23,6 +23,7 @@
<ng-container *ngIf="formControl.value; else tableLayout">
<ngx-charts-advanced-pie-chart [results]="vizData2$ | async"></ngx-charts-advanced-pie-chart>
</ng-container>
<ng-template #tableLayout>
<table class="table table-striped table-striped table-hover table-sm scrollable">
<thead>

View File

@ -1,14 +1,14 @@
import {
ChangeDetectionStrategy,
ChangeDetectionStrategy, ChangeDetectorRef,
Component,
DestroyRef,
inject,
QueryList,
QueryList, TemplateRef, ViewChild,
ViewChildren
} from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
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 { SortableHeader, SortEvent, compare } from 'src/app/_single-module/table/_directives/sortable-header.directive';
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 { SortableHeader as SortableHeader_1 } from '../../../_single-module/table/_directives/sortable-header.directive';
import { NgIf, NgFor, AsyncPipe, DecimalPipe } from '@angular/common';
import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap';
import {TranslocoDirective, TranslocoService} from "@ngneat/transloco";
import {NgbModal, NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco";
import {filter, tap} from "rxjs/operators";
import {GenericTableModalComponent} from "../_modals/generic-table-modal/generic-table-modal.component";
export interface StackedBarChartDataItem {
name: string,
@ -36,7 +38,11 @@ export interface StackedBarChartDataItem {
})
export class FileBreakdownStatsComponent {
private readonly destroyRef = inject(DestroyRef);
private readonly cdRef = inject(ChangeDetectorRef);
@ViewChildren(SortableHeader<PieDataItem>) headers!: QueryList<SortableHeader<PieDataItem>>;
@ViewChild('tablelayout') tableTemplate!: TemplateRef<any>;
rawData$!: Observable<FileExtensionBreakdown>;
files$!: Observable<Array<FileExtension>>;
@ -45,14 +51,15 @@ export class FileBreakdownStatsComponent {
currentSort = new BehaviorSubject<SortEvent<FileExtension>>({column: 'extension', direction: 'asc'});
currentSort$: Observable<SortEvent<FileExtension>> = this.currentSort.asObservable();
private readonly destroyRef = inject(DestroyRef);
view: [number, number] = [700, 400];
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.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 => {
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>) {

View File

@ -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>

View File

@ -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);
}

View File

@ -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();
});
}
}

View File

@ -113,9 +113,14 @@
</div>
<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>
</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>
</ng-container>

View File

@ -26,6 +26,10 @@ import {AsyncPipe, DecimalPipe, NgIf} from '@angular/common';
import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco";
import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison";
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({
selector: 'app-server-stats',
@ -33,12 +37,15 @@ import {FilterField} from "../../../_models/metadata/v2/filter-field";
styleUrls: ['./server-stats.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [NgIf, IconAndTitleComponent, StatListComponent, TopReadersComponent, FileBreakdownStatsComponent,
PublicationStatusStatsComponent, ReadingActivityComponent, DayBreakdownComponent, AsyncPipe, DecimalPipe,
CompactNumberPipe, TimeDurationPipe, BytesPipe, TranslocoDirective]
imports: [NgIf, IconAndTitleComponent, StatListComponent, TopReadersComponent, FileBreakdownStatsComponent,
PublicationStatusStatsComponent, ReadingActivityComponent, DayBreakdownComponent, AsyncPipe, DecimalPipe,
CompactNumberPipe, TimeDurationPipe, BytesPipe, TranslocoDirective, KavitaplusMetadataBreakdownStatsComponent]
})
export class ServerStatsComponent {
private readonly destroyRef = inject(DestroyRef);
protected readonly accountService = inject(AccountService);
releaseYears$!: Observable<Array<PieDataItem>>;
mostActiveUsers$!: Observable<Array<PieDataItem>>;
mostActiveLibrary$!: Observable<Array<PieDataItem>>;
@ -54,7 +61,7 @@ export class ServerStatsComponent {
breakpointSubject = new ReplaySubject<Breakpoint>(1);
breakpoint$: Observable<Breakpoint> = this.breakpointSubject.asObservable();
private readonly destroyRef = inject(DestroyRef);
@HostListener('window:resize', ['$event'])
@HostListener('window:orientationchange', ['$event'])

View File

@ -0,0 +1,5 @@
export interface KavitaPlusMetadataBreakdown {
totalSeries: number;
erroredSeries: number;
seriesCompleted: number;
}

View File

@ -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>

View File

@ -1,9 +0,0 @@
.error {
color: var(--error-color);
}
.confirm-icon {
color: var(--primary-color);
font-size: 14px;
vertical-align: middle;
}

View File

@ -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();
}
}

View File

@ -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>

View File

@ -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();
}
}

View File

@ -13,7 +13,7 @@
<app-change-email></app-change-email>
<app-change-password></app-change-password>
<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) {

View File

@ -9,7 +9,7 @@ import {
} from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { ToastrService } from 'ngx-toastr';
import {take, tap} from 'rxjs/operators';
import {take} from 'rxjs/operators';
import { Title } from '@angular/platform-browser';
import {
readingDirections,
@ -39,7 +39,6 @@ import { ManageDevicesComponent } from '../manage-devices/manage-devices.compone
import { ThemeManagerComponent } from '../theme-manager/theme-manager.component';
import { ApiKeyComponent } from '../api-key/api-key.component';
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 { ChangePasswordComponent } from '../change-password/change-password.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 {translate, TranslocoDirective} from "@ngneat/transloco";
import {LoadingComponent} from "../../shared/loading/loading.component";
import {ManageScrobblingProvidersComponent} from "../manage-scrobbling-providers/manage-scrobbling-providers.component";
enum AccordionPanelID {
ImageReader = 'image-reader',
@ -74,10 +74,10 @@ enum FragmentID {
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
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,
ThemeManagerComponent, ManageDevicesComponent, UserStatsComponent, UserScrobbleHistoryComponent, UserHoldsComponent, NgbNavOutlet, TitleCasePipe, SentenceCasePipe,
TranslocoDirective, LoadingComponent],
TranslocoDirective, LoadingComponent, ManageScrobblingProvidersComponent],
})
export class UserPreferencesComponent implements OnInit, OnDestroy {
@ -166,7 +166,6 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
this.route.fragment.subscribe(frag => {
const tab = this.tabs.filter(item => item.fragment === frag);
console.log('tab: ', tab);
if (tab.length > 0) {
this.active = tab[0];
} else {

View File

@ -294,11 +294,17 @@
"no-token-set": "No Token Set",
"token-set": "Token Set",
"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.",
"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",
"mal-token-input-label": "MAL Client Id",
"mal-username-input-label": "MAL Username",
"edit": "{{common.edit}}",
"cancel": "{{common.cancel}}",
"save": "{{common.save}}"
"save": "{{common.save}}",
"loading": "{{common.loading}}"
},
"typeahead": {
@ -937,6 +943,7 @@
"metadata-tab": "Metadata",
"cover-tab": "Cover",
"info-tab": "Info",
"progress-tab": "Progress",
"no-summary": "No Summary available.",
"writers-title": "{{series-metadata-detail.writers-title}}",
"genres-title": "{{series-metadata-detail.genres-title}}",
@ -1025,6 +1032,7 @@
"id-title": "ID",
"links-title": "{{series-metadata-detail.links-title}}",
"isbn-title": "ISBN",
"sort-order-title": "Sort Order",
"last-read-title": "Last Read",
"less-than-hour": "<1 Hour",
"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."
},
"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": {
"close": "{{common.close}}",
"title": "CBL Import",
@ -1759,6 +1784,15 @@
"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": {
"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.",
@ -2125,6 +2159,7 @@
"view-series": "View Series",
"clear": "{{common.clear}}",
"import-cbl": "Import CBL",
"import-mal-stack": "Import MAL Stack",
"read": "Read",
"add-rule-group-and": "Add Rule Group (AND)",
"add-rule-group-or": "Add Rule Group (OR)",

View File

@ -1,10 +1,9 @@
.progress {
background-color: var(--progress-bg-color);
}
.progress-bar {
background-color: var(--progress-bar-color);
background-color: var(--progress-bar-color) !important;
}
.progress-bar-striped {

View File

@ -7,7 +7,7 @@
"name": "GPL-3.0",
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
},
"version": "0.7.14.6"
"version": "0.7.14.8"
},
"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": {
"get": {
"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": {
"post": {
"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": {
"get": {
"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": {
"post": {
"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": {
"get": {
"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": {
"get": {
"tags": [
@ -12754,6 +12993,16 @@
"description": "The JWT for the user's AniList account. Expires after a year.",
"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": {
"type": "array",
"items": {
@ -15766,6 +16015,49 @@
},
"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": {
"type": "object",
"properties": {
@ -16277,6 +16569,58 @@
},
"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": {
"type": "object",
"properties": {
@ -20133,6 +20477,24 @@
},
"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": {
"type": "object",
"properties": {