Reading List Polish (#4634)

Co-authored-by: Amelia <77553571+Fesaa@users.noreply.github.com>
Co-authored-by: Arden Rasmussen <ardenisthebest@gmail.com>
This commit is contained in:
Joe Milazzo 2026-04-23 15:18:49 -05:00 committed by GitHub
parent 9e44ac285d
commit 485c6f87a8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
122 changed files with 1532 additions and 1072 deletions

View File

@ -12,10 +12,10 @@
<ItemGroup>
<PackageReference Include="Hangfire" Version="1.8.23" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Abstractions" Version="10.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Abstractions" Version="10.0.6" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="System.IO.Abstractions" Version="22.1.0" />
<PackageReference Include="System.IO.Abstractions" Version="22.1.1" />
<PackageReference Include="VersOne.Epub" Version="3.3.6" />
</ItemGroup>

View File

@ -61,4 +61,5 @@ public interface IReadingListRepository
Task<List<ReadingListTagDto>> GetAllReadingListTagDtosAsync(int userId, CancellationToken ct = default);
Task<PagedList<ReadingListDto>> GetBrowseReadingListDtos(int userId, ReadingListFilterDto filter, UserParams userParams, CancellationToken ct = default);
Task<ReadingList?> GetReadingListBySourcePathStemAsync(string sourcePathStem, int userId, ReadingListIncludes includes = ReadingListIncludes.Items, CancellationToken ct = default);
}

View File

@ -22,9 +22,9 @@ public interface ITachiyomiService
/// Marks every chapter and volume that is sorted below the passed number as Read. This will not mark any specials as read.
/// Passed number will also be marked as read
/// </summary>
/// <param name="userWithProgress"></param>
/// <param name="user"></param>
/// <param name="seriesId"></param>
/// <param name="chapterNumber">Can also be a Tachiyomi encoded volume number</param>
/// <param name="ct"></param>
Task<bool> MarkChaptersUntilAsRead(AppUser userWithProgress, int seriesId, float chapterNumber, CancellationToken ct = default);
Task<bool> MarkChaptersUntilAsRead(AppUser user, int seriesId, float chapterNumber, CancellationToken ct = default);
}

View File

@ -26,8 +26,6 @@ public interface IReaderService
Task<int> GetNextChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId);
Task<int> GetPrevChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId);
Task<ChapterDto> GetContinuePoint(int seriesId, int userId);
Task MarkChaptersUntilAsRead(AppUser user, int seriesId, float chapterNumber);
Task MarkVolumesUntilAsRead(AppUser user, int seriesId, int volumeNumber);
IDictionary<int, int> GetPairs(IEnumerable<FileDimensionDto> dimensions);
Task<string> GetThumbnail(Chapter chapter, int pageNum, IEnumerable<string> cachedImages);
Task<RereadDto> CheckSeriesForReRead(int userId, int seriesId, int libraryId);

View File

@ -12,7 +12,7 @@ public interface ICblImportService
/// <summary>
/// Creates a new RL or updates an existing
/// </summary>
Task<CblImportSummaryDto> UpsertReadingList(int userId, string filePath, CblImportDecisions decisions);
Task<CblImportSummaryDto> UpsertReadingList(int userId, string filePath, CblImportDecisions decisions, bool promote = false);
/// <summary>
/// Checks for updates against upstream ReadingList files and attempts to Update reading list
/// </summary>

View File

@ -8,7 +8,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="8.0.1">
<PackageReference Include="coverlet.collector" Version="10.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

@ -10,18 +10,18 @@
<ItemGroup>
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
<PackageReference Include="Cronos" Version="0.11.1" />
<PackageReference Include="Cronos" Version="0.12.0" />
<PackageReference Include="DotNet.Glob" Version="3.1.3" />
<PackageReference Include="Flurl.Http" Version="4.0.2" />
<PackageReference Include="Hangfire.Core" Version="1.8.23" />
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
<PackageReference Include="Microsoft.AspNetCore.StaticFiles" Version="2.3.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.5" />
<PackageReference Include="Microsoft.Net.Http.Headers" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.6" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.6" />
<PackageReference Include="Microsoft.Net.Http.Headers" Version="10.0.6" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.22.0.136894">
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.23.0.137933">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

@ -8,14 +8,14 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="8.0.1">
<PackageReference Include="coverlet.collector" Version="10.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Hangfire.InMemory" Version="1.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="TestableIO.System.IO.Abstractions.TestingHelpers" Version="22.1.0" />
<PackageReference Include="TestableIO.System.IO.Abstractions.TestingHelpers" Version="22.1.1" />
<PackageReference Include="xunit" Version="2.9.3"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>

View File

@ -1,7 +1,7 @@
using System;
using System.Linq;
using Kavita.Models.DTOs.Filtering.v2;
using Kavita.Models.DTOs.Filtering.v2.FilterFields;
using Kavita.Models.Entities.Enums.ReadingList;
namespace Kavita.Database.Converters;
@ -14,6 +14,7 @@ public static class ReadingListFilterFieldValueConverter
ReadingListFilterField.Title => value,
ReadingListFilterField.ReleaseYear => string.IsNullOrEmpty(value) ? 0 : int.Parse(value),
ReadingListFilterField.ItemCount => string.IsNullOrEmpty(value) ? 0 : int.Parse(value),
ReadingListFilterField.MissingItemCount => string.IsNullOrEmpty(value) ? 0 : int.Parse(value),
ReadingListFilterField.Tags => value.Split(',')
.Where(s => !string.IsNullOrEmpty(s))
.Select(int.Parse)
@ -26,6 +27,7 @@ public static class ReadingListFilterFieldValueConverter
.Where(s => !string.IsNullOrEmpty(s))
.Select(int.Parse)
.ToList(),
ReadingListFilterField.Provider => Enum.Parse<ReadingListProvider>(value),
_ => throw new ArgumentOutOfRangeException(nameof(field), field, "Field is not supported")
};
}

View File

@ -40,6 +40,15 @@ public static class ComparisonProfile
FilterComparison.Contains, FilterComparison.NotContains, FilterComparison.MustContains
];
/// <summary>
/// List/set membership fields: Equal, NotEqual, Contains, NotContains
/// </summary>
public static readonly HashSet<FilterComparison> ListWithoutMustContains =
[
FilterComparison.Equal, FilterComparison.NotEqual,
FilterComparison.Contains, FilterComparison.NotContains
];
/// <summary>
/// List/set membership fields with IsEmpty: Equal, NotEqual, Contains, NotContains, MustContains, IsEmpty, IsNotEmpty
/// </summary>

View File

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using Kavita.Models.DTOs.Filtering.v2;
using Kavita.Models.Entities.Enums;
using Kavita.Models.Entities.Enums.ReadingList;
using Kavita.Models.Entities.ReadingLists;
using Microsoft.EntityFrameworkCore;
@ -71,6 +72,45 @@ public static class ReadingListFilter
};
}
public IQueryable<ReadingList> HasProvider(bool condition, FilterComparison comparison, ReadingListProvider provider)
{
if (!condition) return queryable;
ComparisonProfile.Validate(comparison, [FilterComparison.Equal, FilterComparison.NotEqual], "ReadingList.Provider");
switch (comparison)
{
case FilterComparison.Equal:
return queryable.Where(s => s.Provider == provider);
case FilterComparison.NotEqual:
return queryable.Where(s => s.Provider != provider);
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
}
}
public IQueryable<ReadingList> HasMissingCount(bool condition, FilterComparison comparison, int itemCount)
{
if (!condition) return queryable;
ComparisonProfile.Validate(comparison, ComparisonProfile.Numeric, "ReadingList.MissingCount");
return comparison switch
{
FilterComparison.NotEqual => queryable.WhereNotEqual(s => s.TotalItemsAtImport - s.Items.Count,
itemCount),
FilterComparison.Equal => queryable.WhereEqual(s => s.TotalItemsAtImport - s.Items.Count, itemCount),
FilterComparison.GreaterThan => queryable.WhereGreaterThan(s => s.TotalItemsAtImport - s.Items.Count,
itemCount),
FilterComparison.GreaterThanEqual => queryable.WhereGreaterThanOrEqual(
s => s.TotalItemsAtImport - s.Items.Count,
itemCount),
FilterComparison.LessThan => queryable.WhereLessThan(s => s.TotalItemsAtImport - s.Items.Count,
itemCount),
FilterComparison.LessThanEqual => queryable.WhereLessThanOrEqual(
s => s.TotalItemsAtImport - s.Items.Count, itemCount),
_ => throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null)
};
}
public IQueryable<ReadingList> HasTags(bool condition, FilterComparison comparison, IList<int> tags)
{
if (!condition || (comparison != FilterComparison.IsEmpty && comparison != FilterComparison.IsNotEmpty && tags.Count == 0)) return queryable;

View File

@ -6,7 +6,6 @@ using System.Threading;
using System.Threading.Tasks;
using Kavita.API.Repositories;
using Kavita.Models.DTOs.Annotations;
using Kavita.Models.DTOs.Filtering;
using Kavita.Models.DTOs.Filtering.v2.SortFields;
using Kavita.Models.DTOs.Filtering.v2.SortOptions;
using Kavita.Models.DTOs.KavitaPlus.Manage;
@ -114,24 +113,6 @@ public static class QueryableExtensions
.Select(lib => lib.Id);
}
/// <summary>
/// Returns all libraries for a given user and library type
/// </summary>
/// <param name="library"></param>
/// <param name="userId"></param>
/// <param name="queryContext"></param>
/// <returns></returns>
public static IQueryable<int> GetUserLibrariesByType(this IQueryable<Library> library, int userId, LibraryType type, QueryContext queryContext = QueryContext.None)
{
return library
.Include(l => l.AppUsers)
.Where(lib => lib.AppUsers.Any(user => user.Id == userId))
.Where(lib => lib.Type == type)
.IsRestricted(queryContext)
.AsNoTracking()
.AsSplitQuery()
.Select(lib => lib.Id);
}
public static IEnumerable<DateTime> Range(this DateTime startDate, int numberOfDays) =>
Enumerable.Range(0, numberOfDays).Select(e => startDate.AddDays(e));
@ -250,11 +231,12 @@ public static class QueryableExtensions
{
if (!condition || string.IsNullOrEmpty(searchQuery)) return queryable;
var method = typeof(DbFunctionsExtensions).GetMethod(nameof(DbFunctionsExtensions.Like), new[] { typeof(DbFunctions), typeof(string), typeof(string) });
var method = typeof(DbFunctionsExtensions).GetMethod(nameof(DbFunctionsExtensions.Like), [typeof(DbFunctions), typeof(string), typeof(string)
]);
var dbFunctions = typeof(EF).GetMethod(nameof(EF.Functions))?.Invoke(null, null);
var searchExpression = Expression.Constant($"%{searchQuery}%");
Expression orExpression = null;
Expression? orExpression = null;
foreach (var propertySelector in propertySelectors)
{
var likeExpression = Expression.Call(method, Expression.Constant(dbFunctions), propertySelector.Body, searchExpression);
@ -310,8 +292,8 @@ public static class QueryableExtensions
return sort.SortField switch
{
PersonSortField.Name when sort.IsAscending => query.OrderBy(p => p.Name),
PersonSortField.Name => query.OrderByDescending(p => p.Name),
PersonSortField.Name when sort.IsAscending => query.OrderBy(p => p.Name.ToLower()),
PersonSortField.Name => query.OrderByDescending(p => p.Name.ToLower()),
PersonSortField.SeriesCount when sort.IsAscending => query.OrderBy(p => p.SeriesMetadataPeople.Count),
PersonSortField.SeriesCount => query.OrderByDescending(p => p.SeriesMetadataPeople.Count),
PersonSortField.ChapterCount when sort.IsAscending => query.OrderBy(p => p.ChapterPeople.Count),
@ -324,20 +306,20 @@ public static class QueryableExtensions
{
if (sort == null)
{
return query.OrderBy(p => p.Title);
return query.OrderBy(p => p.Title.ToLower());
}
return sort.SortField switch
{
ReadingListSortField.Title when sort.IsAscending => query.OrderBy(p => p.Title),
ReadingListSortField.Title => query.OrderByDescending(p => p.Title),
ReadingListSortField.Title when sort.IsAscending => query.OrderBy(p => p.Title.ToLower()),
ReadingListSortField.Title => query.OrderByDescending(p => p.Title.ToLower()),
ReadingListSortField.ReleaseYearStart when sort.IsAscending => query.OrderBy(r => r.StartingYear),
ReadingListSortField.ReleaseYearStart => query.OrderByDescending(r => r.StartingYear),
ReadingListSortField.ReleaseYearEnd when sort.IsAscending => query.OrderBy(r => r.EndingYear),
ReadingListSortField.ReleaseYearEnd => query.OrderByDescending(r => r.EndingYear),
ReadingListSortField.ItemCount when sort.IsAscending => query.OrderBy(r => r.Items.Count),
ReadingListSortField.ItemCount => query.OrderByDescending(r => r.Items.Count),
_ => query.OrderBy(p => p.Title),
_ => query.OrderBy(p => p.Title.ToLower()),
};
}

View File

@ -14,9 +14,9 @@
<ItemGroup>
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.5" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.5" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.6" />
<PackageReference Include="NeoSmart.Caching.Sqlite" Version="9.0.1" />
<PackageReference Include="NeoSmart.Caching.Sqlite.AspNetCore" Version="9.0.1" />
</ItemGroup>

View File

@ -237,7 +237,6 @@ public class LibraryRepository(DataContext context, IMapper mapper) : ILibraryRe
.Where(s => !string.IsNullOrEmpty(s))
.DistinctBy(l => l.ToNormalized())
.Select(GetCulture)
.Where(s => s != null)
.OrderBy(s => s.Title)
.ToList();
}

View File

@ -14,7 +14,6 @@ using Kavita.Database.Extensions.Filters;
using Kavita.Models.DTOs.Filtering.v2;
using Kavita.Models.DTOs.Filtering.v2.FilterFields;
using Kavita.Models.DTOs.Filtering.v2.Requests;
using Kavita.Models.DTOs.Metadata.Browse;
using Kavita.Models.DTOs.Person;
using Kavita.Models.DTOs.ReadingLists;
using Kavita.Models.Entities;
@ -255,7 +254,7 @@ public class ReadingListRepository(DataContext context, IMapper mapper) : IReadi
.Where(l => l.AppUserId == userId || (includePromoted && l.Promoted ))
.RestrictAgainstAgeRestriction(user.GetAgeRestriction());
query = sortByLastModified ? query.OrderByDescending(l => l.LastModified) : query.OrderBy(l => l.Title.ToUpper());
query = sortByLastModified ? query.OrderByDescending(l => l.LastModified) : query.OrderBy(l => l.Title.ToLower());
var finalQuery = query.ProjectTo<ReadingListDto>(mapper.ConfigurationProvider)
.AsNoTracking();
@ -547,6 +546,24 @@ public class ReadingListRepository(DataContext context, IMapper mapper) : IReadi
return await PagedList<ReadingListDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize, ct);
}
/// <summary>
/// Attempts to match the SourcePath.EndsWith(sourcePathStem) to do the matching
/// </summary>
/// <param name="sourcePathStem"></param>
/// <param name="userId"></param>
/// <returns></returns>
public async Task<ReadingList?> GetReadingListBySourcePathStemAsync(string sourcePathStem, int userId,
ReadingListIncludes includes = ReadingListIncludes.Items, CancellationToken ct = default)
{
if (string.IsNullOrEmpty(sourcePathStem)) return null;
return await context.ReadingList
.Includes(includes)
.FirstOrDefaultAsync(x => x.SourcePath != null &&
x.SourcePath.EndsWith(sourcePathStem) &&
x.AppUserId == userId, ct);
}
private IQueryable<ReadingListDto> CreateFilteredReadingListQueryable(int userId, ReadingListFilterDto filter,
AgeRestriction ageRating, CancellationToken ct = default)
{
@ -562,7 +579,6 @@ public class ReadingListRepository(DataContext context, IMapper mapper) : IReadi
query = query.RestrictAgainstAgeRestriction(ageRating);
// Apply sorting and limiting
var sortedQuery = query.SortBy(filter.SortOptions);
@ -583,6 +599,8 @@ public class ReadingListRepository(DataContext context, IMapper mapper) : IReadi
ReadingListFilterField.Tags => query.HasTags(true, statement.Comparison, (IList<int>) value),
ReadingListFilterField.Writer => query.HasPeople(true, statement.Comparison, (IList<int>) value, PersonRole.Writer),
ReadingListFilterField.Artist => query.HasPeople(true, statement.Comparison, (IList<int>) value, PersonRole.CoverArtist),
ReadingListFilterField.Provider => query.HasProvider(true, statement.Comparison, (ReadingListProvider) value),
ReadingListFilterField.MissingItemCount => query.HasMissingCount(true, statement.Comparison, (int) value),
_ => throw new ArgumentOutOfRangeException(nameof(statement.Field), $"Unexpected value for field: {statement.Field}")
};
}

View File

