mirror of
https://github.com/Kareadita/Kavita.git
synced 2026-04-24 18:09:50 -04:00
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:
parent
9e44ac285d
commit
485c6f87a8
@ -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>
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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")
|
||||
};
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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()),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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}")
|
||||
};
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices.JavaScript;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"/>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"));
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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!",
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
{
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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]
|
||||
|
||||
BIN
Kavita.Services.Tests/Test Data/BookService/Role Refinement.epub
Normal file
BIN
Kavita.Services.Tests/Test Data/BookService/Role Refinement.epub
Normal file
Binary file not shown.
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -5,6 +5,8 @@ export enum ReadingListFilterField {
|
||||
Tags = 4,
|
||||
Writer = 5,
|
||||
Artist = 6,
|
||||
Provider = 7,
|
||||
MissingItemCount = 8
|
||||
}
|
||||
|
||||
export const allReadingListFilterFields = Object.keys(ReadingListFilterField)
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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');
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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([]);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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>
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -48,6 +48,9 @@ export class VersionUpdateModalComponent {
|
||||
this.bustLocaleCache();
|
||||
// Refresh manually
|
||||
location.reload();
|
||||
|
||||
// Dismiss anyway in case reload doesn't work
|
||||
this.modal.dismiss();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -25,7 +25,6 @@
|
||||
<app-entity-card [entity]="item" [config]="bookmarkConfig()"
|
||||
[index]="position" [maxIndex]="bookmarkEntities().length"
|
||||
(reload)="clearBookmarks(item.data.series)"
|
||||
|
||||
/>
|
||||
</ng-template>
|
||||
|
||||
|
||||
@ -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)"
|
||||
|
||||
@ -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>();
|
||||
|
||||
@ -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 !== '') {
|
||||
|
||||
@ -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>>();
|
||||
|
||||
|
||||
@ -176,6 +176,7 @@
|
||||
[tags]="chapterValue.tags"
|
||||
[webLinks]="weblinks()"
|
||||
[files]="chapterValue.files"
|
||||
[basicMetadata]="chapterBasicMetadata()"
|
||||
/>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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 })!;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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}}"
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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}}"
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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}}">
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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)]);
|
||||
|
||||
@ -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';
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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%;
|
||||
|
||||
@ -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;
|
||||
})]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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`;
|
||||
|
||||
/**
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 };
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user