@ -241,11 +241,12 @@ public class SeriesRepository(DataContext context, IMapper mapper) : ISeriesRepo
#endregion
var seriesTask = baseSeriesQuery
.Where(s => EF.Functions.Like(s.Name, $"%{searchQuery}%")
|| (s.OriginalName != null && EF.Functions.Like(s.OriginalName, $"%{searchQuery}%"))
|| (s.LocalizedName != null && EF.Functions.Like(s.LocalizedName, $"%{searchQuery}%"))
|| EF.Functions.Like(s.NormalizedName, $"%{searchQueryNormalized}%")
|| (hasYearInQuery && s.Metadata.ReleaseYear == yearComparison))
.Where(s =>
(EF.Functions.Like(s.Name, $"%{searchQuery}%")
|| (s.OriginalName != null && EF.Functions.Like(s.OriginalName, $"%{searchQuery}%"))
|| (s.LocalizedName != null && EF.Functions.Like(s.LocalizedName, $"%{searchQuery}%"))
|| EF.Functions.Like(s.NormalizedName, $"%{searchQueryNormalized}%"))
&& (!hasYearInQuery || s.Metadata.ReleaseYear == yearComparison))
.OrderBy(s => s.SortName!.Length)
.ThenBy(s => s.SortName!.ToLower())
.Take(maxRecords)

View File

@ -8,7 +8,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="8.0.1">
<PackageReference Include="coverlet.collector" Version="10.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices.JavaScript;

View File

@ -11,12 +11,12 @@ public enum FilterComparison
LessThan = 3,
LessThanEqual = 4,
/// <summary>
/// value is within any of the series. This is inheritently an OR, even if combinator is an AND
/// value is within any of the entities. This is inherently an OR, even if combinator is an AND
/// </summary>
/// <remarks>Only works with IList</remarks>
Contains = 5,
/// <summary>
/// value is within All of the series. This is an AND, even if combinator ORs the different statements
/// value is within all the entities. This is an AND, even if combinator ORs the different statements
/// </summary>
/// <remarks>Only works with IList</remarks>
MustContains = 6,

View File

@ -8,4 +8,9 @@ public enum ReadingListFilterField
Tags = 4,
Writer = 5,
Artist = 6,
/// <summary>
/// Source is either Kavita/Url/File
/// </summary>
Provider = 7,
MissingItemCount = 8
}

View File

@ -26,4 +26,9 @@ public sealed record CblFinalizeRequestDto
/// Optional Git SHA for sync tracking
/// </summary>
public string? Sha { get; set; }
/// <summary>
/// Optional flag to promote the RL on creation
/// </summary>
public bool Promote { get; set; } = false;
}

View File

@ -19,5 +19,9 @@ public sealed record CblImportSummaryDto
/// Are we updating a pre-existing list or not
/// </summary>
public bool IsUpdate { get; set; }
/// <summary>
/// Id of the reading list
/// </summary>
public int ReadingListId { get; set; }
}

View File

@ -44,7 +44,7 @@ public sealed record UpdateChapterDto : IUpdateExternalMetadataIds
/// <summary>
/// Language of the content (BCP-47 code)
/// </summary>
public string Language { get; set; } = string.Empty;
public string? Language { get; set; } = string.Empty;
/// <summary>

View File

@ -13,9 +13,9 @@
<ItemGroup>
<PackageReference Include="CsvHelper" Version="33.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Identity" Version="2.3.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Abstractions" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Identity.Stores" Version="10.0.5" />
<PackageReference Include="YamlDotNet" Version="16.3.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Abstractions" Version="10.0.6" />
<PackageReference Include="Microsoft.Extensions.Identity.Stores" Version="10.0.6" />
<PackageReference Include="YamlDotNet" Version="17.0.1" />
</ItemGroup>
</Project>

View File

@ -8,11 +8,11 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="8.0.1">
<PackageReference Include="coverlet.collector" Version="10.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.6" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="xunit" Version="2.9.3"/>

View File

@ -123,13 +123,8 @@ public class BaseApiController : ControllerBase
return false;
}
if (fileName.Contains("..", StringComparison.Ordinal))
{
return false;
}
if (fileName.IndexOf(Path.DirectorySeparatorChar) >= 0 ||
fileName.IndexOf(Path.AltDirectorySeparatorChar) >= 0)
if (fileName.Contains(Path.DirectorySeparatorChar) ||
fileName.Contains(Path.AltDirectorySeparatorChar))
{
return false;
}

View File

@ -21,6 +21,7 @@ using Kavita.Models.DTOs.Uploads;
using AutoMapper;
using Hangfire;
using Kavita.Models.DTOs.SignalR;
using Kavita.Services.ReadingLists;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@ -62,17 +63,10 @@ public class CblController(IReadingListService readingListService, IDirectorySer
var userId = UserId;
var filename = cblFile.FileName;
var ext = Path.GetExtension(filename);
if (!ext.Equals(".cbl", StringComparison.OrdinalIgnoreCase)
&& !ext.Equals(".json", StringComparison.OrdinalIgnoreCase))
{
return BadRequest("Only .cbl and .json files are allowed");
}
var (isInvalid, actionResult) = await HasInvalidExtensionAsync(filename, filename);
if (isInvalid) return actionResult!;
if (filename.Contains(".exe", StringComparison.OrdinalIgnoreCase))
{
return BadRequest("Invalid filename");
}
await SaveCblFile(cblFile, userId, filename);
@ -112,16 +106,11 @@ public class CblController(IReadingListService readingListService, IDirectorySer
}
catch (FlurlHttpException)
{
return BadRequest("Unable to download file from URL");
return BadRequest(await localizationService.TranslateAsync("cbl-import-download-from-url"));
}
var ext = Path.GetExtension(filename);
if (!ext.Equals(".cbl", StringComparison.OrdinalIgnoreCase)
&& !ext.Equals(".json", StringComparison.OrdinalIgnoreCase))
{
if (System.IO.File.Exists(fullPath)) System.IO.File.Delete(fullPath);
return BadRequest("Only .cbl and .json files are allowed");
}
var (isInvalid, actionResult) = await HasInvalidExtensionAsync(filename, fullPath);
if (isInvalid) return actionResult!;
return Ok(new CblSavedFileDto
{
@ -132,6 +121,24 @@ public class CblController(IReadingListService readingListService, IDirectorySer
});
}
private async Task<(bool IsInvalid, ActionResult<CblSavedFileDto>? ActionResult)> HasInvalidExtensionAsync(string filename, string fullPath)
{
var ext = Path.GetExtension(filename);
if (!ext.Equals(".cbl", StringComparison.OrdinalIgnoreCase)
&& !ext.Equals(".json", StringComparison.OrdinalIgnoreCase))
{
if (System.IO.File.Exists(fullPath) && filename != fullPath) System.IO.File.Delete(fullPath);
return (true, BadRequest(await localizationService.TranslateAsync("cbl-import-validation-types")));
}
if (filename.Contains(".exe", StringComparison.OrdinalIgnoreCase))
{
return (true, BadRequest(await localizationService.TranslateAsync("invalid-filename")));
}
return (false, null);
}
/// <summary>
/// Downloads selected CBL files from the GitHub repo and saves them to disk without importing.
@ -169,14 +176,14 @@ public class CblController(IReadingListService readingListService, IDirectorySer
[DisallowRole(PolicyConstants.ReadOnlyRole)]
public async Task<ActionResult<CblImportSummaryDto>> ReValidate([FromBody] CblReValidateRequestDto dto)
{
if (!ValidateFilename(dto.FileName)) return BadRequest("Invalid filename");
if (!ValidateFilename(dto.FileName)) return BadRequest(await localizationService.TranslateAsync("invalid-filename"));
var userId = UserId;
var fullPath = Path.Join(GetCblManagerFolder(userId), dto.FileName);
if (!System.IO.File.Exists(fullPath))
{
return BadRequest("File not found on server");
return BadRequest(await localizationService.TranslateAsync("file-doesnt-exist"));
}
var summary = await cblImporterService.ValidateList(userId, fullPath);
@ -191,27 +198,28 @@ public class CblController(IReadingListService readingListService, IDirectorySer
[DisallowRole(PolicyConstants.ReadOnlyRole)]
public async Task<ActionResult<CblImportSummaryDto>> FinalizeImport([FromBody] CblFinalizeRequestDto dto)
{
if (!ValidateFilename(dto.FileName)) return BadRequest("Invalid filename");
if (!ValidateFilename(dto.FileName)) return BadRequest(await localizationService.TranslateAsync("invalid-filename"));
var userId = UserId;
var fullPath = Path.Join(GetCblManagerFolder(userId), dto.FileName);
if (!System.IO.File.Exists(fullPath))
{
return BadRequest("File not found on server");
return BadRequest(await localizationService.TranslateAsync("file-doesnt-exist"));
}
try
{
var summary = await cblImporterService.UpsertReadingList(
userId, fullPath, dto.Decisions);
userId, fullPath, dto.Decisions, dto.Promote);
summary.FileName = dto.FileName;
// Set provider and sync tracking fields
if (summary.Success != CblImportResult.Fail && dto.Provider != ReadingListProvider.None)
if (dto.Provider != ReadingListProvider.None)
{
var readingList = await unitOfWork.ReadingListRepository
.GetReadingListByTitleAsync(summary.CblName, userId);
.GetReadingListByIdAsync(summary.ReadingListId);
if (readingList != null)
{
@ -237,7 +245,11 @@ public class CblController(IReadingListService readingListService, IDirectorySer
}
await readingListService.CalculateReadingListAgeRating(readingList);
await readingListService.CalculateStartAndEndDates(readingList);
if (CblImportService.ShouldCalcReleaseDatesFromIssues(readingList))
{
await readingListService.CalculateStartAndEndDates(readingList);
}
await unitOfWork.CommitAsync();
}
@ -463,7 +475,6 @@ public class CblController(IReadingListService readingListService, IDirectorySer
var result = await cblGithubService.BrowseRepo(path);
// TODO: Refactor into CblService - Update Browse Results with sync details from what's on disk
var syncedPaths = await dataContext.ReadingList
.Where(rl => rl.AppUserId == UserId
&& rl.Provider == ReadingListProvider.Url

View File

@ -61,7 +61,7 @@ public class ReaderController(ICacheService cacheService,
{
if (!UserContext.IsAuthenticated) return Unauthorized();
var chapter = await cacheService.Ensure(chapterId, extractPdf);
if (chapter == null) return NoContent();
if (chapter == null) return NotFound();
try
{
@ -95,7 +95,7 @@ public class ReaderController(ICacheService cacheService,
try
{
var chapter = await cacheService.Ensure(chapterId, extractPdf);
if (chapter == null) return NoContent();
if (chapter == null) return NotFound();
var path = cacheService.GetCachedPagePath(chapter.Id, page);
return CachedFile(path, maxAge: TimeSpan.FromHours(1).Seconds);
@ -120,7 +120,7 @@ public class ReaderController(ICacheService cacheService,
public async Task<ActionResult> GetThumbnail(int chapterId, int pageNum, string apiKey)
{
var chapter = await cacheService.Ensure(chapterId, true);
if (chapter == null) return NoContent();
if (chapter == null) return NotFound();
var images = cacheService.GetCachedPages(chapterId);
@ -176,7 +176,7 @@ public class ReaderController(ICacheService cacheService,
{
if (chapterId <= 0) return ArraySegment<FileDimensionDto>.Empty;
var chapter = await cacheService.Ensure(chapterId, extractPdf);
if (chapter == null) return NoContent();
if (chapter == null) return NotFound();
return Ok(cacheService.GetCachedFileDimensions(cacheService.GetCachePath(chapterId)));
}
@ -196,7 +196,7 @@ public class ReaderController(ICacheService cacheService,
{
if (chapterId <= 0) return Ok(null); // This can happen occasionally from UI, we should just ignore
var chapter = await cacheService.Ensure(chapterId, extractPdf);
if (chapter == null) return NoContent();
if (chapter == null) return NotFound();
var dto = await unitOfWork.ChapterRepository.GetChapterInfoDtoAsync(chapterId);
if (dto == null) return BadRequest(await localizationService.TranslateAsync(UserId, "perform-scan"));

View File

@ -77,7 +77,7 @@ public class SeriesController(
{
var ct = HttpContext.RequestAborted;
var series = await unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, UserId, ct);
if (series == null) return NoContent();
if (series == null) return NotFound();
return Ok(series);
}
@ -139,7 +139,7 @@ public class SeriesController(
{
var ct = HttpContext.RequestAborted;
var vol = await unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, UserId, ct);
if (vol == null) return NoContent();
if (vol == null) return NotFound();
return Ok(vol);
}
@ -154,7 +154,7 @@ public class SeriesController(
{
var ct = HttpContext.RequestAborted;
var chapter = await unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId, UserId, ct);
if (chapter == null) return NoContent();
if (chapter == null) return NotFound();
return Ok(chapter);
}

View File

@ -227,6 +227,9 @@
"auth-key-unique": "The Auth Key name must be unique to your account",
"role-restricted": "Access forbidden: Your role does not permit this action",
"cbl-import-download-from-url": "Unable to download file from URL",
"cbl-import-validation-types": "Only .cbl and .json files are allowed",
"email.auth-key-expired.subject": "Kavita - One or more Auth Keys have expired!",

View File

@ -160,7 +160,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.5">
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.6">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

@ -46,6 +46,9 @@ public class BookServiceTests
Assert.Equal("genre1, genre2", comicInfo.Genre);
}
/// <summary>
/// This tests an edge case where there is bad metadata
/// </summary>
[Fact]
public void ShouldHaveComicInfo_WithAuthors()
{
@ -57,6 +60,17 @@ public class BookServiceTests
Assert.Equal("Roger Starbuck,Junya Inoue", comicInfo.Writer);
}
[Fact]
public void ShouldHaveComicInfo_WithAuthors_ForRoleRefinement()
{
var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Test Data/BookService");
var archive = Path.Join(testDirectory, "Role Refinement.epub");
var comicInfo = _bookService.GetComicInfo(archive);
Assert.NotNull(comicInfo);
Assert.Equal("미아키 스가루", comicInfo.Writer); // This should not use the fallback for the test case ShouldHaveComicInfo_WithAuthors
}
[Fact]
public void ShouldParseAsVolumeGroup_WithoutSeriesIndex()
{

View File

@ -378,7 +378,7 @@ public class CleanupServiceTests(ITestOutputHelper outputHelper): AbstractDbTest
await context.SaveChangesAsync();
var user = await unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress);
await readerService.MarkChaptersUntilAsRead(user, 1, 5);
await readerService.MarkChaptersAsRead(user, series.Id, series.Volumes.First().Chapters);
await context.SaveChangesAsync();
// Validate correct chapters have read status

View File

@ -8,7 +8,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="8.0.1">
<PackageReference Include="coverlet.collector" Version="10.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

@ -363,6 +363,7 @@ public class MangaParsingTests
[InlineData("Monster Ch. 001 [MangaPlus] [Digital] [amit34521]", "1")]
[InlineData("Naruto v2.5", Parser.DefaultChapter)]
[InlineData("조선왕조실톡 106화", "106")]
[InlineData("나루토 1.5권", Parser.DefaultChapter)]
public void ParseChaptersTest(string filename, string expected)
{
Assert.Equal(expected, Parser.ParseChapter(filename, LibraryType.Manga));

View File

@ -2701,217 +2701,6 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb
}
#endregion
#region MarkChaptersUntilAsRead
[Fact]
public async Task MarkChaptersUntilAsRead_ShouldMarkAllChaptersAsRead()
{
var (unitOfWork, context, _) = await CreateDatabase();
var readerService = Setup(unitOfWork);
var library = new LibraryBuilder("Test Lib", LibraryType.Manga).Build();
context.Library.Add(library);
await context.SaveChangesAsync();
var series = new SeriesBuilder("Test")
.WithLibraryId(library.Id)
.WithVolume(new VolumeBuilder(Parser.LooseLeafVolume)
.WithChapter(new ChapterBuilder("1").WithPages(1).Build())
.WithChapter(new ChapterBuilder("2").WithPages(1).Build())
.WithChapter(new ChapterBuilder("3").WithPages(1).Build())
.Build())
.WithVolume(new VolumeBuilder(Parser.SpecialVolume)
.WithChapter(new ChapterBuilder("Some Special Title").WithSortOrder(Parser.SpecialVolumeNumber + 1).WithIsSpecial(true).WithPages(1).Build())
.Build())
.Build();
context.Series.Add(series);
context.AppUser.Add(new AppUser()
{
UserName = "majora2007"
});
await context.SaveChangesAsync();
var user = await unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress);
await readerService.MarkChaptersUntilAsRead(user, 1, 5);
await context.SaveChangesAsync();
// Validate correct chapters have read status
Assert.Equal(1, (await unitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1)).PagesRead);
Assert.Equal(1, (await unitOfWork.AppUserProgressRepository.GetUserProgressAsync(2, 1)).PagesRead);
Assert.Equal(1, (await unitOfWork.AppUserProgressRepository.GetUserProgressAsync(3, 1)).PagesRead);
Assert.Null((await unitOfWork.AppUserProgressRepository.GetUserProgressAsync(4, 1)));
}
[Fact]
public async Task MarkChaptersUntilAsRead_ShouldMarkUptTillChapterNumberAsRead()
{
var (unitOfWork, context, _) = await CreateDatabase();
var readerService = Setup(unitOfWork);
var library = new LibraryBuilder("Test Lib", LibraryType.Manga).Build();
context.Library.Add(library);
await context.SaveChangesAsync();
var series = new SeriesBuilder("Test")
.WithLibraryId(library.Id)
.WithVolume(new VolumeBuilder(Parser.LooseLeafVolume)
.WithChapter(new ChapterBuilder("1").WithPages(1).Build())
.WithChapter(new ChapterBuilder("2").WithPages(1).Build())
.WithChapter(new ChapterBuilder("2.5").WithPages(1).Build())
.WithChapter(new ChapterBuilder("3").WithPages(1).Build())
.Build())
.WithVolume(new VolumeBuilder(Parser.SpecialVolume)
.WithChapter(new ChapterBuilder("Some Special Title").WithSortOrder(Parser.SpecialVolumeNumber + 1).WithIsSpecial(true).WithPages(1).Build())
.Build())
.Build();
context.Series.Add(series);
context.AppUser.Add(new AppUser()
{
UserName = "majora2007"
});
await context.SaveChangesAsync();
var user = await unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress);
await readerService.MarkChaptersUntilAsRead(user, 1, 2.5f);
await context.SaveChangesAsync();
// Validate correct chapters have read status
Assert.Equal(1, (await unitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1)).PagesRead);
Assert.Equal(1, (await unitOfWork.AppUserProgressRepository.GetUserProgressAsync(2, 1)).PagesRead);
Assert.Equal(1, (await unitOfWork.AppUserProgressRepository.GetUserProgressAsync(3, 1)).PagesRead);
Assert.Null((await unitOfWork.AppUserProgressRepository.GetUserProgressAsync(4, 1)));
Assert.Null((await unitOfWork.AppUserProgressRepository.GetUserProgressAsync(5, 1)));
}
[Fact]
public async Task MarkChaptersUntilAsRead_ShouldMarkAsRead_OnlyVolumesWithChapter0()
{
var (unitOfWork, context, _) = await CreateDatabase();
var readerService = Setup(unitOfWork);
var library = new LibraryBuilder("Test Lib", LibraryType.Manga).Build();
context.Library.Add(library);
await context.SaveChangesAsync();
var series = new SeriesBuilder("Test")
.WithLibraryId(library.Id)
.WithVolume(new VolumeBuilder("1")
.WithChapter(new ChapterBuilder(Parser.DefaultChapter).WithPages(1).Build())
.Build())
.WithVolume(new VolumeBuilder("2")
.WithChapter(new ChapterBuilder(Parser.DefaultChapter).WithPages(1).Build())
.Build())
.Build();
context.Series.Add(series);
context.AppUser.Add(new AppUser()
{
UserName = "majora2007"
});
await context.SaveChangesAsync();
var user = await unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress);
Assert.NotNull(user);
await readerService.MarkChaptersUntilAsRead(user, 1, 2);
await context.SaveChangesAsync();
// Validate correct chapters have read status
Assert.True(await unitOfWork.AppUserProgressRepository.UserHasProgress(LibraryType.Manga, 1));
}
[Fact]
public async Task MarkChaptersUntilAsRead_ShouldMarkAsReadAnythingUntil()
{
var (unitOfWork, context, _) = await CreateDatabase();
var readerService = Setup(unitOfWork);
var library = new LibraryBuilder("Test Lib", LibraryType.Manga).Build();
context.Library.Add(library);
await context.SaveChangesAsync();
var series = new SeriesBuilder("Test")
.WithLibraryId(library.Id)
.WithVolume(new VolumeBuilder(Parser.LooseLeafVolume)
.WithChapter(new ChapterBuilder("45").WithPages(5).Build())
.WithChapter(new ChapterBuilder("46").WithPages(46).Build())
.WithChapter(new ChapterBuilder("47").WithPages(47).Build())
.WithChapter(new ChapterBuilder("48").WithPages(48).Build())
.WithChapter(new ChapterBuilder("49").WithPages(49).Build())
.WithChapter(new ChapterBuilder("50").WithPages(50).Build())
.Build())
.WithVolume(new VolumeBuilder(Parser.SpecialVolume)
.WithChapter(new ChapterBuilder("Some Special Title").WithSortOrder(Parser.SpecialVolumeNumber + 1).WithIsSpecial(true).WithPages(10).Build())
.Build())
.WithVolume(new VolumeBuilder("1")
.WithChapter(new ChapterBuilder(Parser.DefaultChapter).WithPages(6).Build())
.Build())
.WithVolume(new VolumeBuilder("2")
.WithChapter(new ChapterBuilder(Parser.DefaultChapter).WithPages(7).Build())
.Build())
.WithVolume(new VolumeBuilder("3")
.WithChapter(new ChapterBuilder("12").WithPages(5).Build())
.WithChapter(new ChapterBuilder("13").WithPages(5).Build())
.WithChapter(new ChapterBuilder("14").WithPages(5).Build())
.Build())
.Build();
context.Series.Add(series);
context.AppUser.Add(new AppUser()
{
UserName = "majora2007"
});
await context.SaveChangesAsync();
var user = await unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress);
const int markReadUntilNumber = 47;
await readerService.MarkChaptersUntilAsRead(user, 1, markReadUntilNumber);
await context.SaveChangesAsync();
var volumes = await unitOfWork.VolumeRepository.GetVolumesDtoAsync(1, 1);
Assert.True(volumes.SelectMany(v => v.Chapters).All(c =>
{
// Specials are ignored.
var notReadChapterRanges = new[] {"Some Special Title", "48", "49", "50"};
if (notReadChapterRanges.Contains(c.Range))
{
return c.PagesRead == 0;
}
// Pages read and total pages must match -> chapter fully read
return c.Pages == c.PagesRead;
}));
}
#endregion
#region MarkSeriesAsRead
@ -3040,134 +2829,6 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb
#endregion
#region MarkVolumesUntilAsRead
[Fact]
public async Task MarkVolumesUntilAsRead_ShouldMarkVolumesAsRead()
{
var (unitOfWork, context, _) = await CreateDatabase();
var readerService = Setup(unitOfWork);
var library = new LibraryBuilder("Test Lib", LibraryType.Manga).Build();
context.Library.Add(library);
await context.SaveChangesAsync();
var series = new SeriesBuilder("Test")
.WithLibraryId(library.Id)
.WithVolume(new VolumeBuilder(Parser.LooseLeafVolume)
.WithChapter(new ChapterBuilder("10").WithPages(1).Build())
.WithChapter(new ChapterBuilder("20").WithPages(1).Build())
.WithChapter(new ChapterBuilder("30").WithPages(1).Build())
.Build())
.WithVolume(new VolumeBuilder(Parser.SpecialVolume)
.WithChapter(new ChapterBuilder("Some Special Title").WithSortOrder(Parser.SpecialVolumeNumber + 1).WithIsSpecial(true).WithPages(1).Build())
.Build())
.WithVolume(new VolumeBuilder("1997")
.WithChapter(new ChapterBuilder(Parser.DefaultChapter).WithPages(1).Build())
.Build())
.WithVolume(new VolumeBuilder("2002")
.WithChapter(new ChapterBuilder(Parser.DefaultChapter).WithPages(1).Build())
.Build())
.WithVolume(new VolumeBuilder("2003")
.WithChapter(new ChapterBuilder(Parser.DefaultChapter).WithPages(1).Build())
.Build())
.Build();
context.Series.Add(series);
context.AppUser.Add(new AppUser()
{
UserName = "majora2007"
});
await context.SaveChangesAsync();
var user = await unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress);
await readerService.MarkVolumesUntilAsRead(user, 1, 2002);
Assert.NotNull(user);
await context.SaveChangesAsync();
// Validate loose leaf chapters don't get marked as read
Assert.Null((await unitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1)));
Assert.Null((await unitOfWork.AppUserProgressRepository.GetUserProgressAsync(2, 1)));
Assert.Null((await unitOfWork.AppUserProgressRepository.GetUserProgressAsync(3, 1)));
// Validate that volumes 1997 and 2002 both have their respective chapter 0 marked as read
Assert.Equal(1, (await unitOfWork.AppUserProgressRepository.GetUserProgressAsync(5, 1)).PagesRead);
Assert.Equal(1, (await unitOfWork.AppUserProgressRepository.GetUserProgressAsync(6, 1)).PagesRead);
// Validate that the chapter 0 of the following volume (2003) is not read
Assert.Null(await unitOfWork.AppUserProgressRepository.GetUserProgressAsync(7, 1));
}
[Fact]
public async Task MarkVolumesUntilAsRead_ShouldMarkChapterBasedVolumesAsRead()
{
var (unitOfWork, context, _) = await CreateDatabase();
var readerService = Setup(unitOfWork);
var library = new LibraryBuilder("Test Lib", LibraryType.Manga).Build();
context.Library.Add(library);
await context.SaveChangesAsync();
var series = new SeriesBuilder("Test")
.WithLibraryId(library.Id)
.WithVolume(new VolumeBuilder(Parser.LooseLeafVolume)
.WithChapter(new ChapterBuilder("10").WithPages(1).Build())
.WithChapter(new ChapterBuilder("20").WithPages(1).Build())
.WithChapter(new ChapterBuilder("30").WithPages(1).Build())
.Build())
.WithVolume(new VolumeBuilder(Parser.SpecialVolume)
.WithChapter(new ChapterBuilder("Some Special Title").WithSortOrder(Parser.SpecialVolumeNumber + 1).WithIsSpecial(true).WithPages(1).Build())
.Build())
.WithVolume(new VolumeBuilder("1997")
.WithChapter(new ChapterBuilder("1").WithPages(1).Build())
.Build())
.WithVolume(new VolumeBuilder("2002")
.WithChapter(new ChapterBuilder("2").WithPages(1).Build())
.Build())
.WithVolume(new VolumeBuilder("2003")
.WithChapter(new ChapterBuilder("3").WithPages(1).Build())
.Build())
.Build();
context.Series.Add(series);
context.AppUser.Add(new AppUser()
{
UserName = "majora2007"
});
await context.SaveChangesAsync();
var user = await unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress);
Assert.NotNull(user);
await readerService.MarkVolumesUntilAsRead(user, 1, 2002);
await context.SaveChangesAsync();
// Validate loose leaf chapters don't get marked as read
Assert.Null((await unitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1)));
Assert.Null((await unitOfWork.AppUserProgressRepository.GetUserProgressAsync(2, 1)));
Assert.Null((await unitOfWork.AppUserProgressRepository.GetUserProgressAsync(3, 1)));
// Validate volumes chapter 0 have read status
Assert.Equal(1, (await unitOfWork.AppUserProgressRepository.GetUserProgressAsync(5, 1))?.PagesRead);
Assert.Equal(1, (await unitOfWork.AppUserProgressRepository.GetUserProgressAsync(6, 1))?.PagesRead);
Assert.Null((await unitOfWork.AppUserProgressRepository.GetUserProgressAsync(3, 1)));
}
#endregion
#region GetPairs
[Theory]

View File

@ -494,160 +494,15 @@ public partial class BookService(
try
{
epubBook = OpenEpubWithFallback(filePath, epubBook);
if (epubBook == null) return null;
var publicationDate = epubBook?.Schema.Package.Metadata.Dates.Find(pDate => pDate.Event == "publication")?.Date;
if (string.IsNullOrEmpty(publicationDate))
{
publicationDate = epubBook?.Schema.Package.Metadata.Dates.FirstOrDefault()?.Date;
}
var (year, month, day) = GetPublicationDate(publicationDate);
var summary = epubBook?.Schema.Package.Metadata.Descriptions.FirstOrDefault();
var info = new ComicInfo
{
Summary = string.IsNullOrEmpty(summary?.Description) ? string.Empty : summary.Description,
Publisher = string.Join(",", epubBook?.Schema.Package.Metadata.Publishers.Select(p => p.Publisher) ?? []),
Month = month,
Day = day,
Year = year,
Title = epubBook?.Title ?? string.Empty,
Genre = string.Join(",",
epubBook?.Schema.Package.Metadata.Subjects.Select(s => s.Subject.ToLower().Trim()) ?? []),
LanguageISO = ValidateLanguage(epubBook?.Schema.Package.Metadata.Languages
.Select(l => l.Language)
.FirstOrDefault())
};
var info = BuildBaseComicInfo(epubBook);
info.CleanComicInfo();
var weblinks = new List<string>();
if (epubBook?.Schema.Package.Metadata.Identifiers != null)
{
foreach (var identifier in epubBook.Schema.Package.Metadata.Identifiers)
{
if (string.IsNullOrEmpty(identifier.Identifier)) continue;
if (!string.IsNullOrEmpty(identifier.Scheme) &&
identifier.Scheme.Equals("ISBN", StringComparison.InvariantCultureIgnoreCase))
{
var isbn = identifier.Identifier.Replace("urn:isbn:", string.Empty).Replace("isbn:", string.Empty);
if (!ArticleNumberHelper.IsValidIsbn10(isbn) && !ArticleNumberHelper.IsValidIsbn13(isbn))
{
logger.LogDebug("[BookService] {File} has invalid ISBN number", filePath);
continue;
}
info.Isbn = isbn;
}
if ((!string.IsNullOrEmpty(identifier.Scheme) &&
identifier.Scheme.Equals("URL", StringComparison.InvariantCultureIgnoreCase)) ||
identifier.Identifier.StartsWith("url:"))
{
var url = identifier.Identifier.Replace("url:", string.Empty);
weblinks.Add(url.Trim());
}
}
}
if (weblinks.Count > 0)
{
info.Web = string.Join(',', weblinks.Distinct());
}
// Parse tags not exposed via Library
if (epubBook?.Schema.Package.Metadata.MetaItems != null)
{
foreach (var metadataItem in epubBook.Schema.Package.Metadata.MetaItems)
{
// EPUB 2 and 3
switch (metadataItem.Name)
{
case "calibre:rating":
info.UserRating = metadataItem.Content.AsFloat();
break;
case "calibre:title_sort":
info.TitleSort = metadataItem.Content;
break;
case "calibre:series":
info.Series = metadataItem.Content;
if (string.IsNullOrEmpty(info.SeriesSort))
{
info.SeriesSort = metadataItem.Content;
}
break;
case "calibre:series_index":
info.Volume = metadataItem.Content;
break;
}
// EPUB 3.2+ only
switch (metadataItem.Property)
{
case "group-position":
info.Volume = metadataItem.Content;
break;
case "belongs-to-collection":
info.Series = metadataItem.Content;
if (string.IsNullOrEmpty(info.SeriesSort))
{
info.SeriesSort = metadataItem.Content;
}
break;
case "collection-type":
// These look to be genres from https://manual.calibre-ebook.com/sub_groups.html or can be "series"
break;
case "role":
if (metadataItem.Scheme != null && !metadataItem.Scheme.Equals("marc:relators")) break;
var creatorId = metadataItem.Refines?.Replace("#", string.Empty);
var person = epubBook.Schema.Package.Metadata.Creators
.SingleOrDefault(c => c.Id == creatorId);
if (person == null) break;
PopulatePerson(metadataItem, info, person);
break;
case "title-type":
if (metadataItem.Content.Equals("collection"))
{
ExtractCollectionOrReadingList(metadataItem, epubBook, info);
}
if (metadataItem.Content.Equals("main"))
{
ExtractSortTitle(metadataItem, epubBook, info);
}
break;
}
}
}
// If this is a single book and not a collection, set publication status to Completed
if (string.IsNullOrEmpty(info.Volume) &&
Parser.IsLooseLeafVolume(Parser.ParseVolume(filePath, LibraryType.Manga)))
{
info.Count = 1;
}
// Include regular Writer as well, for cases where there is no special tag
info.Writer = string.Join(",",
epubBook?.Schema.Package.Metadata.Creators.Select(c => Parser.CleanAuthor(c.Creator)) ?? []);
var hasVolumeInSeries = !Parser.IsLooseLeafVolume(Parser.ParseVolume(info.Title, LibraryType.Manga));
if (string.IsNullOrEmpty(info.Volume) && hasVolumeInSeries &&
(!info.Series.Equals(info.Title) || string.IsNullOrEmpty(info.Series)))
{
// This is likely a light novel for which we can set series from parsed title
info.Series = Parser.ParseSeries(info.Title, LibraryType.Manga);
info.Volume = Parser.ParseVolume(info.Title, LibraryType.Manga);
}
ApplyIdentifiers(epubBook, info, filePath);
ApplyMetadataItems(epubBook, info, out var refinedCreatorIds);
ApplyCreators(epubBook, info, refinedCreatorIds);
ApplySeriesFallbacks(info, filePath);
return info;
}
@ -665,6 +520,200 @@ public partial class BookService(
return null;
}
private void ApplyIdentifiers(EpubBookRef epubBook, ComicInfo info, string filePath)
{
var identifiers = epubBook.Schema.Package.Metadata.Identifiers;
if (identifiers == null) return;
var weblinks = new List<string>();
foreach (var identifier in identifiers)
{
if (string.IsNullOrEmpty(identifier.Identifier)) continue;
if (IsIsbnScheme(identifier))
{
TryApplyIsbn(identifier, info, filePath);
}
if (IsUrlScheme(identifier))
{
weblinks.Add(identifier.Identifier.Replace("url:", string.Empty).Trim());
}
}
if (weblinks.Count > 0)
{
info.Web = string.Join(',', weblinks.Distinct());
}
}
private static bool IsIsbnScheme(EpubMetadataIdentifier identifier) =>
!string.IsNullOrEmpty(identifier.Scheme) &&
identifier.Scheme.Equals("ISBN", StringComparison.InvariantCultureIgnoreCase);
private static bool IsUrlScheme(EpubMetadataIdentifier identifier) =>
(!string.IsNullOrEmpty(identifier.Scheme) &&
identifier.Scheme.Equals("URL", StringComparison.InvariantCultureIgnoreCase)) ||
identifier.Identifier.StartsWith("url:");
private void TryApplyIsbn(EpubMetadataIdentifier identifier, ComicInfo info, string filePath)
{
var isbn = identifier.Identifier
.Replace("urn:isbn:", string.Empty)
.Replace("isbn:", string.Empty);
if (!ArticleNumberHelper.IsValidIsbn10(isbn) && !ArticleNumberHelper.IsValidIsbn13(isbn))
{
logger.LogDebug("[BookService] {File} has invalid ISBN number", filePath);
return;
}
info.Isbn = isbn;
}
private static ComicInfo BuildBaseComicInfo(EpubBookRef epubBook)
{
var publicationDate = epubBook?.Schema.Package.Metadata.Dates.Find(pDate => pDate.Event == "publication")?.Date;
if (string.IsNullOrEmpty(publicationDate))
{
publicationDate = epubBook?.Schema.Package.Metadata.Dates.FirstOrDefault()?.Date;
}
var (year, month, day) = GetPublicationDate(publicationDate);
var summary = epubBook?.Schema.Package.Metadata.Descriptions.FirstOrDefault();
var info = new ComicInfo
{
Summary = string.IsNullOrEmpty(summary?.Description) ? string.Empty : summary.Description,
Publisher = string.Join(",", epubBook?.Schema.Package.Metadata.Publishers.Select(p => p.Publisher) ?? []),
Month = month,
Day = day,
Year = year,
Title = epubBook?.Title ?? string.Empty,
Genre = string.Join(",",
epubBook?.Schema.Package.Metadata.Subjects.Select(s => s.Subject.ToLower().Trim()) ?? []),
LanguageISO = ValidateLanguage(epubBook?.Schema.Package.Metadata.Languages
.Select(l => l.Language)
.FirstOrDefault())
};
return info;
}
private static void ApplyMetadataItems(EpubBookRef epubBook, ComicInfo info, out HashSet<string> refinedCreatorIds)
{
refinedCreatorIds = [];
var metaItems = epubBook.Schema.Package.Metadata.MetaItems;
if (metaItems == null) return;
foreach (var item in metaItems)
{
ApplyEpub2Metadata(item, info);
ApplyEpub3Metadata(item, info, epubBook, refinedCreatorIds);
}
}
private static void ApplyEpub2Metadata(EpubMetadataMeta item, ComicInfo info)
{
switch (item.Name)
{
case "calibre:rating":
info.UserRating = item.Content.AsFloat();
break;
case "calibre:title_sort":
info.TitleSort = item.Content;
break;
case "calibre:series":
info.Series = item.Content;
if (string.IsNullOrEmpty(info.SeriesSort))
{
info.SeriesSort = item.Content;
}
break;
case "calibre:series_index":
info.Volume = item.Content;
break;
}
}
private static void ApplyEpub3Metadata(EpubMetadataMeta item, ComicInfo info, EpubBookRef epubBook, HashSet<string> refinedCreatorIds)
{
switch (item.Property)
{
case "group-position":
info.Volume = item.Content;
break;
case "belongs-to-collection":
info.Series = item.Content;
if (string.IsNullOrEmpty(info.SeriesSort)) info.SeriesSort = item.Content;
break;
case "role":
ApplyRoleRefinement(item, info, epubBook, refinedCreatorIds);
break;
case "title-type":
if (item.Content.Equals("collection")) ExtractCollectionOrReadingList(item, epubBook, info);
if (item.Content.Equals("main")) ExtractSortTitle(item, epubBook, info);
break;
}
}
private static void ApplyRoleRefinement(EpubMetadataMeta item, ComicInfo info, EpubBookRef epubBook, HashSet<string> refinedCreatorIds)
{
if (item.Scheme != null && !item.Scheme.Equals("marc:relators")) return;
var creatorId = item.Refines?.Replace("#", string.Empty);
if (string.IsNullOrEmpty(creatorId)) return;
var person = epubBook.Schema.Package.Metadata.Creators.SingleOrDefault(c => c.Id == creatorId);
if (person == null) return;
PopulatePerson(item, info, person);
refinedCreatorIds.Add(creatorId);
}
private static void ApplyCreators(EpubBookRef epubBook, ComicInfo info, HashSet<string> refinedCreatorIds)
{
// Creators without a role refinement are assumed to be writers.
// This handles both: EPUBs with no refinements at all, and EPUBs
// where only some creators have refinements (mixed case).
var unrefinedCreators = epubBook.Schema.Package.Metadata.Creators
.Where(c => string.IsNullOrEmpty(c.Id) || !refinedCreatorIds.Contains(c.Id))
.Select(c => Parser.CleanAuthor(c.Creator))
.Where(name => !string.IsNullOrEmpty(name))
.ToList();
var trimmedExisting = info.Writer.TrimEnd(',');
if (unrefinedCreators.Count == 0)
{
info.Writer = trimmedExisting;
return;
}
var joined = string.Join(",", unrefinedCreators);
info.Writer = string.IsNullOrEmpty(trimmedExisting) ? joined.TrimEnd(',') : $"{joined},{trimmedExisting}";
}
private static void ApplySeriesFallbacks(ComicInfo info, string filePath)
{
// If this is a single book and not a collection, set publication status to Completed
if (string.IsNullOrEmpty(info.Volume) &&
Parser.IsLooseLeafVolume(Parser.ParseVolume(filePath, LibraryType.Manga)))
{
info.Count = 1;
}
var hasVolumeInSeries = !Parser.IsLooseLeafVolume(Parser.ParseVolume(info.Title, LibraryType.Manga));
if (string.IsNullOrEmpty(info.Volume) && hasVolumeInSeries &&
(!info.Series.Equals(info.Title) || string.IsNullOrEmpty(info.Series)))
{
// This is likely a light novel for which we can set series from parsed title
info.Series = Parser.ParseSeries(info.Title, LibraryType.Manga);
info.Volume = Parser.ParseVolume(info.Title, LibraryType.Manga);
}
}
private EpubBookRef? OpenEpubWithFallback(string filePath, EpubBookRef? epubBook)
{
// default: Refactor this to use the Async version

View File

@ -14,8 +14,8 @@
<ItemGroup>
<PackageReference Include="CsvHelper" Version="33.1.0" />
<PackageReference Include="MailKit" Version="4.15.1" />
<PackageReference Include="Markdig" Version="1.1.2" />
<PackageReference Include="MailKit" Version="4.16.0" />
<PackageReference Include="Markdig" Version="1.1.3" />
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
<PackageReference Include="Docnet.Core" Version="2.6.0" />
<PackageReference Include="EasyCaching.InMemory" Version="1.9.2" />
@ -27,11 +27,11 @@
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.23" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.5" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" Version="10.4.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.6" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.6" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.6" />
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" Version="10.5.0" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
<PackageReference Include="MimeTypeMapOfficial" Version="1.0.17" />
<PackageReference Include="Nager.ArticleNumber" Version="1.0.8" />
@ -49,18 +49,18 @@
<PackageReference Include="Serilog.Sinks.SignalR.Core" Version="0.1.2" />
<PackageReference Include="SharpCompress" Version="0.47.4" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.22.0.136894">
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.23.0.137933">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="10.0.1" />
<PackageReference Include="System.Drawing.Common" Version="10.0.5" />
<PackageReference Include="System.Drawing.Common" Version="10.0.6" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.17.0" />
<PackageReference Include="System.IO.Abstractions" Version="22.1.0" />
<PackageReference Include="System.IO.Abstractions" Version="22.1.1" />
<PackageReference Include="TimeZoneConverter" Version="7.2.0" />
<PackageReference Include="VersOne.Epub" Version="3.3.6" />
<PackageReference Include="YamlDotNet" Version="16.3.0" />
<PackageReference Include="YamlDotNet" Version="17.0.1" />
</ItemGroup>
</Project>

View File

@ -540,6 +540,11 @@ public class ReaderService(IUnitOfWork unitOfWork, ILogger<ReaderService> logger
private static ChapterDto FindNextReadingChapter(IList<ChapterDto> volumeChapters)
{
if (volumeChapters.Count <= 0)
{
throw new KavitaNotFoundException();
}
var chaptersWithProgress = volumeChapters.Where(c => c.PagesRead > 0).ToList();
if (chaptersWithProgress.Count <= 0) return volumeChapters[0];
@ -593,33 +598,6 @@ public class ReaderService(IUnitOfWork unitOfWork, ILogger<ReaderService> logger
return -1;
}
/// <summary>
/// Marks every chapter that is sorted below the passed number as Read. This will not mark any specials as read or Volumes with a single 0 chapter.
/// </summary>
/// <param name="user"></param>
/// <param name="seriesId"></param>
/// <param name="chapterNumber"></param>
public async Task MarkChaptersUntilAsRead(AppUser user, int seriesId, float chapterNumber)
{
var volumes = await unitOfWork.VolumeRepository.GetVolumesForSeriesAsync(new List<int> { seriesId }, true);
foreach (var volume in volumes.OrderBy(v => v.MinNumber))
{
var chapters = volume.Chapters
.Where(c => !c.IsSpecial && c.MaxNumber <= chapterNumber)
.OrderBy(c => c.MinNumber);
await MarkChaptersAsRead(user, volume.SeriesId, chapters.ToList());
}
}
public async Task MarkVolumesUntilAsRead(AppUser user, int seriesId, int volumeNumber)
{
var volumes = await unitOfWork.VolumeRepository.GetVolumesForSeriesAsync(new List<int> { seriesId }, true);
foreach (var volume in volumes.Where(v => v.MinNumber <= volumeNumber && v.MinNumber > 0).OrderBy(v => v.MinNumber))
{
await MarkChaptersAsRead(user, volume.SeriesId, volume.Chapters);
}
}
public async Task<HourEstimateRangeDto> GetEstimateToCompletionForChapter(int userId, int seriesId, int chapterId)
{
var series = await unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);

View File

@ -21,6 +21,7 @@ using Kavita.Services.Helpers;
using Flurl.Http;
using Kavita.Common;
using Kavita.Common.Helpers;
using Kavita.Models.Entities.Enums.ReadingList;
using Microsoft.Extensions.Logging;
namespace Kavita.Services.ReadingLists;
@ -66,12 +67,17 @@ public class CblImportService(IUnitOfWork unitOfWork, ICblGithubService cblGithu
var existingList = await unitOfWork.ReadingListRepository
.GetReadingListByTitleAsync(cbl.Name, userId);
// Users may rename the underlying list causing a title lookup to fail, we fall back to lookup with the filename
var sourcePathStem = new FileInfo(filePath).Name;
existingList ??= await unitOfWork.ReadingListRepository.GetReadingListBySourcePathStemAsync(sourcePathStem, userId);
summary.IsUpdate = existingList != null;
return summary;
}
public async Task<CblImportSummaryDto> UpsertReadingList(int userId, string filePath, CblImportDecisions decisions)
public async Task<CblImportSummaryDto> UpsertReadingList(int userId, string filePath, CblImportDecisions decisions, bool promote = false)
{
ParsedCblReadingList cbl;
try
@ -108,22 +114,21 @@ public class CblImportService(IUnitOfWork unitOfWork, ICblGithubService cblGithu
// Override with user decisions
foreach (var (order, decision) in decisions.ItemResolutions)
{
if (matchResults.ContainsKey(order))
if (!matchResults.ContainsKey(order)) continue;
var item = cbl.Items.FirstOrDefault(i => i.Order == order);
if (item != null)
{
var item = cbl.Items.FirstOrDefault(i => i.Order == order);
if (item != null)
{
matchResults[order] = (
new MatchedItem(decision.SeriesId, decision.VolumeId, decision.ChapterId, CblMatchTier.UserDecision),
new CblBookResult(item)
{
Reason = CblImportReason.Success,
MatchTier = CblMatchTier.UserDecision,
SeriesId = decision.SeriesId,
ChapterId = decision.ChapterId
}
);
}
matchResults[order] = (
new MatchedItem(decision.SeriesId, decision.VolumeId, decision.ChapterId, CblMatchTier.UserDecision),
new CblBookResult(item)
{
Reason = CblImportReason.Success,
MatchTier = CblMatchTier.UserDecision,
SeriesId = decision.SeriesId,
ChapterId = decision.ChapterId
}
);
}
}
@ -137,11 +142,14 @@ public class CblImportService(IUnitOfWork unitOfWork, ICblGithubService cblGithu
readingList = new ReadingListBuilder(cbl.Name)
.WithSummary(cbl.Summary ?? string.Empty)
.WithAppUserId(userId)
.WithPromoted(promote)
.Build();
unitOfWork.ReadingListRepository.Add(readingList);
}
// Set metadata from CBL
await SetMetadataFromParsedCblAsync(cbl, readingList);
@ -187,6 +195,8 @@ public class CblImportService(IUnitOfWork unitOfWork, ICblGithubService cblGithu
var summary = BuildSummary(cbl, filePath, matchResults);
summary.IsUpdate = isUpdate;
summary.ReadingListId = readingList.Id;
return summary;
}
@ -286,8 +296,7 @@ public class CblImportService(IUnitOfWork unitOfWork, ICblGithubService cblGithu
string content;
string? contentHash;
// Github-based list
if (!string.IsNullOrEmpty(readingList.SourcePath))
if (!string.IsNullOrEmpty(readingList.SourcePath)) // Github-based list
{
try
{
@ -308,9 +317,8 @@ public class CblImportService(IUnitOfWork unitOfWork, ICblGithubService cblGithu
return;
}
}
else if (!string.IsNullOrEmpty(readingList.DownloadUrl))
else if (!string.IsNullOrEmpty(readingList.DownloadUrl)) // Url-based list
{
// Url-based list
try
{
await urlValidationService.ValidateUrlAsync(readingList.DownloadUrl);
@ -339,8 +347,10 @@ public class CblImportService(IUnitOfWork unitOfWork, ICblGithubService cblGithu
// Save to temp file for parsing
var tempDir = Path.Join(directoryService.TempDirectory, $"{userId}", "cbl-sync");
directoryService.ExistOrCreate(tempDir);
var sourceRef = readingList.SourcePath ?? readingList.DownloadUrl ?? $"list-{readingListId}";
var tempFile = Path.Join(tempDir, $"sync-{readingListId}{GetExtension(sourceRef)}");
await directoryService.FileSystem.File.WriteAllTextAsync(tempFile, content);
try
@ -384,7 +394,13 @@ public class CblImportService(IUnitOfWork unitOfWork, ICblGithubService cblGithu
// Re-run side effects like age ratings, cover generation, etc
await readingListService.CalculateReadingListAgeRating(readingList);
await readingListService.CalculateStartAndEndDates(readingList);
// Don't calculate from issue-level metadata if already set from json file
if (ShouldCalcReleaseDatesFromIssues(readingList))
{
await readingListService.CalculateStartAndEndDates(readingList);
}
await GenerateCoverForReadingList(readingList, cbl.CoverImageUrls);
await unitOfWork.CommitAsync();
@ -403,6 +419,20 @@ public class CblImportService(IUnitOfWork unitOfWork, ICblGithubService cblGithu
}
}
public static bool ShouldCalcReleaseDatesFromIssues(ReadingList readingList)
{
var url = readingList.SourcePath ?? readingList.DownloadUrl;
var isV2 = readingList.Provider == ReadingListProvider.Url && !string.IsNullOrEmpty(url) && url.EndsWith(".json");
if (!isV2) return true;
// v2 lists don't have Months for some reason
var hasStartDate = readingList is { StartingYear: > 0 };
var hasEndDate = readingList is { EndingYear: > 0 };
return isV2 && !hasStartDate && !hasEndDate;
}
private async Task<bool> CheckAndMarkIfNoChanges(ReadingList readingList, string hash, bool force)
{
if (force || readingList.HasRemoteChange(hash)) return false;

View File

@ -640,10 +640,29 @@ public static partial class Parser
new Regex(
@"(Глава|глава|Главы|Глава)(\.?)(\s|_)?(?<Chapter>\d+(?:.\d+|-\d+)?)",
MatchOptions, RegexTimeout),
// Chinese Chapter: 第n话 -> Chapter n, 【TFO汉化&Petit汉化】迷你偶像漫画第25话
new Regex(
@"第(?<Chapter>\d+)话",
MatchOptions, RegexTimeout),
// Korean Chapter: 제n화 -> Chapter n, 가디언즈 오브 갤럭시 죽음의 보석.E0008.7화#44
new Regex(
@"제?(?<Chapter>\d+\.?\d+)(회|화|장)",
MatchOptions, RegexTimeout),
// Korean Chapter: 第10話 -> Chapter n, [ハレム]ナナとカオル 高校生のSMごっこ 第1話
new Regex(
@"第?(?<Chapter>\d+(?:\.\d+|-\d+)?)話",
MatchOptions, RegexTimeout),
// Russian Chapter: n Главa -> Chapter n
new Regex(
@"(?!Том)(?<!Том\.)\s\d+(\s|_)?(?<Chapter>\d+(?:\.\d+|-\d+)?)(\s|_)(Глава|глава|Главы|Глава)",
MatchOptions, RegexTimeout),
// Fullmetal Alchemist chapters 101-108
new Regex(
@"^(?<Series>.+?)\schapter(?:s)?\s(?<Chapter>\d+-\d+)",
MatchOptions, RegexTimeout),
// Hinowa ga CRUSH! 018 (2019) (Digital) (LuCaZ).cbz, Hinowa ga CRUSH! 018.5 (2019) (Digital) (LuCaZ).cbz
new Regex(
@"^(?<Series>.+?)(?<!Vol)(?<!Vol.)(?<!Volume)\s(\d\s)?(?<Chapter>\d+(?:\.\d+|-\d+)?)(?:\s\(\d{4}\))?(\b|_|-)",
@"^(?<Series>.+?)(?<!Vol)(?<!Vol.)(?<!Volume)\s(\d\s)?(?<Chapter>\d+(?:\.\d+|-\d+)?)(?![\d.권])(?:\s\(\d{4}\))?(\b|_|-)",
MatchOptions, RegexTimeout),
// Tower Of God S01 014 (CBT) (digital).cbz
new Regex(
@ -665,22 +684,6 @@ public static partial class Parser
new Regex(
@"(?<Volume>((vol|volume|v))?(\s|_)?\.?\d+)(\s|_)(Chp|Chapter)\.?(\s|_)?(?<Chapter>\d+)",
MatchOptions, RegexTimeout),
// Chinese Chapter: 第n话 -> Chapter n, 【TFO汉化&Petit汉化】迷你偶像漫画第25话
new Regex(
@"第(?<Chapter>\d+)话",
MatchOptions, RegexTimeout),
// Korean Chapter: 제n화 -> Chapter n, 가디언즈 오브 갤럭시 죽음의 보석.E0008.7화#44
new Regex(
@"제?(?<Chapter>\d+\.?\d+)(회|화|장)",
MatchOptions, RegexTimeout),
// Korean Chapter: 第10話 -> Chapter n, [ハレム]ナナとカオル 高校生のSMごっこ 第1話
new Regex(
@"第?(?<Chapter>\d+(?:\.\d+|-\d+)?)話",
MatchOptions, RegexTimeout),
// Russian Chapter: n Главa -> Chapter n
new Regex(
@"(?!Том)(?<!Том\.)\s\d+(\s|_)?(?<Chapter>\d+(?:\.\d+|-\d+)?)(\s|_)(Глава|глава|Главы|Глава)",
MatchOptions, RegexTimeout)
];
private static readonly Regex MangaEditionRegex = new Regex(

View File

@ -1,4 +1,4 @@
using System;
using System;
using System.Threading.Tasks;
using System.Collections.Immutable;
using System.Collections.Generic;
@ -6,11 +6,13 @@ using System.Globalization;
using System.Linq;
using System.Threading;
using AutoMapper;
using Hangfire;
using Kavita.API.Database;
using Kavita.API.Services;
using Kavita.API.Services.Reading;
using Kavita.Common.Extensions;
using Kavita.Models.DTOs;
using Kavita.Models.Entities;
using Kavita.Models.Entities.Progress;
using Kavita.Models.Entities.User;
using Kavita.Services.Comparators;
@ -96,37 +98,41 @@ public class TachiyomiService(
{
// Use R to ensure that localization of underlying system doesn't affect the stringification
// https://docs.microsoft.com/en-us/globalization/locale/number-formatting-in-dotnet-framework
Number = (number / 10_000f).ToString("R", EnglishCulture)
Number = (number / 10_000f).ToString("R", EnglishCulture),
Files = new List<MangaFileDto>()
};
}
public async Task<bool> MarkChaptersUntilAsRead(AppUser userWithProgress, int seriesId, float chapterNumber,
public async Task<bool> MarkChaptersUntilAsRead(AppUser user, int seriesId, float chapterNumber,
CancellationToken ct = default)
{
userWithProgress.Progresses ??= [];
user.Progresses ??= [];
switch (chapterNumber)
var chapters = chapterNumber switch
{
// When Tachiyomi sync's progress, if there is no current progress in Tachiyomi, 0.0f is sent.
// Due to the encoding for volumes, this marks all chapters in volume 0 (loose chapters) as read.
// Hence we catch and return early, so we ignore the request.
case 0.0f:
return true;
case < 1.0f:
{
// This is a hack to track volume number. We need to map it back by x10,000
var volumeNumber = int.Parse($"{(int)(chapterNumber * 10_000)}", EnglishCulture);
await readerService.MarkVolumesUntilAsRead(userWithProgress, seriesId, volumeNumber);
break;
}
default:
await readerService.MarkChaptersUntilAsRead(userWithProgress, seriesId, chapterNumber);
break;
}
0.0f => [],
// This is a hack to track volume number. We need to map it back by x10,000
< 1.0f => await GetChaptersUntilVolume(seriesId, int.Parse($"{(int)(chapterNumber * 10_000)}", EnglishCulture)),
_ => await GetChaptersUntilChapter(seriesId, chapterNumber)
};
if (chapters.Count == 0) return true;
var chapterIds = chapters.Select(c => c.Id).ToList();
var progressDictionary = await unitOfWork.AppUserProgressRepository
.GetUserProgressForChaptersByChapters(user.Id, seriesId, chapterIds, ct);
await readerService.MarkChaptersAsRead(user, seriesId, chapters);
// Generate reading sessions
BackgroundJob.Enqueue<IReadingSessionService>(s
=> s.GenerateReadingSessionForChapters(user.Id, seriesId, progressDictionary, CancellationToken.None));
try {
unitOfWork.UserRepository.Update(userWithProgress);
if (!unitOfWork.HasChanges()) return true;
if (await unitOfWork.CommitAsync(ct)) return true;
} catch (Exception ex) {
@ -135,4 +141,29 @@ public class TachiyomiService(
}
return false;
}
private async Task<List<Chapter>> GetChaptersUntilVolume(int seriesId, int volumeNumber)
{
var volumes = await unitOfWork.VolumeRepository.GetVolumesForSeriesAsync([seriesId], true);
return volumes
.Where(v => v.MinNumber <= volumeNumber && v.MinNumber > 0)
.OrderBy(v => v.MinNumber)
.SelectMany(v => v.Chapters)
.ToList();
}
private async Task<List<Chapter>> GetChaptersUntilChapter(int seriesId, float chapterNumber)
{
var volumes = await unitOfWork.VolumeRepository.GetVolumesForSeriesAsync([seriesId], true);
return volumes
.OrderBy(v => v.MinNumber)
.SelectMany(v => v.Chapters)
.Where(c => !c.IsSpecial && c.MaxNumber <= chapterNumber)
.OrderBy(c => c.MinNumber)
.ToList();
}
}

View File

@ -11,6 +11,7 @@ using Kavita.API.Services;
using Kavita.API.Services.Metadata;
using Kavita.API.Services.Plus;
using Kavita.API.Services.Reading;
using Kavita.API.Services.ReadingLists;
using Kavita.API.Services.Scanner;
using Kavita.API.Services.SignalR;
using Kavita.Common.Constants;
@ -171,7 +172,7 @@ public class TaskScheduler : ITaskScheduler
if (IsInvalidCronSetting(setting))
{
_logger.LogError("Backup Task has invalid cron, defaulting to Weekly");
RecurringJob.AddOrUpdate(BackupTaskId, () => _backupService.BackupDatabase(CancellationToken.None),
RecurringJob.AddOrUpdate<IBackupService>(BackupTaskId, (service) => service.BackupDatabase(CancellationToken.None),
Cron.Weekly, RecurringJobOptions);
}
else
@ -183,7 +184,7 @@ public class TaskScheduler : ITaskScheduler
// Override daily and make 2am so that everything on system has cleaned up and no blocking
schedule = Cron.Daily(2);
}
RecurringJob.AddOrUpdate(BackupTaskId, () => _backupService.BackupDatabase(CancellationToken.None),
RecurringJob.AddOrUpdate<IBackupService>(BackupTaskId, (service) => service.BackupDatabase(CancellationToken.None),
() => schedule, RecurringJobOptions);
}
@ -191,13 +192,13 @@ public class TaskScheduler : ITaskScheduler
if (IsInvalidCronSetting(setting))
{
_logger.LogError("Cleanup Task has invalid cron, defaulting to Daily");
RecurringJob.AddOrUpdate(CleanupTaskId, () => _cleanupService.Cleanup(CancellationToken.None),
RecurringJob.AddOrUpdate<ICleanupService>(CleanupTaskId, (service) => service.Cleanup(CancellationToken.None),
Cron.Daily, RecurringJobOptions);
}
else
{
_logger.LogDebug("Scheduling Cleanup Task for {Setting}", setting);
RecurringJob.AddOrUpdate(CleanupTaskId, () => _cleanupService.Cleanup(CancellationToken.None),
RecurringJob.AddOrUpdate<ICleanupService>(CleanupTaskId, (service) => service.Cleanup(CancellationToken.None),
CronConverter.ConvertToCronNotation(setting), RecurringJobOptions);
}
@ -205,22 +206,22 @@ public class TaskScheduler : ITaskScheduler
if (IsInvalidCronSetting(setting))
{
_logger.LogError("CBL Sync Task has invalid cron, defaulting to Daily");
RecurringJob.AddOrUpdate<CblImportService>(TaskCblSyncId, service => service.SyncAllReadingLists(CancellationToken.None),
RecurringJob.AddOrUpdate<ICblImportService>(TaskCblSyncId, service => service.SyncAllReadingLists(CancellationToken.None),
"0 4 * * *", RecurringJobOptions);
}
else
{
_logger.LogDebug("Scheduling CBL Sync Task for {Setting}", setting);
RecurringJob.AddOrUpdate<CblImportService>(TaskCblSyncId, service => service.SyncAllReadingLists(CancellationToken.None),
RecurringJob.AddOrUpdate<ICblImportService>(TaskCblSyncId, service => service.SyncAllReadingLists(CancellationToken.None),
CronConverter.ConvertToCronNotation(setting), RecurringJobOptions);
}
RecurringJob.AddOrUpdate(RemoveFromWantToReadTaskId,
() => _cleanupService.CleanupWantToRead(CancellationToken.None),
RecurringJob.AddOrUpdate<ICleanupService>(RemoveFromWantToReadTaskId,
(service) => service.CleanupWantToRead(CancellationToken.None),
Cron.Daily, RecurringJobOptions);
RecurringJob.AddOrUpdate(UpdateYearlyStatsTaskId,
() => _statisticService.UpdateServerStatistics(CancellationToken.None),
RecurringJob.AddOrUpdate<IStatisticService>(UpdateYearlyStatsTaskId,
(service) => service.UpdateServerStatistics(CancellationToken.None),
Cron.Monthly, RecurringJobOptions);
RecurringJob.AddOrUpdate<IThemeService>(SyncThemesTaskId,

View File

@ -120,4 +120,8 @@ export enum Action {
* Marks the entity as read while creating a fake reading session
*/
MarkAsReadWithSession = 37,
/**
* A special action to just navigate somewhere
*/
Navigate = 38,
}

View File

@ -5,6 +5,8 @@ export enum ReadingListFilterField {
Tags = 4,
Writer = 5,
Artist = 6,
Provider = 7,
MissingItemCount = 8
}
export const allReadingListFilterFields = Object.keys(ReadingListFilterField)

View File

@ -62,6 +62,10 @@ export enum ReadingListProvider {
Url = 2
}
export const allReadingListProviders = Object.keys(ReadingListProvider)
.filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0)
.map(key => parseInt(key, 10)) as ReadingListProvider[];
export interface ReadingList extends IHasCover {
id: number;
title: string;

View File

@ -66,6 +66,8 @@ export class GenericFilterFieldPipe implements PipeTransform {
private translateReadingListFilterField(value: ReadingListFilterField) {
switch (value) {
case ReadingListFilterField.Provider:
return translate('generic-filter-field-pipe.readinglist-provider');
case ReadingListFilterField.Title:
return translate('generic-filter-field-pipe.readinglist-title');
case ReadingListFilterField.ReleaseYear:
@ -78,6 +80,8 @@ export class GenericFilterFieldPipe implements PipeTransform {
return translate('generic-filter-field-pipe.readinglist-writer');
case ReadingListFilterField.Artist:
return translate('generic-filter-field-pipe.readinglist-artist');
case ReadingListFilterField.MissingItemCount:
return translate('generic-filter-field-pipe.readinglist-missing-item-count');
}
}

View File

@ -19,7 +19,6 @@ export class ReadingListProviderPipe implements PipeTransform {
return this.translocoService.translate('reading-list-provider-pipe.file');
case ReadingListProvider.Url:
return this.translocoService.translate('reading-list-provider-pipe.url');
}
}

View File

@ -1,6 +1,5 @@
import { inject } from '@angular/core';
import { Pipe, PipeTransform, SecurityContext } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
import {inject, Pipe, PipeTransform, SecurityContext} from '@angular/core';
import {DomSanitizer} from '@angular/platform-browser';
@Pipe({
name: 'safeHtml',
@ -9,7 +8,6 @@ import { DomSanitizer } from '@angular/platform-browser';
})
export class SafeHtmlPipe implements PipeTransform {
private readonly dom: DomSanitizer = inject(DomSanitizer);
constructor() {}
transform(value: string): string | null {
return this.dom.sanitize(SecurityContext.HTML, value);

View File

@ -16,8 +16,7 @@ export class UtcToLocalDatePipe implements PipeTransform {
return null;
}
const browserLanguage = navigator.language;
const dateTime = DateTime.fromISO(utcDate, { zone: 'utc' }).toLocal().setLocale(browserLanguage);
const dateTime = DateTime.fromISO(utcDate, { zone: 'utc' }).toLocal();
return dateTime.toJSDate()
}

View File

@ -49,6 +49,7 @@ export class ActionFactoryService {
private sideNavStreamActions: Array<ActionItem<SideNavStream>> = [];
private smartFilterActions: Array<ActionItem<SmartFilter>> = [];
private sideNavHomeActions: Array<ActionItem<{}>> = [];
private sideNavReadingListActions: Array<ActionItem<{}>> = [];
private annotationActions: Array<ActionItem<Annotation>> = [];
private clientDeviceActions: Array<ActionItem<ClientDevice>> = [];
@ -174,6 +175,19 @@ export class ActionFactoryService {
);
}
getSideNavReadingListActions(shouldRenderFunc: ActionShouldRenderFunc<{}> = this.basicReadRender) {
// If the caller doesn't pass a render function, assume that readonly users cannot perform actions
const renderFunc = shouldRenderFunc === this.basicReadRender
? (action: ActionItem<any>, entity: any, user: User) => !this.accountService.hasReadOnlyRole()
: shouldRenderFunc;
return this.applyCallbackToList(
this.sideNavReadingListActions,
(action, entity) => this.actionService.handleSideNavReadingListStream(action, entity),
renderFunc
);
}
getBulkLibraryActions(shouldRenderFunc: ActionShouldRenderFunc<Library> = this.basicReadRender) {
const filteredActions = this.flattenActions<Library>(this.libraryActions).filter(a => {
@ -1234,6 +1248,19 @@ export class ActionFactoryService {
}
];
this.sideNavReadingListActions = [
{
action: Action.Navigate,
title: 'cbl-manager',
description: '',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiredRoles: [],
children: [],
}
];
this.annotationActions = [
{
action: Action.Delete,

View File

@ -873,6 +873,16 @@ export class ActionService {
}
}
handleSideNavReadingListStream(action: ActionItem<{}>, entity: {}) {
switch (action.action) {
case Action.Navigate:
return of(this.fromAction(action, entity, 'none'));
default:
return of(this.fromAction(action, entity, 'none'));
}
}
/**
* Centralized handler for all bulk library actions.
* Returns Observable<ActionResult<Library>> so the caller can react to effects.

View File

@ -45,13 +45,14 @@ export class CblService {
return this.httpClient.post<CblImportSummary>(this.baseUrl + 'cbl/re-validate', {fileName});
}
finalizeImport(fileName: string, decisions: CblImportDecisions, provider: ReadingListProvider,
finalizeImport(fileName: string, decisions: CblImportDecisions, provider: ReadingListProvider, promote: boolean = false,
repoMeta?: { repoPath: string; downloadUrl: string; sha: string }) {
return this.httpClient.post<CblImportSummary>(this.baseUrl + 'cbl/finalize-import', {
fileName,
decisions,
provider,
...repoMeta
...repoMeta,
promote
});
}

View File

@ -24,7 +24,7 @@ export class KavitaTitleStrategy extends TitleStrategy {
const titleSuffix = route.data['titleSuffix'] || '';
const entity = this.findInRouteTree(route, titleField);
if (entity?.[titleProp]) {
this.title.setTitle(`${entity[titleProp]}${titleSuffix} (Kavita)`);
this.title.setTitle(`${entity[titleProp]}${titleSuffix}`);
return;
}
}

View File

@ -42,6 +42,8 @@ import {ReadingListTag} from "../_models/reading-list/reading-list-tag";
import {ReadingListSortField} from "../_models/metadata/v2/reading-list-sort-field";
import {ReadingListFilterField} from "../_models/metadata/v2/reading-list-filter-field";
import {FilterEntityType} from "../_models/metadata/v2/filter-entity-type";
import {allReadingListProviders} from "../_models/reading-list/reading-list";
import {ReadingListProviderPipe} from "../_pipes/reading-list-provider.pipe";
@Injectable({
providedIn: 'root'
@ -64,6 +66,7 @@ export class MetadataService {
private ageRatingPipe = new AgeRatingPipe();
private mangaFormatPipe = new MangaFormatPipe();
private personRolePipe = new PersonRolePipe();
private readingListProviderPipe = new ReadingListProviderPipe();
getSeriesMetadataFromPlus(seriesId: number, libraryType: LibraryType) {
return this.httpClient.get<SeriesDetailPlus | null>(this.baseUrl + 'metadata/series-detail-plus?seriesId=' + seriesId + '&libraryType=' + libraryType);
@ -393,9 +396,11 @@ export class MetadataService {
return {value: tag.id, label: tag.title}
})));
case ReadingListFilterField.Writer:
return this.getPersonOptions(PersonRole.Writer)
return this.getPersonOptions(PersonRole.Writer);
case ReadingListFilterField.Artist:
return this.getPersonOptions(PersonRole.CoverArtist)
return this.getPersonOptions(PersonRole.CoverArtist);
case ReadingListFilterField.Provider:
return of(allReadingListProviders.map(p => { return {value: p, label: this.readingListProviderPipe.transform(p)} }));
}
return of([]);

View File

@ -1,176 +1,228 @@
<ng-container *transloco="let t; prefix: 'details-tab'">
<div class="details pb-3">
<div class="details pb-3">
@let filePathsValue = filePaths();
@let filesValue = files();
@let filesValue = files();
@let filePathsValue = filePaths();
@let bm = basicMetadata();
@if (accountService.hasAdminRole() && (filePathsValue.length > 0 || filesValue.length > 0)) {
<div class="mb-3 ms-1">
<h4 class="header">{{t(filesValue.length > 0 ? 'file-path-title' : 'folder-path-title')}}</h4>
<div class="ms-3 d-flex flex-column">
@if (filesValue.length > 0) {
@for (fp of filesValue; track $index) {
{{fp.filePath}}
@if (fp.koreaderHash) {
({{fp.koreaderHash}})
@if (accountService.hasAdminRole() && (filePathsValue.length > 0 || filesValue.length > 0)) {
<section class="mb-3 mt-3 ms-1">
<h4 class="kv-section-header">{{t(filesValue.length > 0 ? 'file-path-title' : 'folder-path-title')}}</h4>
<div class="d-flex flex-column gap-2">
@for (fp of filePathsValue; track $index) {
<div class="file-card">
<span class="file-path">{{fp}}</span>
</div>
} @empty {
@for (fp of filesValue; track fp.id) {
<div class="file-card">
<div class="d-flex align-items-start gap-2">
<i class="fas fa-file-archive file-card-icon" aria-hidden="true"></i>
<span class="file-path">{{fp.filePath}}</span>
</div>
<div class="file-meta-row mt-2">
<span class="file-meta-item">{{t('pages-count', {num: fp.pages | compactNumber})}}</span>
<span class="file-meta-item">{{t('bytes-count', {num: fp.bytes | bytes})}}</span>
@if (fp.koreaderHash) {
<span class="file-meta-item file-meta-hash" [ngbTooltip]="fp.koreaderHash">KOReader ✓</span>
}
</div>
</div>
}
}
} @else {
@for (fp of filePathsValue; track $index) {
{{fp}}
</div>
</section>
}
@if (showBasicMetadata() && bm) {
<section class="mb-4 ms-1">
<p class="kv-section-header">{{t('basic-metadata-title')}}</p>
<div class="label-card-grid">
@if (bm.readingTime) {
<app-label-card [label]="t('read-time-label')" [value]="bm.readingTime | readTime" />
}
}
<app-label-card [label]="t('pages-label')" [value]="bm.pages != null ? t('pages-count', {num: bm.pages | compactNumber}) : (null | defaultValue)" />
<app-label-card [label]="t('words-label')" [value]="bm.words != null ? t('words-count', {num: bm.words | compactNumber}) : (null | defaultValue)" />
<app-label-card [label]="t('added-label')" [value]="bm.addedAt | date:'shortDate' | defaultValue" />
<app-label-card [label]="t('updated-label')" [value]="(bm.updatedAt ?? null) | timeAgo | defaultValue" />
<app-label-card [label]="t('kavita-id-label')" [value]="bm.kavitaId | defaultValue" />
@if (bm.sortOrder != null) {
<app-label-card [label]="t('sort-order-label')" [value]="bm.sortOrder | defaultValue" />
}
@if (bm.isSpecial != null) {
<app-label-card [label]="t('is-special-label')" [value]="bm.isSpecial ? t('yes-label') : t('no-label')"/>
}
<app-label-card [label]="t('language-label')" [value]="languageDisplay() | defaultValue" />
@if (bm.publicationStatus != null) {
<app-label-card [label]="t('pub-status-label')" [value]="bm.publicationStatus | publicationStatus" />
}
</div>
</section>
<hr class="setting-section-break" aria-hidden="true" />
}
@let metadataEntity = entity();
@if (metadataEntity) {
<section class="mb-3 ms-1">
<h4 class="kv-section-header">{{t('external-metadata-title')}}</h4>
<app-external-metadata-detail [entity]="metadataEntity" [isbn]="isbn()" />
</section>
}
@if (webLinks().length > 0) {
<div class="mb-3 ms-1">
<h4 class="kv-section-header">{{t('weblinks-title')}}</h4>
<div class="ms-3 pill-row">
@for (link of webLinks(); track $index) {
<a [href]="link | safeUrl" target="_blank" rel="noopener noreferrer" [title]="link">
<app-tag-badge [selectionMode]="TagBadgeCursor.Clickable" color="primary">
<app-image height="16px" width="16px" aria-hidden="true" [imageUrl]="imageService.getWebLinkImage(link)"
[errorImage]="imageService.errorWebLinkImage" />
</app-tag-badge>
</a>
}
</div>
</div>
</div>
}
}
@let metadataEntity = entity();
@if(metadataEntity) {
<div class="mb-3 ms-1">
<h4 class="header">{{t('external-metadata-title')}}</h4>
<div class="ms-3">
<app-external-metadata-detail [entity]="metadataEntity" />
@if (metadataEntity || webLinks().length > 0) {
<hr class="setting-section-break mb-3" aria-hidden="true" />
}
@if (showGenres()) {
<div class="mb-3 ms-1">
<h4 class="kv-section-header">{{t('genres-title')}}</h4>
<div class="pill-row">
@if (genres().length > 0) {
@for (item of genres(); track item.id) {
<a href="javascript:void(0)" (click)="openGeneric(FilterField.Genres, item.id)">
<app-tag-badge shape="pill" [selectionMode]="TagBadgeCursor.Clickable">{{item.title}}</app-tag-badge>
</a>
}
} @else {
<span class="empty-value">{{null | defaultValue}}</span>
}
</div>
</div>
</div>
}
}
@if (showGenres()) {
<div class="mb-3 ms-1">
<h4 class="header">{{t('genres-title')}}</h4>
<div class="ms-3">
<app-badge-expander [includeComma]="true" [items]="genres()" [itemsTillExpander]="3" [defaultExpanded]="true">
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openGeneric(FilterField.Genres, item.id)">{{item.title}}</a>
</ng-template>
</app-badge-expander>
@if (showTags()) {
<div class="mb-3 ms-1">
<h4 class="kv-section-header">{{t('tags-title')}}</h4>
<div class="pill-row">
@if (tags().length > 0) {
@for (item of tags(); track item.id) {
<a href="javascript:void(0)" (click)="openGeneric(FilterField.Tags, item.id)">
<app-tag-badge shape="pill" [selectionMode]="TagBadgeCursor.Clickable">{{item.title}}</app-tag-badge>
</a>
}
} @else {
<span class="empty-value">{{null | defaultValue}}</span>
}
</div>
</div>
}
@if (hasUpperMetadata()) {
<div class="setting-section-break" aria-hidden="true"></div>
}
<div class="">
<app-carousel-reel [items]="metadata().writers" [title]="t('writers-title')" headerClass="kv-section-header dark-exempt">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" size="medium" />
</ng-template>
</app-carousel-reel>
</div>
}
@if (showTags()) {
<div class="mb-3 ms-1">
<h4 class="header">{{t('tags-title')}}</h4>
<div class="ms-3">
<app-badge-expander [includeComma]="true" [items]="tags()" [itemsTillExpander]="3" [defaultExpanded]="true">
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openGeneric(FilterField.Tags, item.id)">{{item.title}}</a>
</ng-template>
</app-badge-expander>
</div>
<div class="">
<app-carousel-reel [items]="metadata().colorists" [title]="t('colorists-title')" headerClass="kv-section-header dark-exempt">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" size="medium" />
</ng-template>
</app-carousel-reel>
</div>
<div class="mb-3">
<app-carousel-reel [items]="metadata().editors" [title]="t('editors-title')" headerClass="kv-section-header dark-exempt">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" size="medium" />
</ng-template>
</app-carousel-reel>
</div>
<div class="mb-3">
<app-carousel-reel [items]="metadata().coverArtists" [title]="t('cover-artists-title')" headerClass="kv-section-header dark-exempt">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" size="medium" />
</ng-template>
</app-carousel-reel>
</div>
<div class="mb-3">
<app-carousel-reel [items]="metadata().inkers" [title]="t('inkers-title')" headerClass="kv-section-header dark-exempt">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" size="medium" />
</ng-template>
</app-carousel-reel>
</div>
<div class="mb-3">
<app-carousel-reel [items]="metadata().letterers" [title]="t('letterers-title')" headerClass="kv-section-header dark-exempt">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" size="medium" />
</ng-template>
</app-carousel-reel>
</div>
<div class="mb-3">
<app-carousel-reel [items]="metadata().pencillers" [title]="t('pencillers-title')" headerClass="kv-section-header dark-exempt">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" size="medium" />
</ng-template>
</app-carousel-reel>
</div>
<div class="mb-3">
<app-carousel-reel [items]="metadata().translators" [title]="t('translators-title')" headerClass="kv-section-header dark-exempt">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" size="medium" />
</ng-template>
</app-carousel-reel>
</div>
<div class="mb-3">
<app-carousel-reel [items]="metadata().characters" [title]="t('characters-title')" headerClass="kv-section-header dark-exempt">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" size="medium" />
</ng-template>
</app-carousel-reel>
</div>
<div class="mb-3">
<app-carousel-reel [items]="metadata().locations" [title]="t('locations-title')" headerClass="kv-section-header dark-exempt">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" size="medium" />
</ng-template>
</app-carousel-reel>
</div>
<div class="mb-3">
<app-carousel-reel [items]="metadata().teams" [title]="t('teams-title')" headerClass="kv-section-header dark-exempt">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" size="medium" />
</ng-template>
</app-carousel-reel>
</div>
<div class="mb-3">
<app-carousel-reel [items]="metadata().imprints" [title]="t('imprints-title')" headerClass="kv-section-header dark-exempt">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" size="medium" />
</ng-template>
</app-carousel-reel>
</div>
}
<div class="mb-3">
<app-carousel-reel [items]="webLinks()" [title]="t('weblinks-title')">
<ng-template #carouselItem let-item>
<a class="me-1" [href]="item | safeUrl" target="_blank" rel="noopener noreferrer" [title]="item">
<app-image height="24px" width="24px" aria-hidden="true" [imageUrl]="imageService.getWebLinkImage(item)"
[errorImage]="imageService.errorWebLinkImage" />
</a>
</ng-template>
</app-carousel-reel>
</div>
@if (hasUpperMetadata()) {
<div class="setting-section-break" aria-hidden="true"></div>
}
<div class="mb-3">
<app-carousel-reel [items]="metadata().writers" [title]="t('writers-title')">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" />
</ng-template>
</app-carousel-reel>
</div>
<div class="mb-3">
<app-carousel-reel [items]="metadata().colorists" [title]="t('colorists-title')">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" />
</ng-template>
</app-carousel-reel>
</div>
<div class="mb-3">
<app-carousel-reel [items]="metadata().editors" [title]="t('editors-title')">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" />
</ng-template>
</app-carousel-reel>
</div>
<div class="mb-3">
<app-carousel-reel [items]="metadata().coverArtists" [title]="t('cover-artists-title')">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" />
</ng-template>
</app-carousel-reel>
</div>
<div class="mb-3">
<app-carousel-reel [items]="metadata().inkers" [title]="t('inkers-title')">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" />
</ng-template>
</app-carousel-reel>
</div>
<div class="mb-3">
<app-carousel-reel [items]="metadata().letterers" [title]="t('letterers-title')">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" />
</ng-template>
</app-carousel-reel>
</div>
<div class="mb-3">
<app-carousel-reel [items]="metadata().pencillers" [title]="t('pencillers-title')">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" />
</ng-template>
</app-carousel-reel>
</div>
<div class="mb-3">
<app-carousel-reel [items]="metadata().translators" [title]="t('translators-title')">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" />
</ng-template>
</app-carousel-reel>
</div>
<div class="mb-3">
<app-carousel-reel [items]="metadata().characters" [title]="t('characters-title')">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" />
</ng-template>
</app-carousel-reel>
</div>
<div class="mb-3">
<app-carousel-reel [items]="metadata().locations" [title]="t('locations-title')">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" />
</ng-template>
</app-carousel-reel>
</div>
<div class="mb-3">
<app-carousel-reel [items]="metadata().teams" [title]="t('teams-title')">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" />
</ng-template>
</app-carousel-reel>
</div>
<div class="mb-3">
<app-carousel-reel [items]="metadata().imprints" [title]="t('imprints-title')">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" />
</ng-template>
</app-carousel-reel>
</div>
</div>
</ng-container>

View File

@ -1 +1,65 @@
.label-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 8px;
}
.section-sep {
border: none;
border-top: 1px solid var(--setting-break-color);
margin: 0;
}
.pill-row {
display: flex;
flex-wrap: wrap;
gap: 5px;
a {
text-decoration: none;
}
}
.file-card {
background: var(--label-card-bg);
border: 1px solid var(--label-card-border);
border-radius: 5px;
padding: 10px 12px;
}
.file-card-icon {
font-size: 0.75rem;
color: var(--label-card-icon-color);
opacity: 0.6;
margin-top: 2px;
flex-shrink: 0;
}
.file-path {
font-family: 'Courier New', monospace;
font-size: 0.72rem;
color: var(--file-path-color);
word-break: break-all;
line-height: 1.4;
}
.file-meta-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.file-meta-item {
font-size: 0.72rem;
color: var(--text-muted-color);
}
.empty-value {
font-size: 0.82rem;
color: var(--label-card-value-muted-color);
}
.file-meta-hash {
color: var(--primary-color);
opacity: 0.8;
}

View File

@ -1,27 +1,54 @@
import {ChangeDetectionStrategy, Component, computed, inject, input} from '@angular/core';
import {CarouselReelComponent} from "../../carousel/_components/carousel-reel/carousel-reel.component";
import {PersonBadgeComponent} from "../../shared/person-badge/person-badge.component";
import {TranslocoDirective} from "@jsverse/transloco";
import {IHasCast} from "../../_models/common/i-has-cast";
import {PersonRole} from "../../_models/metadata/person";
import {SeriesFilterField} from "../../_models/metadata/v2/series-filter-field";
import {FilterComparison} from "../../_models/metadata/v2/filter-comparison";
import {FilterUtilitiesService} from "../../shared/_services/filter-utilities.service";
import {Genre} from "../../_models/metadata/genre";
import {Tag} from "../../_models/tag";
import {ImageComponent} from "../../shared/image/image.component";
import {ImageService} from "../../_services/image.service";
import {BadgeExpanderComponent} from "../../shared/badge-expander/badge-expander.component";
import {MangaFormat} from "../../_models/manga-format";
import {SafeUrlPipe} from "../../_pipes/safe-url.pipe";
import {AccountService} from "../../_services/account.service";
import {MangaFile} from "../../_models/manga-file";
import {Series} from "../../_models/series";
import {Volume} from "../../_models/volume";
import {Chapter} from "../../_models/chapter";
import {ChangeDetectionStrategy, Component, computed, effect, inject, input, signal} from '@angular/core';
import {CarouselReelComponent} from '../../carousel/_components/carousel-reel/carousel-reel.component';
import {PersonBadgeComponent} from '../../shared/person-badge/person-badge.component';
import {TranslocoDirective} from '@jsverse/transloco';
import {IHasCast} from '../../_models/common/i-has-cast';
import {PersonRole} from '../../_models/metadata/person';
import {SeriesFilterField} from '../../_models/metadata/v2/series-filter-field';
import {FilterComparison} from '../../_models/metadata/v2/filter-comparison';
import {FilterUtilitiesService} from '../../shared/_services/filter-utilities.service';
import {Genre} from '../../_models/metadata/genre';
import {Tag} from '../../_models/tag';
import {ImageComponent} from '../../shared/image/image.component';
import {ImageService} from '../../_services/image.service';
import {MangaFormat} from '../../_models/manga-format';
import {SafeUrlPipe} from '../../_pipes/safe-url.pipe';
import {AccountService} from '../../_services/account.service';
import {MangaFile} from '../../_models/manga-file';
import {Series} from '../../_models/series';
import {Volume} from '../../_models/volume';
import {Chapter} from '../../_models/chapter';
import {
ExternalMetadataDetailComponent
} from "../../shared/_components/external-metadata-detail/external-metadata-detail.component";
} from '../../shared/_components/external-metadata-detail/external-metadata-detail.component';
import {LabelCardComponent} from '../label-card/label-card.component';
import {TagBadgeComponent, TagBadgeCursor} from '../../shared/tag-badge/tag-badge.component';
import {DefaultValuePipe} from '../../_pipes/default-value.pipe';
import {BytesPipe} from '../../_pipes/bytes.pipe';
import {TimeAgoPipe} from '../../_pipes/time-ago.pipe';
import {DatePipe} from '@angular/common';
import {PublicationStatus} from '../../_models/metadata/publication-status';
import {PublicationStatusPipe} from '../../_pipes/publication-status.pipe';
import {ReadTimePipe} from '../../_pipes/read-time.pipe';
import {IHasReadingTime} from '../../_models/common/i-has-reading-time';
import {CompactNumberPipe} from "../../_pipes/compact-number.pipe";
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {MetadataService} from "../../_services/metadata.service";
export interface BasicMetadataInfo {
readingTime?: IHasReadingTime | null;
pages?: number | null;
words?: number | null;
addedAt?: string | null;
updatedAt?: string | null;
kavitaId?: number | null;
sortOrder?: number | null;
isSpecial?: boolean | null;
language?: string | null;
publicationStatus?: PublicationStatus | null;
publicationStatusCurrent?: number | null;
publicationStatusTotal?: number | null;
}
@Component({
selector: 'app-details-tab',
@ -30,10 +57,18 @@ import {
PersonBadgeComponent,
TranslocoDirective,
ImageComponent,
BadgeExpanderComponent,
SafeUrlPipe,
ExternalMetadataDetailComponent,
LabelCardComponent,
TagBadgeComponent,
DefaultValuePipe,
BytesPipe,
TimeAgoPipe,
DatePipe,
PublicationStatusPipe,
ReadTimePipe,
CompactNumberPipe,
NgbTooltip,
],
templateUrl: './details-tab.component.html',
styleUrl: './details-tab.component.scss',
@ -43,11 +78,13 @@ export class DetailsTabComponent {
protected readonly imageService = inject(ImageService);
private readonly filterUtilityService = inject(FilterUtilitiesService);
private readonly metadataService = inject(MetadataService);
protected readonly accountService = inject(AccountService);
protected readonly PersonRole = PersonRole;
protected readonly FilterField = SeriesFilterField;
protected readonly MangaFormat = MangaFormat;
protected readonly TagBadgeCursor = TagBadgeCursor;
metadata = input.required<IHasCast>();
entity = input<Series | Volume | Chapter>();
@ -58,14 +95,36 @@ export class DetailsTabComponent {
suppressEmptyTags = input<boolean>(false);
filePaths = input<string[]>([]);
files = input<MangaFile[]>([]);
basicMetadata = input<BasicMetadataInfo>();
hasUpperMetadata = computed(() => {
return this.genres().length > 0 || this.tags().length > 0 || this.webLinks().length > 0;
});
showBasicMetadata = computed(() => !!this.basicMetadata());
hasUpperMetadata = computed(() => this.genres().length > 0 || this.tags().length > 0 || this.webLinks().length > 0);
showTags = computed(() => !this.suppressEmptyTags() || this.tags().length > 0);
showGenres = computed(() => !this.suppressEmptyGenres() || this.genres().length > 0);
isbn = computed(() => {
const entity = this.entity();
if (!entity?.hasOwnProperty('isbn')) return null;
return (this.entity() as Chapter).isbn;
});
languageName = signal<string | null>(null);
languageDisplay = computed(() => {
return this.languageName() ?? this.basicMetadata()?.language;
});
constructor() {
effect(() => {
const lang = this.basicMetadata()?.language;
const langName = this.languageName();
if (lang && !langName) {
this.metadataService.getLanguageNameForCode(lang).subscribe(fullCode => {
this.languageName.set(fullCode);
});
}
});
}
openGeneric(queryParamName: SeriesFilterField, filter: string | number) {
if (queryParamName === SeriesFilterField.None) return;

View File

@ -252,6 +252,7 @@ export class EditChapterModalComponent implements OnInit {
this.chapter.malId = model.malId;
this.chapter.hardcoverId = model.hardcoverId;
this.chapter.metronId = model.metronId;
this.chapter.language = model.language;
const apis = [

View File

@ -0,0 +1,15 @@
<div class="label-card">
<span class="label-card-label">{{label()}}</span>
@if (value() != null) {
@if (linkUrl()) {
<a class="label-card-value" [href]="linkUrl() | safeUrl" target="_blank" rel="noopener noreferrer">
{{value()}}<i class="fa-solid fa-external-link ms-1" aria-hidden="true"></i>
</a>
} @else {
<span class="label-card-value">{{value()}}</span>
}
} @else {
<ng-content />
}
</div>

View File

@ -0,0 +1,34 @@
.label-card {
background: var(--label-card-bg);
border: 1px solid var(--label-card-border);
border-radius: 5px;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 3px;
}
.label-card-icon {
font-size: 0.65rem;
color: var(--label-card-icon-color);
opacity: 0.8;
margin-bottom: 2px;
display: flex;
align-items: center;
}
.label-card-label {
font-size: 0.68rem;
color: var(--label-card-label-color);
line-height: 1;
}
.label-card-value {
font-size: 0.82rem;
font-weight: 500;
line-height: 1.3;
&:not(a) {
color: var(--label-card-value-color);
}
}

View File

@ -0,0 +1,20 @@
import {ChangeDetectionStrategy, Component, input} from '@angular/core';
import {SafeUrlPipe} from "../../_pipes/safe-url.pipe";
export type LabelCardValueColor = 'default' | 'green' | 'muted';
@Component({
selector: 'app-label-card',
templateUrl: './label-card.component.html',
styleUrl: './label-card.component.scss',
imports: [
SafeUrlPipe
],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class LabelCardComponent {
label = input.required<string>();
value = input<string | number | null | undefined>();
/** When link provided, the value will render as a link **/
linkUrl = input<string | undefined>(undefined);
}

View File

@ -19,7 +19,7 @@ import {Annotation} from "../book-reader/_models/annotations/annotation";
import {Pagination} from "../_models/pagination";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {map, tap} from "rxjs/operators";
import {AnnotationsFilterSettings} from "../metadata-filter/filter-settings";
import {AnnotationFilterSettings} from "../metadata-filter/filter-settings";
import {
AnnotationsFilter,
AnnotationsFilterField,
@ -74,7 +74,7 @@ export class AllAnnotationsComponent implements OnInit {
filterActive = signal(false);
filter = signal<AnnotationsFilter | undefined>(undefined);
filterSettings: AnnotationsFilterSettings = new AnnotationsFilterSettings();
filterSettings: AnnotationFilterSettings = new AnnotationFilterSettings();
trackByIdentity = (idx: number, item: Annotation) => `${item.id}`;
refresh: EventEmitter<void> = new EventEmitter();
filterOpen: EventEmitter<boolean> = new EventEmitter();

View File

@ -48,6 +48,9 @@ export class VersionUpdateModalComponent {
this.bustLocaleCache();
// Refresh manually
location.reload();
// Dismiss anyway in case reload doesn't work
this.modal.dismiss();
}

View File

@ -211,18 +211,19 @@ export class BookLineOverlayComponent implements OnInit {
switchMode(mode: BookLineOverlayMode) {
this.mode.set(mode);
if (mode === BookLineOverlayMode.Bookmark) {
this.bookmarkForm.get('name')?.setValue(this.selectedText());
this.focusOnBookmarkInput();
return;
}
// On mobile, first selection might not match as users can select after the fact. Recalculate
const windowText = window.getSelection();
const selectedText = windowText?.toString() === '' ? this.selectedText() : windowText?.toString() ?? this.selectedText();
if (mode === BookLineOverlayMode.Annotate) {
if (mode === BookLineOverlayMode.Bookmark) {
this.bookmarkForm.get('name')?.setValue(selectedText);
this.focusOnBookmarkInput();
return;
}
if (mode === BookLineOverlayMode.Annotate) {
const createAnnotation = {
id: 0,
xPath: this.startXPath,

View File

@ -25,7 +25,6 @@
<app-entity-card [entity]="item" [config]="bookmarkConfig()"
[index]="position" [maxIndex]="bookmarkEntities().length"
(reload)="clearBookmarks(item.data.series)"
/>
</ng-template>

View File

@ -40,7 +40,7 @@
}
<virtual-scroller [ngClass]="{'empty': items().length === 0 && !isLoading()}" #scroll [items]="items()" [bufferAmount]="bufferAmount" [parentScroll]="parentScroll()!">
<div class="grid row g-0" #container id="card-detail-layout-items-container">
<div class="grid row g-0" #container id="card-detail-layout-items-container" [style.grid-template-columns]="gridColumnsTemplate()">
@for (item of scroll.viewPortItems; track trackItem(i, item); let i = $index) {
<div class="card col-auto mt-2 mb-2 card-detail-layout-item"
(click)="tryToSaveJumpKey(item)"

View File

@ -101,6 +101,7 @@ export class CardDetailLayoutComponent<TFilter extends number, TSort extends num
*/
customSort = input(false);
jumpBarKeys = input<Array<JumpKey>>([]); // This is approx 784 pixels tall, original keys
gridColumnsTemplate = input('repeat(auto-fill, 10rem)');
itemClicked = output<any>();
applyFilter = output<FilterEvent>();

View File

@ -8,9 +8,9 @@
}
<h4 class="header" (click)="sectionClicked($event)" [ngClass]="{'non-selectable': !clickableTitle}">
@if (titleLink !== '') {
<a [href]="titleLink | safeUrl" class="section-title">{{title}}</a>
<a [href]="titleLink | safeUrl" [class]="headerClass()">{{title}}</a>
} @else {
<a href="javascript:void(0)" class="section-title">{{title}}</a>
<a href="javascript:void(0)" [class]="headerClass()">{{title}}</a>
}
@if (iconClasses !== '') {

View File

@ -67,6 +67,7 @@ export class CarouselReelComponent {
* If using actionables, this is the entity to allow Action.Service to handle logic
*/
@Input() actionableEntity: ActionableEntity = null;
headerClass = input<string>('section-title');
readonly sectionClick = output<string>();
readonly handleAction = output<ActionItem<any>>();

View File

@ -176,6 +176,7 @@
[tags]="chapterValue.tags"
[webLinks]="weblinks()"
[files]="chapterValue.files"
[basicMetadata]="chapterBasicMetadata()"
/>
}
</ng-template>

View File

@ -42,7 +42,7 @@ import {BulkSelectionService} from "../cards/bulk-selection.service";
import {ReaderService} from "../_services/reader.service";
import {AccountService} from "../_services/account.service";
import {ReadMoreComponent} from "../shared/read-more/read-more.component";
import {DetailsTabComponent} from "../_single-module/details-tab/details-tab.component";
import {BasicMetadataInfo, DetailsTabComponent} from "../_single-module/details-tab/details-tab.component";
import {EntityTitleComponent} from "../cards/entity-title/entity-title.component";
import {EditChapterModalComponent} from "../_single-module/edit-chapter-modal/edit-chapter-modal.component";
import {SeriesFilterField} from "../_models/metadata/v2/series-filter-field";
@ -194,7 +194,22 @@ export class ChapterDetailComponent implements OnInit {
return hasAnyCast(chp) || (chp?.genres || []).length > 0 ||
(chp?.tags || []).length > 0 || (chp?.webLinks || []).length > 0 || this.accountService.hasAdminRole();
})
mobileSeriesImgBackground = this.themeService.getCssVariable('--mobile-series-img-background');
chapterBasicMetadata = computed<BasicMetadataInfo>(() => {
const c = this.chapter();
return {
readingTime: c,
pages: c.pages,
words: c.wordCount,
addedAt: c.createdUtc,
updatedAt: c.createdUtc,
kavitaId: c.id,
sortOrder: c.sortOrder,
isSpecial: c.isSpecial,
language: c.language || null,
publicationStatus: c.publicationStatus ?? null,
};
});
mobileSeriesImgBackground = this.themeService.getCssVariable('--mobile-series-img-background')
activeTabId = Tabs.Details;

View File

@ -1,6 +1,6 @@
<div class="image-container {{imageFitClass$ | async}}"
[ngClass]="{'d-none': !renderWithCanvas }"
[style.filter]="(darkness$ | async) ?? '' | safeStyle">
<canvas #content ondragstart="return false;" onselectstart="return false;" class="{{imageFitClass$ | async}}"></canvas>
<canvas #content tabindex="0" [style.outline]="'none'" ondragstart="return false;" onselectstart="return false;" class="{{imageFitClass$ | async}}"></canvas>
</div>

View File

@ -50,7 +50,7 @@ export class CanvasRendererComponent implements OnInit, AfterViewInit, ImageRend
readonly imageHeight = output<number>();
readonly canvas = viewChild<ElementRef>('content');
readonly canvas = viewChild<ElementRef<HTMLCanvasElement>>('content');
private ctx!: CanvasRenderingContext2D;
currentImageSplitPart: SPLIT_PAGE_PART = SPLIT_PAGE_PART.NO_SPLIT;
@ -135,7 +135,7 @@ export class CanvasRendererComponent implements OnInit, AfterViewInit, ImageRend
ngAfterViewInit() {
const canvas = this.canvas();
if (canvas) {
this.ctx = canvas.nativeElement.getContext('2d', { alpha: false });
this.ctx = canvas.nativeElement.getContext('2d', { alpha: false })!;
}
}

View File

@ -4,6 +4,7 @@
[ngClass]="{'center-double': (shouldRenderDouble$ | async)}">
@if (currentImage) {
<img alt=" "
tabindex="0" [style.outline]="'none'"
#image [src]="currentImage.src"
id="image-1"
class="{{imageFitClass$ | async}} {{readerModeClass$ | async}} {{showClickOverlayClass$ | async}}"

View File

@ -1,5 +1,15 @@
import { DOCUMENT, NgClass, AsyncPipe } from '@angular/common';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit, output } from '@angular/core';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef, ElementRef,
inject,
Input,
OnInit,
output,
viewChild
} from '@angular/core';
import { Observable, of, map, tap, shareReplay, filter, combineLatest } from 'rxjs';
import { PageSplitOption } from 'src/app/_models/preferences/page-split-option';
import { ReaderMode } from 'src/app/_models/preferences/reader-mode';
@ -28,6 +38,7 @@ export class DoubleNoCoverRendererComponent implements OnInit {
private document = inject<Document>(DOCUMENT);
readerService = inject(ReaderService);
readonly imageElement = viewChild<ElementRef<HTMLImageElement>>('image');
@Input({required: true}) readerSettings$!: Observable<ReaderSetting>;
@Input({required: true}) image$!: Observable<HTMLImageElement | null>;

View File

@ -4,6 +4,7 @@
[ngClass]="{'center-double': (shouldRenderDouble$ | async)}">
@if (currentImage) {
<img alt=" "
tabindex="0" [style.outline]="'none'"
#image [src]="currentImage.src"
id="image-1"
class="{{imageFitClass$ | async}} {{readerModeClass$ | async}} {{showClickOverlayClass$ | async}}"

View File

@ -1,5 +1,15 @@
import { DOCUMENT, NgClass, AsyncPipe } from '@angular/common';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit, output } from '@angular/core';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef, ElementRef,
inject,
Input,
OnInit,
output,
viewChild
} from '@angular/core';
import { Observable, of, map, tap, shareReplay, filter, combineLatest } from 'rxjs';
import { PageSplitOption } from 'src/app/_models/preferences/page-split-option';
import { ReaderMode } from 'src/app/_models/preferences/reader-mode';
@ -28,6 +38,8 @@ export class DoubleRendererComponent implements OnInit, ImageRenderer {
private document = inject<Document>(DOCUMENT);
readerService = inject(ReaderService);
readonly imageElement = viewChild<ElementRef<HTMLImageElement>>('image');
@Input({required: true}) readerSettings$!: Observable<ReaderSetting>;
@Input({required: true}) image$!: Observable<HTMLImageElement | null>;

View File

@ -5,6 +5,7 @@
@if(leftImage) {
<img alt=" "
tabindex="0" [style.outline]="'none'"
#image [src]="leftImage.src"
id="image-1"
class="{{imageFitClass$ | async}} {{readerModeClass$ | async}} {{showClickOverlayClass$ | async}}">

View File

@ -1,5 +1,15 @@
import {AsyncPipe, DOCUMENT, NgClass} from '@angular/common';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit, output } from '@angular/core';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef, ElementRef,
inject,
Input,
OnInit,
output,
viewChild
} from '@angular/core';
import {combineLatest, filter, map, Observable, of, shareReplay, tap} from 'rxjs';
import {PageSplitOption} from 'src/app/_models/preferences/page-split-option';
import {ReaderMode} from 'src/app/_models/preferences/reader-mode';
@ -29,7 +39,7 @@ export class DoubleReverseRendererComponent implements OnInit, ImageRenderer {
private document = inject<Document>(DOCUMENT);
readerService = inject(ReaderService);
readonly imageElement = viewChild<ElementRef<HTMLImageElement>>('image');
@Input({required: true}) readerSettings$!: Observable<ReaderSetting>;
@Input({required: true}) image$!: Observable<HTMLImageElement | null>;

View File

@ -21,7 +21,7 @@
[scrollContainer]="scrollElement"
(triggered)="loadPrevChapter.emit()" />
<div infinite-scroll [infiniteScrollDistance]="1" [infiniteScrollThrottle]="50">
<div #scroller tabindex="0" [style.outline]="'none'" infinite-scroll [infiniteScrollDistance]="1" [infiniteScrollThrottle]="50">
@for(item of webtoonImages | async; let index = $index; track item.src) {
<img src="{{item.src}}" style="display: block;"
[style.filter]="(darkness$ | async) ?? '' | safeStyle"

View File

@ -12,7 +12,7 @@
img, .full-width {
max-width: 100% !important;
height: auto;
//height: auto; // This can cause (rarely) a small line between panels
}
// This is to force hardware acceleration to help address https://github.com/Kareadita/Kavita/issues/1848

View File

@ -1,5 +1,6 @@
import {AsyncPipe, DOCUMENT} from '@angular/common';
import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
@ -16,7 +17,7 @@ import {
output,
Renderer2,
Signal,
SimpleChanges
SimpleChanges, viewChild
} from '@angular/core';
import {BehaviorSubject, fromEvent, map, Observable, of, ReplaySubject, Subject, tap} from 'rxjs';
import {debounceTime, distinctUntilChanged} from 'rxjs/operators';
@ -86,7 +87,7 @@ const enum DEBUG_MODES {
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [AsyncPipe, TranslocoDirective, InfiniteScrollDirective, SafeStylePipe, PullToLoadComponent]
})
export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy, AfterViewInit {
private readonly document = inject<Document>(DOCUMENT);
private readonly mangaReaderService = inject(MangaReaderService);
private readonly readerService = inject(ReaderService);
@ -96,6 +97,8 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
private readonly destroyRef = inject(DestroyRef);
protected readonly breakpointService = inject(BreakpointService);
scrollContainer = viewChild.required<ElementRef<HTMLDivElement>>('scroller');
get scrollElement(): HTMLElement {
return this.isFullscreenMode ? this.readerElemRef.nativeElement : this.document.body;
}
@ -248,6 +251,10 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
this.intersectionObserver.disconnect();
}
ngAfterViewInit() {
this.scrollContainer().nativeElement.focus();
}
/**
* Responsible for binding the scroll handler to the correct event. On non-fullscreen, body is correct. However, on fullscreen, we must use the reader as that is what
* gets promoted to fullscreen.
@ -257,8 +264,10 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
// Reset any modal-induced overflow lock (this can happen when Starting Over and ngBootstrap modal hasn't completed teardown)
if (element === this.document.body) {
this.document.body.style.overflow = 'auto';
this.document.body.classList.remove('modal-open'); // ngBootstrap adds this
setTimeout(() => {
this.document.body.style.overflow = 'auto';
this.document.body.classList.remove('modal-open'); // ngBootstrap adds this
}, 100);
}
fromEvent(element, 'scroll')
@ -296,8 +305,6 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
takeUntilDestroyed(this.destroyRef)
);
// We need the injector as toSignal is only allowed in injection context
// https://angular.dev/guide/signals#injection-context
this.readerSettings = toSignal(this.readerSettings$, {injector: this.injector, requireSync: true});
// Automatically updates when the breakpoint changes, or when reader settings changes
@ -311,9 +318,9 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
return (parseInt(value) <= 0) ? '' : value + '%';
});
//perform jump so the page stays in view
// perform jump so the page stays in view
effect(() => {
const width = this.widthOverride(); // needs to be at the top for effect to work
const width = this.widthOverride();
this.currentPageElem = this.document.querySelector('img#page-' + this.pageNum);
if(!this.currentPageElem)
return;

View File

@ -145,6 +145,13 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
readonly doubleReverseRenderer = viewChild(DoubleReverseRendererComponent);
readonly doubleNoCoverRenderer = viewChild(DoubleNoCoverRendererComponent);
readonly imageElement = computed(() =>
this.singleRenderer()?.imageElement()
?? this.doubleRenderer()?.imageElement()
?? this.doubleReverseRenderer()?.imageElement()
?? this.doubleNoCoverRenderer()?.imageElement()
?? this.canvasRenderer()?.canvas());
private readonly destroyRef = inject(DestroyRef);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
@ -629,6 +636,14 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
})
})
).subscribe();
this.currentImage$.pipe(
filter(() => this.readerMode !== ReaderMode.Webtoon),
filter(img => !!img),
tap(() => {
this.imageElement()?.nativeElement?.focus();
}),
).subscribe();
}
ngAfterViewInit() {

View File

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

View File

@ -10,7 +10,7 @@ import {
Input,
OnInit,
Signal,
output
output, viewChild, ElementRef, effect
} from '@angular/core';
import {combineLatest, filter, map, Observable, of, shareReplay, switchMap, tap} from 'rxjs';
import {PageSplitOption} from 'src/app/_models/preferences/page-split-option';
@ -46,6 +46,8 @@ export class SingleRendererComponent implements OnInit, ImageRenderer {
@Input({required: true}) showClickOverlay$!: Observable<boolean>;
@Input({required: true}) pageNum$!: Observable<{pageNum: number, maxPages: number}>;
readonly imageElement = viewChild<ElementRef<HTMLImageElement>>('image');
readonly imageHeight = output<number>();
private readonly destroyRef = inject(DestroyRef);

View File

@ -296,7 +296,7 @@ export class MetadataFilterRowComponent<TFilter extends number = number, TSort e
const dropdownFieldsWithoutMustContains = this.filterUtilitiesService.getDropdownFieldsWithoutMustContains<TFilter>(this.entityType());
const customComparisons = this.filterUtilitiesService.getCustomComparisons(this.entityType(), inputVal);
let baseComparisons: FilterComparison[];
let baseComparisons: FilterComparison[] = [];
let predicateType: PredicateType;
let defaultValue: string | number | boolean;
@ -329,13 +329,14 @@ export class MetadataFilterRowComponent<TFilter extends number = number, TSort e
}
predicateType = PredicateType.Dropdown;
defaultValue = 0;
} else {
} else{
return;
}
if (fieldsThatShouldIncludeIsEmpty.includes(inputVal)) baseComparisons.push(FilterComparison.IsEmpty);
if (fieldsThatShouldIncludeIsNotEmpty.includes(inputVal)) baseComparisons.push(FilterComparison.IsNotEmpty);
// Custom comparisons need to also be included in some base type to drive the fields
const comps = (customComparisons?.length ?? 0) > 0 ? customComparisons : baseComparisons;
this.validComparisons$.next([...new Set(comps)]);

View File

@ -39,7 +39,7 @@ export class PersonFilterSettings extends FilterSettingsBase<PersonFilterField,
type: ValidFilterEntity = 'person';
}
export class AnnotationsFilterSettings extends FilterSettingsBase<AnnotationsFilterField, AnnotationsSortField> {
export class AnnotationFilterSettings extends FilterSettingsBase<AnnotationsFilterField, AnnotationsSortField> {
type : ValidFilterEntity = 'annotation';
}

View File

@ -132,9 +132,9 @@ export class ReadingListDetailComponent implements OnInit {
const startMonth = rl.startingMonth > 0 ? rl.startingMonth - 1 : undefined;
const endMonth = rl.startingMonth > 0 ? rl.endingMonth - 1 : undefined;
const startDate = startMonth !== undefined ? new Date(rl.startingYear, startMonth) : new Date(rl.startingYear);
const startDate = startMonth !== undefined ? new Date(rl.startingYear, startMonth) : new Date(rl.startingYear, 0);
const endDate = rl.endingYear <= 0 ? null :
(endMonth !== undefined ? new Date(rl.endingYear, endMonth) : new Date(rl.endingYear));
(endMonth !== undefined ? new Date(rl.endingYear, endMonth) : new Date(rl.endingYear, 0));
return this.dateYearRangePipe.transform(startDate, endDate, !!endMonth);
});

View File

@ -77,9 +77,7 @@
<!-- Summary with blur and fade -->
@if (summary()) {
<div class="summary-text" [appBlurToggle]="shouldBlur()" [appBlurToggleEnabled]="blurEnabled()">
{{summary()}}
</div>
<div class="summary-text" [appBlurToggle]="shouldBlur()" [appBlurToggleEnabled]="blurEnabled()" [innerHTML]="summary()"></div>
}
</div>

View File

@ -11,6 +11,7 @@ import {BlurToggleDirective} from "../../../_directives/blur-toggle.directive";
import {LooseLeafOrDefaultNumber} from "../../../_models/chapter";
import {DateYearRangePipe, NULL_DATE} from "../../../_pipes/date-year-range.pipe";
import {DefaultValuePipe} from "../../../_pipes/default-value.pipe";
import {SafeHtmlPipe} from "../../../_pipes/safe-html.pipe";
@Component({
selector: 'app-reading-list-item',
@ -23,6 +24,7 @@ export class ReadingListItemComponent {
protected readonly imageService = inject(ImageService);
private readonly accountService = inject(AccountService);
private readonly safeHtmlPipe = new SafeHtmlPipe();
item = input.required<ReadingListItem>();
position = input(0);
@ -43,18 +45,13 @@ export class ReadingListItemComponent {
return translate('common.issue-num-shorthand', {num: chNum})
});
releaseDate = computed(() => this.item().chapter?.releaseDate || this.item().releaseDate);
summary = computed(() => this.item().chapter?.summary || this.item().summary);
summary = computed(() => this.safeHtmlPipe.transform(this.item().chapter?.summary ?? this.item().summary ?? ''));
pages = computed(() => this.item().chapter?.pages ?? this.item().pagesTotal);
writerName = computed(() => this.item().chapter?.writerName);
pencillerName = computed(() => this.item().chapter?.pencillerName);
isUnread = computed(() => this.item().pagesRead === 0 && this.pages() > 0);
isInProgress = computed(() => this.item().pagesRead > 0 && this.item().pagesRead < this.pages());
progressPercent = computed(() => {
const total = this.pages();
if (total === 0) return 0;
return Math.round((this.item().pagesRead / total) * 100);
});
blurEnabled = computed(() => !!this.accountService.userPreferences()?.blurUnreadSummaries);
shouldBlur = computed(() => this.blurEnabled() && this.isUnread());

View File

@ -12,6 +12,7 @@
</app-side-nav-companion-bar>
<app-card-detail-layout
[gridColumnsTemplate]="'repeat(auto-fill, minmax(30rem, 1fr))'"
[isLoading]="isLoadingLists()"
[items]="listEntities()"
[pagination]="pagination()!"
@ -33,13 +34,6 @@
<app-reading-list [entity]="item" [index]="position" [maxIndex]="listEntities().length" [config]="readingListConfig()"
(reload)="loadPage()" (dataChanged)="updateReadingList($event)" />
</div>
<!-- <app-entity-card [entity]="item" [index]="position" [maxIndex]="listEntities().length" [config]="readingListConfig()"-->
<!-- (reload)="loadPage()" (dataChanged)="updateReadingList($event)">-->
<!-- <ng-template #title let-entity>-->
<!-- <app-promoted-icon [promoted]="entity.data.promoted" /> {{entity.data.title}}-->
<!-- </ng-template>-->
<!-- </app-entity-card>-->
</ng-template>
<ng-template #noData>

View File

@ -4,8 +4,6 @@
}
::ng-deep #card-detail-layout-items-container {
grid-template-columns: repeat(auto-fill, minmax(30rem, 1fr)) !important;
.card-detail-layout-item {
background-color: transparent !important;
max-width: 100%;

View File

@ -124,8 +124,11 @@ export class ReadingListsComponent implements OnInit {
updateReadingList(updatedEntity: ReadingList) {
const originalEntity = this.lists().find(s => s.id == updatedEntity.id);
if (originalEntity) {
Object.assign(originalEntity, updatedEntity);
this.lists.set([...this.lists()]);
this.lists.update(l => [...l.map(item => {
if (item.id == updatedEntity.id) return updatedEntity;
return item;
})]);
}
}

View File

@ -1,5 +1,3 @@
<ng-container *transloco="let t; prefix: 'series-detail'">
<app-bulk-operations [marginLeft]="12" [marginRight]="0" />
@ -37,7 +35,7 @@
</span>
</h4>
<div class="subtitle mt-2 mb-2">
<div class="subtitle mt-2 mb-2">
@if (seriesValue.localizedName !== seriesValue.name && seriesValue.localizedName) {
<span>{{seriesValue.localizedName | defaultValue}}</span>
}
@ -367,6 +365,7 @@
[tags]="seriesMetadataValue.tags"
[webLinks]="weblinksValue"
[filePaths]="[seriesValue.folderPath]"
[basicMetadata]="seriesBasicMetadata()"
/>
}
</ng-template>

View File

@ -69,7 +69,7 @@ import {NextExpectedCardComponent} from "../../../cards/next-expected-card/next-
import {MetadataService} from "../../../_services/metadata.service";
import {Rating} from "../../../_models/rating";
import {ThemeService} from "../../../_services/theme.service";
import {DetailsTabComponent} from "../../../_single-module/details-tab/details-tab.component";
import {BasicMetadataInfo, DetailsTabComponent} from "../../../_single-module/details-tab/details-tab.component";
import {ChapterRemovedEvent} from "../../../_models/events/chapter-removed-event";
import {SettingsTabId} from "../../../sidenav/preference-nav/preference-nav.component";
import {SeriesFilterField} from "../../../_models/metadata/v2/series-filter-field";
@ -239,9 +239,7 @@ class SeriesDetailComponent implements OnInit, AfterViewInit {
protected readonly isLoadingReadingHistory = signal(false);
protected readonly readingHistoryCurrentPage = signal(1);
isAdmin = computed(() => {
return this.accountService.hasAdminRole();
});
readonly isAdmin = this.accountService.hasAdminRole;
activeTabId = Tabs.Storyline;
mobileSeriesImgBackground = this.themeService.getCssVariable('--mobile-series-img-background');
@ -412,6 +410,21 @@ class SeriesDetailComponent implements OnInit, AfterViewInit {
return webLinks.split(',');
});
seriesBasicMetadata = computed<BasicMetadataInfo>(() => {
const s = this.series();
const meta = this.seriesMetadata();
return {
readingTime: s,
pages: s.pages,
words: s.wordCount,
addedAt: s.created,
updatedAt: s.lastChapterAdded,
kavitaId: s.id,
language: meta?.language || null,
publicationStatus: meta?.publicationStatus ?? null,
};
});
trackStoryLineIdentity = (index: number, item: StoryLineItem) => item.isChapter ? `${item.chapter!.data.id}_ch_storyline` : `${item.volume!.data.id}_vol_storyline`;
/**

View File

@ -1,15 +1,17 @@
<ng-container *transloco="let t; prefix: 'edit-external-metadata-form'">
<div class="row">
@for(key of metadataIds; track key) {
<div class="col-md-6 col-sm-12 mb-3">
<span class="fw-bold">{{t(key + '-label')}}</span>
@let value = entity()[key];
@if (value === 0) {
<div>{{null | defaultValue}}</div>
} @else {
<div>{{entity()[key] | defaultValue}}</div>
}
<div class="row g-2">
@for(item of metadata(); track item.key) {
<div class="col-auto mb-3">
<app-label-card
[label]="t(item.key + '-label')"
[value]="item.value | defaultValue:t('not-set-label')"
[linkUrl]="item.linkUrl ?? undefined" />
</div>
}
<div class="col-auto mb-3">
<app-label-card
[label]="t('isbn-label')"
[value]="isbn() | defaultValue:t('not-set-label')" />
</div>
}
</div>
</div>
</ng-container>

View File

@ -1,14 +1,25 @@
import {ChangeDetectionStrategy, Component, input} from '@angular/core';
import {ChangeDetectionStrategy, Component, computed, input} from '@angular/core';
import {TranslocoDirective} from "@jsverse/transloco";
import {IHasMetadataIds} from "../../../_models/common/i-has-metadata-ids";
import {HAS_METADATA_DEFAULTS} from "../edit-external-metadata-form/edit-external-metadata-form.component";
import {DefaultValuePipe} from "../../../_pipes/default-value.pipe";
import {LabelCardComponent} from "../../../_single-module/label-card/label-card.component";
const URLS = {
aniListId: 'https://anilist.co/manga/{id}/',
malId: 'https://myanimelist.net/manga/{id}/',
mangaBakaId: 'https://mangabaka.org/{id}',
hardcoverId: null,
comicVineId: null,
metronId: null,
}
@Component({
selector: 'app-external-metadata-detail',
imports: [
TranslocoDirective,
DefaultValuePipe
DefaultValuePipe,
LabelCardComponent
],
templateUrl: './external-metadata-detail.component.html',
styleUrl: './external-metadata-detail.component.scss',
@ -17,6 +28,18 @@ import {DefaultValuePipe} from "../../../_pipes/default-value.pipe";
export class ExternalMetadataDetailComponent {
entity = input.required<IHasMetadataIds>();
protected readonly metadataIds = Object.keys(HAS_METADATA_DEFAULTS) as (keyof IHasMetadataIds)[];
/** Extra id to show in this section for details-tab */
isbn = input<string | null>(null);
metadata = computed(() => {
const e = this.entity();
return (Object.keys(HAS_METADATA_DEFAULTS) as (keyof IHasMetadataIds)[]).map(key => {
const rawValue = e[key];
const value = rawValue === 0 || rawValue == null ? null : rawValue;
const urlTemplate = URLS[key];
const linkUrl = urlTemplate && value != null ? urlTemplate.replace('{id}', String(value)) : null;
return { key, value, linkUrl };
});
});
}

View File

@ -2,6 +2,7 @@
#container
class="container"
[style.height]="containerHeight()"
[style.margin-top]="containerMarginTop()"
[class.armed]="isArmed()"
[class.triggered]="isTriggered()"
aria-hidden="true"

Some files were not shown because too many files have changed in this diff Show More