diff --git a/Kavita.API/Kavita.API.csproj b/Kavita.API/Kavita.API.csproj index 991a6e1b3..f481b6447 100644 --- a/Kavita.API/Kavita.API.csproj +++ b/Kavita.API/Kavita.API.csproj @@ -12,10 +12,10 @@ - - + + - + diff --git a/Kavita.API/Repositories/IReadingListRepository.cs b/Kavita.API/Repositories/IReadingListRepository.cs index cbe3dcb8f..8890b601c 100644 --- a/Kavita.API/Repositories/IReadingListRepository.cs +++ b/Kavita.API/Repositories/IReadingListRepository.cs @@ -61,4 +61,5 @@ public interface IReadingListRepository Task> GetAllReadingListTagDtosAsync(int userId, CancellationToken ct = default); Task> GetBrowseReadingListDtos(int userId, ReadingListFilterDto filter, UserParams userParams, CancellationToken ct = default); + Task GetReadingListBySourcePathStemAsync(string sourcePathStem, int userId, ReadingListIncludes includes = ReadingListIncludes.Items, CancellationToken ct = default); } diff --git a/Kavita.API/Services/ITachiyomiService.cs b/Kavita.API/Services/ITachiyomiService.cs index d9a279801..6fbd4d7c0 100644 --- a/Kavita.API/Services/ITachiyomiService.cs +++ b/Kavita.API/Services/ITachiyomiService.cs @@ -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 /// - /// + /// /// /// Can also be a Tachiyomi encoded volume number /// - Task MarkChaptersUntilAsRead(AppUser userWithProgress, int seriesId, float chapterNumber, CancellationToken ct = default); + Task MarkChaptersUntilAsRead(AppUser user, int seriesId, float chapterNumber, CancellationToken ct = default); } diff --git a/Kavita.API/Services/Reading/IReaderService.cs b/Kavita.API/Services/Reading/IReaderService.cs index 5ffaf54cc..03485ddec 100644 --- a/Kavita.API/Services/Reading/IReaderService.cs +++ b/Kavita.API/Services/Reading/IReaderService.cs @@ -26,8 +26,6 @@ public interface IReaderService Task GetNextChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId); Task GetPrevChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId); Task GetContinuePoint(int seriesId, int userId); - Task MarkChaptersUntilAsRead(AppUser user, int seriesId, float chapterNumber); - Task MarkVolumesUntilAsRead(AppUser user, int seriesId, int volumeNumber); IDictionary GetPairs(IEnumerable dimensions); Task GetThumbnail(Chapter chapter, int pageNum, IEnumerable cachedImages); Task CheckSeriesForReRead(int userId, int seriesId, int libraryId); diff --git a/Kavita.API/Services/ReadingLists/ICblImportService.cs b/Kavita.API/Services/ReadingLists/ICblImportService.cs index 62f5e92a8..59bfc3865 100644 --- a/Kavita.API/Services/ReadingLists/ICblImportService.cs +++ b/Kavita.API/Services/ReadingLists/ICblImportService.cs @@ -12,7 +12,7 @@ public interface ICblImportService /// /// Creates a new RL or updates an existing /// - Task UpsertReadingList(int userId, string filePath, CblImportDecisions decisions); + Task UpsertReadingList(int userId, string filePath, CblImportDecisions decisions, bool promote = false); /// /// Checks for updates against upstream ReadingList files and attempts to Update reading list /// diff --git a/Kavita.Common.Tests/Kavita.Common.Tests.csproj b/Kavita.Common.Tests/Kavita.Common.Tests.csproj index 93a4fb819..5d59de1db 100644 --- a/Kavita.Common.Tests/Kavita.Common.Tests.csproj +++ b/Kavita.Common.Tests/Kavita.Common.Tests.csproj @@ -8,7 +8,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index ba8449f70..bddbb21d8 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -10,18 +10,18 @@ - + - - - + + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Kavita.Database.Tests/Kavita.Database.Tests.csproj b/Kavita.Database.Tests/Kavita.Database.Tests.csproj index 3988f8eae..784a6fd63 100644 --- a/Kavita.Database.Tests/Kavita.Database.Tests.csproj +++ b/Kavita.Database.Tests/Kavita.Database.Tests.csproj @@ -8,14 +8,14 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all diff --git a/Kavita.Database/Converters/ReadingListFilterFieldValueConverter.cs b/Kavita.Database/Converters/ReadingListFilterFieldValueConverter.cs index fd7bf3f99..d9c012669 100644 --- a/Kavita.Database/Converters/ReadingListFilterFieldValueConverter.cs +++ b/Kavita.Database/Converters/ReadingListFilterFieldValueConverter.cs @@ -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(value), _ => throw new ArgumentOutOfRangeException(nameof(field), field, "Field is not supported") }; } diff --git a/Kavita.Database/Extensions/Filters/ComparisonProfile.cs b/Kavita.Database/Extensions/Filters/ComparisonProfile.cs index e7dcbdcbd..418f7d2d8 100644 --- a/Kavita.Database/Extensions/Filters/ComparisonProfile.cs +++ b/Kavita.Database/Extensions/Filters/ComparisonProfile.cs @@ -40,6 +40,15 @@ public static class ComparisonProfile FilterComparison.Contains, FilterComparison.NotContains, FilterComparison.MustContains ]; + /// + /// List/set membership fields: Equal, NotEqual, Contains, NotContains + /// + public static readonly HashSet ListWithoutMustContains = + [ + FilterComparison.Equal, FilterComparison.NotEqual, + FilterComparison.Contains, FilterComparison.NotContains + ]; + /// /// List/set membership fields with IsEmpty: Equal, NotEqual, Contains, NotContains, MustContains, IsEmpty, IsNotEmpty /// diff --git a/Kavita.Database/Extensions/Filters/ReadingListFilter.cs b/Kavita.Database/Extensions/Filters/ReadingListFilter.cs index e4f5b61e0..be4495bce 100644 --- a/Kavita.Database/Extensions/Filters/ReadingListFilter.cs +++ b/Kavita.Database/Extensions/Filters/ReadingListFilter.cs @@ -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 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 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 HasTags(bool condition, FilterComparison comparison, IList tags) { if (!condition || (comparison != FilterComparison.IsEmpty && comparison != FilterComparison.IsNotEmpty && tags.Count == 0)) return queryable; diff --git a/Kavita.Database/Extensions/QueryableExtensions.cs b/Kavita.Database/Extensions/QueryableExtensions.cs index b4cfa6fe7..338eb4fb3 100644 --- a/Kavita.Database/Extensions/QueryableExtensions.cs +++ b/Kavita.Database/Extensions/QueryableExtensions.cs @@ -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); } - /// - /// Returns all libraries for a given user and library type - /// - /// - /// - /// - /// - public static IQueryable GetUserLibrariesByType(this IQueryable 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 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()), }; } diff --git a/Kavita.Database/Kavita.Database.csproj b/Kavita.Database/Kavita.Database.csproj index 7ff02be3e..03bc32d63 100644 --- a/Kavita.Database/Kavita.Database.csproj +++ b/Kavita.Database/Kavita.Database.csproj @@ -14,9 +14,9 @@ - - - + + + diff --git a/Kavita.Database/Repositories/LibraryRepository.cs b/Kavita.Database/Repositories/LibraryRepository.cs index a4362b389..3b8cbd9b5 100644 --- a/Kavita.Database/Repositories/LibraryRepository.cs +++ b/Kavita.Database/Repositories/LibraryRepository.cs @@ -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(); } diff --git a/Kavita.Database/Repositories/ReadingListRepository.cs b/Kavita.Database/Repositories/ReadingListRepository.cs index 46182e3bd..df52b4c69 100644 --- a/Kavita.Database/Repositories/ReadingListRepository.cs +++ b/Kavita.Database/Repositories/ReadingListRepository.cs @@ -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(mapper.ConfigurationProvider) .AsNoTracking(); @@ -547,6 +546,24 @@ public class ReadingListRepository(DataContext context, IMapper mapper) : IReadi return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize, ct); } + /// + /// Attempts to match the SourcePath.EndsWith(sourcePathStem) to do the matching + /// + /// + /// + /// + public async Task 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 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) value), ReadingListFilterField.Writer => query.HasPeople(true, statement.Comparison, (IList) value, PersonRole.Writer), ReadingListFilterField.Artist => query.HasPeople(true, statement.Comparison, (IList) 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}") }; } diff --git a/Kavita.Database/Repositories/SeriesRepository.cs b/Kavita.Database/Repositories/SeriesRepository.cs index c10c889a3..c57d1c2d6 100644 --- a/Kavita.Database/Repositories/SeriesRepository.cs +++ b/Kavita.Database/Repositories/SeriesRepository.cs @@ -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) diff --git a/Kavita.Models.Tests/Kavita.Models.Tests.csproj b/Kavita.Models.Tests/Kavita.Models.Tests.csproj index e966153e8..d4f52b757 100644 --- a/Kavita.Models.Tests/Kavita.Models.Tests.csproj +++ b/Kavita.Models.Tests/Kavita.Models.Tests.csproj @@ -8,7 +8,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Kavita.Models/DTOs/ChapterDto.cs b/Kavita.Models/DTOs/ChapterDto.cs index 3fac07994..2bb519097 100644 --- a/Kavita.Models/DTOs/ChapterDto.cs +++ b/Kavita.Models/DTOs/ChapterDto.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Runtime.InteropServices.JavaScript; diff --git a/Kavita.Models/DTOs/Filtering/v2/FilterComparision.cs b/Kavita.Models/DTOs/Filtering/v2/FilterComparision.cs index c04bc2b85..3c67bbddf 100644 --- a/Kavita.Models/DTOs/Filtering/v2/FilterComparision.cs +++ b/Kavita.Models/DTOs/Filtering/v2/FilterComparision.cs @@ -11,12 +11,12 @@ public enum FilterComparison LessThan = 3, LessThanEqual = 4, /// - /// 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 /// /// Only works with IList Contains = 5, /// - /// 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 /// /// Only works with IList MustContains = 6, diff --git a/Kavita.Models/DTOs/Filtering/v2/FilterFields/ReadingListFilterField.cs b/Kavita.Models/DTOs/Filtering/v2/FilterFields/ReadingListFilterField.cs index ef5faedb0..6d9e3dd46 100644 --- a/Kavita.Models/DTOs/Filtering/v2/FilterFields/ReadingListFilterField.cs +++ b/Kavita.Models/DTOs/Filtering/v2/FilterFields/ReadingListFilterField.cs @@ -8,4 +8,9 @@ public enum ReadingListFilterField Tags = 4, Writer = 5, Artist = 6, + /// + /// Source is either Kavita/Url/File + /// + Provider = 7, + MissingItemCount = 8 } diff --git a/Kavita.Models/DTOs/ReadingLists/CBL/CblFinalizeRequestDto.cs b/Kavita.Models/DTOs/ReadingLists/CBL/CblFinalizeRequestDto.cs index d9874ae46..49df04b7f 100644 --- a/Kavita.Models/DTOs/ReadingLists/CBL/CblFinalizeRequestDto.cs +++ b/Kavita.Models/DTOs/ReadingLists/CBL/CblFinalizeRequestDto.cs @@ -26,4 +26,9 @@ public sealed record CblFinalizeRequestDto /// Optional Git SHA for sync tracking /// public string? Sha { get; set; } + + /// + /// Optional flag to promote the RL on creation + /// + public bool Promote { get; set; } = false; } diff --git a/Kavita.Models/DTOs/ReadingLists/CBL/Import/CblImportSummaryDto.cs b/Kavita.Models/DTOs/ReadingLists/CBL/Import/CblImportSummaryDto.cs index dbcfb13f7..5eb7cd286 100644 --- a/Kavita.Models/DTOs/ReadingLists/CBL/Import/CblImportSummaryDto.cs +++ b/Kavita.Models/DTOs/ReadingLists/CBL/Import/CblImportSummaryDto.cs @@ -19,5 +19,9 @@ public sealed record CblImportSummaryDto /// Are we updating a pre-existing list or not /// public bool IsUpdate { get; set; } + /// + /// Id of the reading list + /// + public int ReadingListId { get; set; } } diff --git a/Kavita.Models/DTOs/UpdateChapterDto.cs b/Kavita.Models/DTOs/UpdateChapterDto.cs index 80068adfe..03ece59e6 100644 --- a/Kavita.Models/DTOs/UpdateChapterDto.cs +++ b/Kavita.Models/DTOs/UpdateChapterDto.cs @@ -44,7 +44,7 @@ public sealed record UpdateChapterDto : IUpdateExternalMetadataIds /// /// Language of the content (BCP-47 code) /// - public string Language { get; set; } = string.Empty; + public string? Language { get; set; } = string.Empty; /// diff --git a/Kavita.Models/Kavita.Models.csproj b/Kavita.Models/Kavita.Models.csproj index 605810784..b269e1327 100644 --- a/Kavita.Models/Kavita.Models.csproj +++ b/Kavita.Models/Kavita.Models.csproj @@ -13,9 +13,9 @@ - - - + + + diff --git a/Kavita.Server.Tests/Kavita.Server.Tests.csproj b/Kavita.Server.Tests/Kavita.Server.Tests.csproj index 866c131aa..a7471e969 100644 --- a/Kavita.Server.Tests/Kavita.Server.Tests.csproj +++ b/Kavita.Server.Tests/Kavita.Server.Tests.csproj @@ -8,11 +8,11 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/Kavita.Server/Controllers/BaseApiController.cs b/Kavita.Server/Controllers/BaseApiController.cs index 20a2ae535..11a1a9818 100644 --- a/Kavita.Server/Controllers/BaseApiController.cs +++ b/Kavita.Server/Controllers/BaseApiController.cs @@ -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; } diff --git a/Kavita.Server/Controllers/CBLController.cs b/Kavita.Server/Controllers/CBLController.cs index 90907c87d..f16f9db97 100644 --- a/Kavita.Server/Controllers/CBLController.cs +++ b/Kavita.Server/Controllers/CBLController.cs @@ -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? 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); + } + /// /// 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> 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> 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 diff --git a/Kavita.Server/Controllers/ReaderController.cs b/Kavita.Server/Controllers/ReaderController.cs index aa8c484a7..51aaee276 100644 --- a/Kavita.Server/Controllers/ReaderController.cs +++ b/Kavita.Server/Controllers/ReaderController.cs @@ -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 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.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")); diff --git a/Kavita.Server/Controllers/SeriesController.cs b/Kavita.Server/Controllers/SeriesController.cs index 729863dad..51aabfb63 100644 --- a/Kavita.Server/Controllers/SeriesController.cs +++ b/Kavita.Server/Controllers/SeriesController.cs @@ -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); } diff --git a/Kavita.Server/I18N/en.json b/Kavita.Server/I18N/en.json index 6117674c6..8c90d0375 100644 --- a/Kavita.Server/I18N/en.json +++ b/Kavita.Server/I18N/en.json @@ -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!", diff --git a/Kavita.Server/Kavita.Server.csproj b/Kavita.Server/Kavita.Server.csproj index fe1a0aff7..58c02b20e 100644 --- a/Kavita.Server/Kavita.Server.csproj +++ b/Kavita.Server/Kavita.Server.csproj @@ -160,7 +160,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Kavita.Services.Tests/BookServiceTests.cs b/Kavita.Services.Tests/BookServiceTests.cs index edf94f639..525b49212 100644 --- a/Kavita.Services.Tests/BookServiceTests.cs +++ b/Kavita.Services.Tests/BookServiceTests.cs @@ -46,6 +46,9 @@ public class BookServiceTests Assert.Equal("genre1, genre2", comicInfo.Genre); } + /// + /// This tests an edge case where there is bad metadata + /// [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() { diff --git a/Kavita.Services.Tests/CleanupServiceTests.cs b/Kavita.Services.Tests/CleanupServiceTests.cs index 9dd82744b..0539d489e 100644 --- a/Kavita.Services.Tests/CleanupServiceTests.cs +++ b/Kavita.Services.Tests/CleanupServiceTests.cs @@ -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 diff --git a/Kavita.Services.Tests/Kavita.Services.Tests.csproj b/Kavita.Services.Tests/Kavita.Services.Tests.csproj index 44056eb1c..ca81125f5 100644 --- a/Kavita.Services.Tests/Kavita.Services.Tests.csproj +++ b/Kavita.Services.Tests/Kavita.Services.Tests.csproj @@ -8,7 +8,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Kavita.Services.Tests/Parsing/MangaParsingTests.cs b/Kavita.Services.Tests/Parsing/MangaParsingTests.cs index c67f34de1..19ae5c18f 100644 --- a/Kavita.Services.Tests/Parsing/MangaParsingTests.cs +++ b/Kavita.Services.Tests/Parsing/MangaParsingTests.cs @@ -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)); diff --git a/Kavita.Services.Tests/ReaderServiceTests.cs b/Kavita.Services.Tests/ReaderServiceTests.cs index f2af5f157..27731c2d3 100644 --- a/Kavita.Services.Tests/ReaderServiceTests.cs +++ b/Kavita.Services.Tests/ReaderServiceTests.cs @@ -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] diff --git a/Kavita.Services.Tests/Test Data/BookService/Role Refinement.epub b/Kavita.Services.Tests/Test Data/BookService/Role Refinement.epub new file mode 100644 index 000000000..eaa83f8b8 Binary files /dev/null and b/Kavita.Services.Tests/Test Data/BookService/Role Refinement.epub differ diff --git a/Kavita.Services/BookService.cs b/Kavita.Services/BookService.cs index 9e19d55ae..e4c62710a 100644 --- a/Kavita.Services/BookService.cs +++ b/Kavita.Services/BookService.cs @@ -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(); - 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(); + 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 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 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 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 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 diff --git a/Kavita.Services/Kavita.Services.csproj b/Kavita.Services/Kavita.Services.csproj index 425e9c4a1..e1fb79853 100644 --- a/Kavita.Services/Kavita.Services.csproj +++ b/Kavita.Services/Kavita.Services.csproj @@ -14,8 +14,8 @@ - - + + @@ -27,11 +27,11 @@ - - - - - + + + + + @@ -49,18 +49,18 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - + - + diff --git a/Kavita.Services/Reading/ReaderService.cs b/Kavita.Services/Reading/ReaderService.cs index 8c620d534..fec074d1d 100644 --- a/Kavita.Services/Reading/ReaderService.cs +++ b/Kavita.Services/Reading/ReaderService.cs @@ -540,6 +540,11 @@ public class ReaderService(IUnitOfWork unitOfWork, ILogger logger private static ChapterDto FindNextReadingChapter(IList 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 logger return -1; } - /// - /// 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. - /// - /// - /// - /// - public async Task MarkChaptersUntilAsRead(AppUser user, int seriesId, float chapterNumber) - { - var volumes = await unitOfWork.VolumeRepository.GetVolumesForSeriesAsync(new List { 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 { 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 GetEstimateToCompletionForChapter(int userId, int seriesId, int chapterId) { var series = await unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); diff --git a/Kavita.Services/ReadingLists/CblImportService.cs b/Kavita.Services/ReadingLists/CblImportService.cs index e59d2ef0e..c9e3e86a8 100644 --- a/Kavita.Services/ReadingLists/CblImportService.cs +++ b/Kavita.Services/ReadingLists/CblImportService.cs @@ -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 UpsertReadingList(int userId, string filePath, CblImportDecisions decisions) + public async Task 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 CheckAndMarkIfNoChanges(ReadingList readingList, string hash, bool force) { if (force || readingList.HasRemoteChange(hash)) return false; diff --git a/Kavita.Services/Scanner/Parser.cs b/Kavita.Services/Scanner/Parser.cs index 9eb4562dd..42ce832c0 100644 --- a/Kavita.Services/Scanner/Parser.cs +++ b/Kavita.Services/Scanner/Parser.cs @@ -640,10 +640,29 @@ public static partial class Parser new Regex( @"(Глава|глава|Главы|Глава)(\.?)(\s|_)?(?\d+(?:.\d+|-\d+)?)", MatchOptions, RegexTimeout), - + // Chinese Chapter: 第n话 -> Chapter n, 【TFO汉化&Petit汉化】迷你偶像漫画第25话 + new Regex( + @"第(?\d+)话", + MatchOptions, RegexTimeout), + // Korean Chapter: 제n화 -> Chapter n, 가디언즈 오브 갤럭시 죽음의 보석.E0008.7화#44 + new Regex( + @"제?(?\d+\.?\d+)(회|화|장)", + MatchOptions, RegexTimeout), + // Korean Chapter: 第10話 -> Chapter n, [ハレム]ナナとカオル ~高校生のSMごっこ~ 第1話 + new Regex( + @"第?(?\d+(?:\.\d+|-\d+)?)話", + MatchOptions, RegexTimeout), + // Russian Chapter: n Главa -> Chapter n + new Regex( + @"(?!Том)(?\d+(?:\.\d+|-\d+)?)(\s|_)(Глава|глава|Главы|Глава)", + MatchOptions, RegexTimeout), + // Fullmetal Alchemist chapters 101-108 + new Regex( + @"^(?.+?)\schapter(?:s)?\s(?\d+-\d+)", + MatchOptions, RegexTimeout), // Hinowa ga CRUSH! 018 (2019) (Digital) (LuCaZ).cbz, Hinowa ga CRUSH! 018.5 (2019) (Digital) (LuCaZ).cbz new Regex( - @"^(?.+?)(?\d+(?:\.\d+|-\d+)?)(?:\s\(\d{4}\))?(\b|_|-)", + @"^(?.+?)(?\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( @"(?((vol|volume|v))?(\s|_)?\.?\d+)(\s|_)(Chp|Chapter)\.?(\s|_)?(?\d+)", MatchOptions, RegexTimeout), - // Chinese Chapter: 第n话 -> Chapter n, 【TFO汉化&Petit汉化】迷你偶像漫画第25话 - new Regex( - @"第(?\d+)话", - MatchOptions, RegexTimeout), - // Korean Chapter: 제n화 -> Chapter n, 가디언즈 오브 갤럭시 죽음의 보석.E0008.7화#44 - new Regex( - @"제?(?\d+\.?\d+)(회|화|장)", - MatchOptions, RegexTimeout), - // Korean Chapter: 第10話 -> Chapter n, [ハレム]ナナとカオル ~高校生のSMごっこ~ 第1話 - new Regex( - @"第?(?\d+(?:\.\d+|-\d+)?)話", - MatchOptions, RegexTimeout), - // Russian Chapter: n Главa -> Chapter n - new Regex( - @"(?!Том)(?\d+(?:\.\d+|-\d+)?)(\s|_)(Глава|глава|Главы|Глава)", - MatchOptions, RegexTimeout) ]; private static readonly Regex MangaEditionRegex = new Regex( diff --git a/Kavita.Services/TachiyomiService.cs b/Kavita.Services/TachiyomiService.cs index f6d810946..d711ce3eb 100644 --- a/Kavita.Services/TachiyomiService.cs +++ b/Kavita.Services/TachiyomiService.cs @@ -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() }; } - public async Task MarkChaptersUntilAsRead(AppUser userWithProgress, int seriesId, float chapterNumber, + public async Task 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(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> 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> 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(); + } + + } diff --git a/Kavita.Services/TaskScheduler.cs b/Kavita.Services/TaskScheduler.cs index 137f97a5e..d4a7c9dc0 100644 --- a/Kavita.Services/TaskScheduler.cs +++ b/Kavita.Services/TaskScheduler.cs @@ -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(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(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(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(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(TaskCblSyncId, service => service.SyncAllReadingLists(CancellationToken.None), + RecurringJob.AddOrUpdate(TaskCblSyncId, service => service.SyncAllReadingLists(CancellationToken.None), "0 4 * * *", RecurringJobOptions); } else { _logger.LogDebug("Scheduling CBL Sync Task for {Setting}", setting); - RecurringJob.AddOrUpdate(TaskCblSyncId, service => service.SyncAllReadingLists(CancellationToken.None), + RecurringJob.AddOrUpdate(TaskCblSyncId, service => service.SyncAllReadingLists(CancellationToken.None), CronConverter.ConvertToCronNotation(setting), RecurringJobOptions); } - RecurringJob.AddOrUpdate(RemoveFromWantToReadTaskId, - () => _cleanupService.CleanupWantToRead(CancellationToken.None), + RecurringJob.AddOrUpdate(RemoveFromWantToReadTaskId, + (service) => service.CleanupWantToRead(CancellationToken.None), Cron.Daily, RecurringJobOptions); - RecurringJob.AddOrUpdate(UpdateYearlyStatsTaskId, - () => _statisticService.UpdateServerStatistics(CancellationToken.None), + RecurringJob.AddOrUpdate(UpdateYearlyStatsTaskId, + (service) => service.UpdateServerStatistics(CancellationToken.None), Cron.Monthly, RecurringJobOptions); RecurringJob.AddOrUpdate(SyncThemesTaskId, diff --git a/UI/Web/src/app/_models/actionables/action.ts b/UI/Web/src/app/_models/actionables/action.ts index ae1c345b6..abc86d4a6 100644 --- a/UI/Web/src/app/_models/actionables/action.ts +++ b/UI/Web/src/app/_models/actionables/action.ts @@ -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, } diff --git a/UI/Web/src/app/_models/metadata/v2/reading-list-filter-field.ts b/UI/Web/src/app/_models/metadata/v2/reading-list-filter-field.ts index 55c188f08..976a6b016 100644 --- a/UI/Web/src/app/_models/metadata/v2/reading-list-filter-field.ts +++ b/UI/Web/src/app/_models/metadata/v2/reading-list-filter-field.ts @@ -5,6 +5,8 @@ export enum ReadingListFilterField { Tags = 4, Writer = 5, Artist = 6, + Provider = 7, + MissingItemCount = 8 } export const allReadingListFilterFields = Object.keys(ReadingListFilterField) diff --git a/UI/Web/src/app/_models/reading-list/reading-list.ts b/UI/Web/src/app/_models/reading-list/reading-list.ts index d2aecd9a0..06835665c 100644 --- a/UI/Web/src/app/_models/reading-list/reading-list.ts +++ b/UI/Web/src/app/_models/reading-list/reading-list.ts @@ -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; diff --git a/UI/Web/src/app/_pipes/generic-filter-field.pipe.ts b/UI/Web/src/app/_pipes/generic-filter-field.pipe.ts index 961aad331..c683efe48 100644 --- a/UI/Web/src/app/_pipes/generic-filter-field.pipe.ts +++ b/UI/Web/src/app/_pipes/generic-filter-field.pipe.ts @@ -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'); } } diff --git a/UI/Web/src/app/_pipes/reading-list-provider.pipe.ts b/UI/Web/src/app/_pipes/reading-list-provider.pipe.ts index be01c9c07..269e8d8df 100644 --- a/UI/Web/src/app/_pipes/reading-list-provider.pipe.ts +++ b/UI/Web/src/app/_pipes/reading-list-provider.pipe.ts @@ -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'); - } } diff --git a/UI/Web/src/app/_pipes/safe-html.pipe.ts b/UI/Web/src/app/_pipes/safe-html.pipe.ts index 0126ea1d1..2c3a502a7 100644 --- a/UI/Web/src/app/_pipes/safe-html.pipe.ts +++ b/UI/Web/src/app/_pipes/safe-html.pipe.ts @@ -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); diff --git a/UI/Web/src/app/_pipes/utc-to-locale-date.pipe.ts b/UI/Web/src/app/_pipes/utc-to-locale-date.pipe.ts index 67d87661b..1f7c936b1 100644 --- a/UI/Web/src/app/_pipes/utc-to-locale-date.pipe.ts +++ b/UI/Web/src/app/_pipes/utc-to-locale-date.pipe.ts @@ -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() } diff --git a/UI/Web/src/app/_services/action-factory.service.ts b/UI/Web/src/app/_services/action-factory.service.ts index 60331538c..f43bc1c49 100644 --- a/UI/Web/src/app/_services/action-factory.service.ts +++ b/UI/Web/src/app/_services/action-factory.service.ts @@ -49,6 +49,7 @@ export class ActionFactoryService { private sideNavStreamActions: Array> = []; private smartFilterActions: Array> = []; private sideNavHomeActions: Array> = []; + private sideNavReadingListActions: Array> = []; private annotationActions: Array> = []; private clientDeviceActions: Array> = []; @@ -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, entity: any, user: User) => !this.accountService.hasReadOnlyRole() + : shouldRenderFunc; + + return this.applyCallbackToList( + this.sideNavReadingListActions, + (action, entity) => this.actionService.handleSideNavReadingListStream(action, entity), + renderFunc + ); + } + getBulkLibraryActions(shouldRenderFunc: ActionShouldRenderFunc = this.basicReadRender) { const filteredActions = this.flattenActions(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, diff --git a/UI/Web/src/app/_services/action.service.ts b/UI/Web/src/app/_services/action.service.ts index 33fcff218..e7101310c 100644 --- a/UI/Web/src/app/_services/action.service.ts +++ b/UI/Web/src/app/_services/action.service.ts @@ -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> so the caller can react to effects. diff --git a/UI/Web/src/app/_services/cbl.service.ts b/UI/Web/src/app/_services/cbl.service.ts index 6fd5b02af..8f5076235 100644 --- a/UI/Web/src/app/_services/cbl.service.ts +++ b/UI/Web/src/app/_services/cbl.service.ts @@ -45,13 +45,14 @@ export class CblService { return this.httpClient.post(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(this.baseUrl + 'cbl/finalize-import', { fileName, decisions, provider, - ...repoMeta + ...repoMeta, + promote }); } diff --git a/UI/Web/src/app/_services/kavita-title.strategy.ts b/UI/Web/src/app/_services/kavita-title.strategy.ts index 84e374131..8be1c5d0d 100644 --- a/UI/Web/src/app/_services/kavita-title.strategy.ts +++ b/UI/Web/src/app/_services/kavita-title.strategy.ts @@ -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; } } diff --git a/UI/Web/src/app/_services/metadata.service.ts b/UI/Web/src/app/_services/metadata.service.ts index 40cdcc136..96936f224 100644 --- a/UI/Web/src/app/_services/metadata.service.ts +++ b/UI/Web/src/app/_services/metadata.service.ts @@ -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(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([]); diff --git a/UI/Web/src/app/_single-module/details-tab/details-tab.component.html b/UI/Web/src/app/_single-module/details-tab/details-tab.component.html index 7e336c2be..02c3a0d73 100644 --- a/UI/Web/src/app/_single-module/details-tab/details-tab.component.html +++ b/UI/Web/src/app/_single-module/details-tab/details-tab.component.html @@ -1,176 +1,228 @@ -
+
- @let filePathsValue = filePaths(); - @let filesValue = files(); + @let filesValue = files(); + @let filePathsValue = filePaths(); + @let bm = basicMetadata(); - @if (accountService.hasAdminRole() && (filePathsValue.length > 0 || filesValue.length > 0)) { -
-

{{t(filesValue.length > 0 ? 'file-path-title' : 'folder-path-title')}}

-
- @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)) { +
+

{{t(filesValue.length > 0 ? 'file-path-title' : 'folder-path-title')}}

+
+ + @for (fp of filePathsValue; track $index) { +
+ {{fp}} +
+ } @empty { + @for (fp of filesValue; track fp.id) { +
+
+ + {{fp.filePath}} +
+
+ {{t('pages-count', {num: fp.pages | compactNumber})}} + {{t('bytes-count', {num: fp.bytes | bytes})}} + @if (fp.koreaderHash) { + KOReader ✓ + } +
+
} } - } @else { - @for (fp of filePathsValue; track $index) { - {{fp}} +
+
+ } + + @if (showBasicMetadata() && bm) { +
+

{{t('basic-metadata-title')}}

+
+ @if (bm.readingTime) { + } - } + + + + + + @if (bm.sortOrder != null) { + + } + @if (bm.isSpecial != null) { + + } + + @if (bm.publicationStatus != null) { + + } +
+
+ + } + + @let metadataEntity = entity(); + @if (metadataEntity) { +
+

{{t('external-metadata-title')}}

+ +
+ } + + @if (webLinks().length > 0) { +
+

{{t('weblinks-title')}}

+
+ @for (link of webLinks(); track $index) { + + + + + } +
-
- } + } - @let metadataEntity = entity(); - @if(metadataEntity) { -
-

{{t('external-metadata-title')}}

-
- + @if (metadataEntity || webLinks().length > 0) { + + } + + @if (showGenres()) { +
+

{{t('genres-title')}}

+
+ @if (genres().length > 0) { + @for (item of genres(); track item.id) { + + {{item.title}} + + } + } @else { + {{null | defaultValue}} + } +
-
- } + } - @if (showGenres()) { -
-

{{t('genres-title')}}

-
- - - {{item.title}} - - + @if (showTags()) { +
+

{{t('tags-title')}}

+
+ @if (tags().length > 0) { + @for (item of tags(); track item.id) { + + {{item.title}} + + } + } @else { + {{null | defaultValue}} + } +
+ } + + @if (hasUpperMetadata()) { + + } + +
+ + + + +
- } - @if (showTags()) { -
-

{{t('tags-title')}}

-
- - - {{item.title}} - - -
+
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + +
- } -
- - - - - -
- - @if (hasUpperMetadata()) { - - } - - -
- - - - - -
- -
- - - - - -
- -
- - - - - -
- - -
- - - - - -
- -
- - - - - -
- -
- - - - - -
- -
- - - - - -
- -
- - - - - -
- -
- - - - - -
- -
- - - - - -
- -
- - - - - -
- -
- - - - - -
-
diff --git a/UI/Web/src/app/_single-module/details-tab/details-tab.component.scss b/UI/Web/src/app/_single-module/details-tab/details-tab.component.scss index 8b1378917..c253dee9f 100644 --- a/UI/Web/src/app/_single-module/details-tab/details-tab.component.scss +++ b/UI/Web/src/app/_single-module/details-tab/details-tab.component.scss @@ -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; +} diff --git a/UI/Web/src/app/_single-module/details-tab/details-tab.component.ts b/UI/Web/src/app/_single-module/details-tab/details-tab.component.ts index 2f9be8155..ec0c2613a 100644 --- a/UI/Web/src/app/_single-module/details-tab/details-tab.component.ts +++ b/UI/Web/src/app/_single-module/details-tab/details-tab.component.ts @@ -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(); entity = input(); @@ -58,14 +95,36 @@ export class DetailsTabComponent { suppressEmptyTags = input(false); filePaths = input([]); files = input([]); + basicMetadata = input(); - 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(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; diff --git a/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.ts b/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.ts index 0b69006e7..a29b9382c 100644 --- a/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.ts +++ b/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.ts @@ -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 = [ diff --git a/UI/Web/src/app/_single-module/label-card/label-card.component.html b/UI/Web/src/app/_single-module/label-card/label-card.component.html new file mode 100644 index 000000000..4487a6fa4 --- /dev/null +++ b/UI/Web/src/app/_single-module/label-card/label-card.component.html @@ -0,0 +1,15 @@ +
+ {{label()}} + @if (value() != null) { + @if (linkUrl()) { + + {{value()}} + + + } @else { + {{value()}} + } + } @else { + + } +
diff --git a/UI/Web/src/app/_single-module/label-card/label-card.component.scss b/UI/Web/src/app/_single-module/label-card/label-card.component.scss new file mode 100644 index 000000000..a27e6e734 --- /dev/null +++ b/UI/Web/src/app/_single-module/label-card/label-card.component.scss @@ -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); + } +} diff --git a/UI/Web/src/app/_single-module/label-card/label-card.component.ts b/UI/Web/src/app/_single-module/label-card/label-card.component.ts new file mode 100644 index 000000000..f3119dac0 --- /dev/null +++ b/UI/Web/src/app/_single-module/label-card/label-card.component.ts @@ -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(); + value = input(); + /** When link provided, the value will render as a link **/ + linkUrl = input(undefined); +} diff --git a/UI/Web/src/app/all-annotations/all-annotations.component.ts b/UI/Web/src/app/all-annotations/all-annotations.component.ts index a22806e2a..25e9ab6f8 100644 --- a/UI/Web/src/app/all-annotations/all-annotations.component.ts +++ b/UI/Web/src/app/all-annotations/all-annotations.component.ts @@ -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(undefined); - filterSettings: AnnotationsFilterSettings = new AnnotationsFilterSettings(); + filterSettings: AnnotationFilterSettings = new AnnotationFilterSettings(); trackByIdentity = (idx: number, item: Annotation) => `${item.id}`; refresh: EventEmitter = new EventEmitter(); filterOpen: EventEmitter = new EventEmitter(); diff --git a/UI/Web/src/app/announcements/_components/version-update-modal/version-update-modal.component.ts b/UI/Web/src/app/announcements/_components/version-update-modal/version-update-modal.component.ts index 7bc5f0a6f..7ee424853 100644 --- a/UI/Web/src/app/announcements/_components/version-update-modal/version-update-modal.component.ts +++ b/UI/Web/src/app/announcements/_components/version-update-modal/version-update-modal.component.ts @@ -48,6 +48,9 @@ export class VersionUpdateModalComponent { this.bustLocaleCache(); // Refresh manually location.reload(); + + // Dismiss anyway in case reload doesn't work + this.modal.dismiss(); } diff --git a/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.ts b/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.ts index 706d7503b..b770e49dc 100644 --- a/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.ts +++ b/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.ts @@ -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, diff --git a/UI/Web/src/app/bookmark/_components/bookmarks/bookmarks.component.html b/UI/Web/src/app/bookmark/_components/bookmarks/bookmarks.component.html index 49bf6b9f0..3ce61f0f0 100644 --- a/UI/Web/src/app/bookmark/_components/bookmarks/bookmarks.component.html +++ b/UI/Web/src/app/bookmark/_components/bookmarks/bookmarks.component.html @@ -25,7 +25,6 @@ diff --git a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html index 823328b25..d9f93428a 100644 --- a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html +++ b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html @@ -40,7 +40,7 @@ } -
+
@for (item of scroll.viewPortItems; track trackItem(i, item); let i = $index) {
>([]); // This is approx 784 pixels tall, original keys + gridColumnsTemplate = input('repeat(auto-fill, 10rem)'); itemClicked = output(); applyFilter = output(); diff --git a/UI/Web/src/app/carousel/_components/carousel-reel/carousel-reel.component.html b/UI/Web/src/app/carousel/_components/carousel-reel/carousel-reel.component.html index 47fb17c47..64e73ca98 100644 --- a/UI/Web/src/app/carousel/_components/carousel-reel/carousel-reel.component.html +++ b/UI/Web/src/app/carousel/_components/carousel-reel/carousel-reel.component.html @@ -8,9 +8,9 @@ }

@if (titleLink !== '') { - {{title}} + {{title}} } @else { - {{title}} + {{title}} } @if (iconClasses !== '') { diff --git a/UI/Web/src/app/carousel/_components/carousel-reel/carousel-reel.component.ts b/UI/Web/src/app/carousel/_components/carousel-reel/carousel-reel.component.ts index b1b25669e..0e6a0333a 100644 --- a/UI/Web/src/app/carousel/_components/carousel-reel/carousel-reel.component.ts +++ b/UI/Web/src/app/carousel/_components/carousel-reel/carousel-reel.component.ts @@ -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('section-title'); readonly sectionClick = output(); readonly handleAction = output>(); diff --git a/UI/Web/src/app/chapter-detail/chapter-detail.component.html b/UI/Web/src/app/chapter-detail/chapter-detail.component.html index 66314efdb..862c9a77a 100644 --- a/UI/Web/src/app/chapter-detail/chapter-detail.component.html +++ b/UI/Web/src/app/chapter-detail/chapter-detail.component.html @@ -176,6 +176,7 @@ [tags]="chapterValue.tags" [webLinks]="weblinks()" [files]="chapterValue.files" + [basicMetadata]="chapterBasicMetadata()" /> } diff --git a/UI/Web/src/app/chapter-detail/chapter-detail.component.ts b/UI/Web/src/app/chapter-detail/chapter-detail.component.ts index 700c4933d..8238d4120 100644 --- a/UI/Web/src/app/chapter-detail/chapter-detail.component.ts +++ b/UI/Web/src/app/chapter-detail/chapter-detail.component.ts @@ -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(() => { + 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; diff --git a/UI/Web/src/app/manga-reader/_components/canvas-renderer/canvas-renderer.component.html b/UI/Web/src/app/manga-reader/_components/canvas-renderer/canvas-renderer.component.html index 96797d7ba..615f8e2af 100644 --- a/UI/Web/src/app/manga-reader/_components/canvas-renderer/canvas-renderer.component.html +++ b/UI/Web/src/app/manga-reader/_components/canvas-renderer/canvas-renderer.component.html @@ -1,6 +1,6 @@
- +
diff --git a/UI/Web/src/app/manga-reader/_components/canvas-renderer/canvas-renderer.component.ts b/UI/Web/src/app/manga-reader/_components/canvas-renderer/canvas-renderer.component.ts index 7dc90896f..ff3e16620 100644 --- a/UI/Web/src/app/manga-reader/_components/canvas-renderer/canvas-renderer.component.ts +++ b/UI/Web/src/app/manga-reader/_components/canvas-renderer/canvas-renderer.component.ts @@ -50,7 +50,7 @@ export class CanvasRendererComponent implements OnInit, AfterViewInit, ImageRend readonly imageHeight = output(); - readonly canvas = viewChild('content'); + readonly canvas = viewChild>('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 })!; } } diff --git a/UI/Web/src/app/manga-reader/_components/double-renderer-no-cover/double-no-cover-renderer.component.html b/UI/Web/src/app/manga-reader/_components/double-renderer-no-cover/double-no-cover-renderer.component.html index 25dc7a93c..79898dde3 100644 --- a/UI/Web/src/app/manga-reader/_components/double-renderer-no-cover/double-no-cover-renderer.component.html +++ b/UI/Web/src/app/manga-reader/_components/double-renderer-no-cover/double-no-cover-renderer.component.html @@ -4,6 +4,7 @@ [ngClass]="{'center-double': (shouldRenderDouble$ | async)}"> @if (currentImage) {  (DOCUMENT); readerService = inject(ReaderService); + readonly imageElement = viewChild>('image'); @Input({required: true}) readerSettings$!: Observable; @Input({required: true}) image$!: Observable; diff --git a/UI/Web/src/app/manga-reader/_components/double-renderer/double-renderer.component.html b/UI/Web/src/app/manga-reader/_components/double-renderer/double-renderer.component.html index 25dc7a93c..79898dde3 100644 --- a/UI/Web/src/app/manga-reader/_components/double-renderer/double-renderer.component.html +++ b/UI/Web/src/app/manga-reader/_components/double-renderer/double-renderer.component.html @@ -4,6 +4,7 @@ [ngClass]="{'center-double': (shouldRenderDouble$ | async)}"> @if (currentImage) {  (DOCUMENT); readerService = inject(ReaderService); + readonly imageElement = viewChild>('image'); + @Input({required: true}) readerSettings$!: Observable; @Input({required: true}) image$!: Observable; diff --git a/UI/Web/src/app/manga-reader/_components/double-reverse-renderer/double-reverse-renderer.component.html b/UI/Web/src/app/manga-reader/_components/double-reverse-renderer/double-reverse-renderer.component.html index 9350ad94e..3add66ad9 100644 --- a/UI/Web/src/app/manga-reader/_components/double-reverse-renderer/double-reverse-renderer.component.html +++ b/UI/Web/src/app/manga-reader/_components/double-reverse-renderer/double-reverse-renderer.component.html @@ -5,6 +5,7 @@ @if(leftImage) {  diff --git a/UI/Web/src/app/manga-reader/_components/double-reverse-renderer/double-reverse-renderer.component.ts b/UI/Web/src/app/manga-reader/_components/double-reverse-renderer/double-reverse-renderer.component.ts index aeaeafccf..840a99e3b 100644 --- a/UI/Web/src/app/manga-reader/_components/double-reverse-renderer/double-reverse-renderer.component.ts +++ b/UI/Web/src/app/manga-reader/_components/double-reverse-renderer/double-reverse-renderer.component.ts @@ -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); readerService = inject(ReaderService); - + readonly imageElement = viewChild>('image'); @Input({required: true}) readerSettings$!: Observable; @Input({required: true}) image$!: Observable; diff --git a/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.html b/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.html index 5886de491..dc78b37d8 100644 --- a/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.html +++ b/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.html @@ -21,7 +21,7 @@ [scrollContainer]="scrollElement" (triggered)="loadPrevChapter.emit()" /> -
+
@for(item of webtoonImages | async; let index = $index; track item.src) { (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>('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; diff --git a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts index f5c437246..6137ed67e 100644 --- a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts +++ b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts @@ -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() { diff --git a/UI/Web/src/app/manga-reader/_components/single-renderer/single-renderer.component.html b/UI/Web/src/app/manga-reader/_components/single-renderer/single-renderer.component.html index 2dcb81889..b08ac3ba1 100644 --- a/UI/Web/src/app/manga-reader/_components/single-renderer/single-renderer.component.html +++ b/UI/Web/src/app/manga-reader/_components/single-renderer/single-renderer.component.html @@ -3,6 +3,7 @@ [style.filter]="(darkness$ | async) ?? '' | safeStyle" [style.height]="(imageContainerHeight$ | async) ?? '' | safeStyle"> @if(currentImage) {  ; @Input({required: true}) pageNum$!: Observable<{pageNum: number, maxPages: number}>; + readonly imageElement = viewChild>('image'); + readonly imageHeight = output(); private readonly destroyRef = inject(DestroyRef); diff --git a/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.ts b/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.ts index 02c9acffc..8616c5fde 100644 --- a/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.ts +++ b/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.ts @@ -296,7 +296,7 @@ export class MetadataFilterRowComponent(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 0 ? customComparisons : baseComparisons; this.validComparisons$.next([...new Set(comps)]); diff --git a/UI/Web/src/app/metadata-filter/filter-settings.ts b/UI/Web/src/app/metadata-filter/filter-settings.ts index c96963233..112bf4f62 100644 --- a/UI/Web/src/app/metadata-filter/filter-settings.ts +++ b/UI/Web/src/app/metadata-filter/filter-settings.ts @@ -39,7 +39,7 @@ export class PersonFilterSettings extends FilterSettingsBase { +export class AnnotationFilterSettings extends FilterSettingsBase { type : ValidFilterEntity = 'annotation'; } diff --git a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.ts b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.ts index 29a8866db..7dc69c43e 100644 --- a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.ts +++ b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.ts @@ -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); }); diff --git a/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.html b/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.html index 330d8cd0e..df62f6aeb 100644 --- a/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.html +++ b/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.html @@ -77,9 +77,7 @@ @if (summary()) { -
- {{summary()}} -
+
}
diff --git a/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.ts b/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.ts index 55df16182..1490e9305 100644 --- a/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.ts +++ b/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.ts @@ -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(); 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()); diff --git a/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.html b/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.html index 2aa7d587a..6f27c68c6 100644 --- a/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.html +++ b/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.html @@ -12,6 +12,7 @@
- - - - - - - diff --git a/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.scss b/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.scss index 9783c99eb..1f5b55316 100644 --- a/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.scss +++ b/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.scss @@ -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%; diff --git a/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.ts b/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.ts index 4606a7997..161877b4f 100644 --- a/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.ts +++ b/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.ts @@ -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; + })]); } } diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html index 6d9c362fa..9f13d4f27 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html @@ -1,5 +1,3 @@ - - @@ -37,7 +35,7 @@

-
+
@if (seriesValue.localizedName !== seriesValue.name && seriesValue.localizedName) { {{seriesValue.localizedName | defaultValue}} } @@ -367,6 +365,7 @@ [tags]="seriesMetadataValue.tags" [webLinks]="weblinksValue" [filePaths]="[seriesValue.folderPath]" + [basicMetadata]="seriesBasicMetadata()" /> } diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts index 2047f4444..5bc378418 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts @@ -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(() => { + 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`; /** diff --git a/UI/Web/src/app/shared/_components/external-metadata-detail/external-metadata-detail.component.html b/UI/Web/src/app/shared/_components/external-metadata-detail/external-metadata-detail.component.html index bfd0504f2..d49b4e7b0 100644 --- a/UI/Web/src/app/shared/_components/external-metadata-detail/external-metadata-detail.component.html +++ b/UI/Web/src/app/shared/_components/external-metadata-detail/external-metadata-detail.component.html @@ -1,15 +1,17 @@ -
- @for(key of metadataIds; track key) { -
- {{t(key + '-label')}} - @let value = entity()[key]; - @if (value === 0) { -
{{null | defaultValue}}
- } @else { -
{{entity()[key] | defaultValue}}
- } +
+ @for(item of metadata(); track item.key) { +
+ +
+ } +
+
- } -
+
diff --git a/UI/Web/src/app/shared/_components/external-metadata-detail/external-metadata-detail.component.ts b/UI/Web/src/app/shared/_components/external-metadata-detail/external-metadata-detail.component.ts index 2627ba097..da77385e5 100644 --- a/UI/Web/src/app/shared/_components/external-metadata-detail/external-metadata-detail.component.ts +++ b/UI/Web/src/app/shared/_components/external-metadata-detail/external-metadata-detail.component.ts @@ -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(); - protected readonly metadataIds = Object.keys(HAS_METADATA_DEFAULTS) as (keyof IHasMetadataIds)[]; + /** Extra id to show in this section for details-tab */ + isbn = input(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 }; + }); + }); } diff --git a/UI/Web/src/app/shared/_components/pull-to-load/pull-to-load.component.html b/UI/Web/src/app/shared/_components/pull-to-load/pull-to-load.component.html index 3f5f13240..d26a10a9c 100644 --- a/UI/Web/src/app/shared/_components/pull-to-load/pull-to-load.component.html +++ b/UI/Web/src/app/shared/_components/pull-to-load/pull-to-load.component.html @@ -2,6 +2,7 @@ #container class="container" [style.height]="containerHeight()" + [style.margin-top]="containerMarginTop()" [class.armed]="isArmed()" [class.triggered]="isTriggered()" aria-hidden="true" diff --git a/UI/Web/src/app/shared/_components/pull-to-load/pull-to-load.component.ts b/UI/Web/src/app/shared/_components/pull-to-load/pull-to-load.component.ts index 9a83cb7fd..b652c2d7e 100644 --- a/UI/Web/src/app/shared/_components/pull-to-load/pull-to-load.component.ts +++ b/UI/Web/src/app/shared/_components/pull-to-load/pull-to-load.component.ts @@ -14,6 +14,7 @@ import { } from '@angular/core'; import {DOCUMENT} from '@angular/common'; import {BreakpointService} from '../../../_services/breakpoint.service'; +import {isSafari} from "../../../_helpers/browser"; /** How long (ms) the user must be idle at the scroll boundary before scroll-driven progress arms. */ const SCROLL_ARM_DELAY_MS = 100; @@ -78,6 +79,13 @@ export class PullToLoadComponent { readonly containerHeight = computed(() => this.isArmed() || this.isTriggered() ? `${this.armedHeightRem()}rem` : `${RESTING_HEIGHT_REM}rem` ); + + readonly containerMarginTop = computed(() => + this.isArmed() && this.direction() === 'down' && isSafari + ? `-${this.armedHeightRem() - RESTING_HEIGHT_REM}rem` + : '0px' + ); + readonly directionArrow = computed(() => { switch (this.direction()) { case 'down': return 'up'; @@ -261,6 +269,12 @@ export class PullToLoadComponent { * The guard lasts two animation frames to cover the scroll event dispatch. */ private adjustScrollTop(deltaPx: number) { + // We do not need to adjust scroll top on iOS & iPadOS. It's handled by negative margins + // It doesn't work anyway. Thanks, Tim Apple + if (this.direction() === 'down' && isSafari) { + return; + } + this.isCompensatingScroll = true; const scrollEl = this.resolveScrollElement(); diff --git a/UI/Web/src/app/shared/_services/filter-utilities.service.ts b/UI/Web/src/app/shared/_services/filter-utilities.service.ts index 91b7012f7..6b5656c68 100644 --- a/UI/Web/src/app/shared/_services/filter-utilities.service.ts +++ b/UI/Web/src/app/shared/_services/filter-utilities.service.ts @@ -14,6 +14,7 @@ import {of, switchMap} from "rxjs"; import {allPersonFilterFields, PersonFilterField} from "../../_models/metadata/v2/person-filter-field"; import {allPersonSortFields} from "../../_models/metadata/v2/person-sort-field"; import { + AnnotationFilterSettings, FilterSettingsBase, PersonFilterSettings, ReadingListFilterSettings, @@ -260,7 +261,7 @@ export class FilterUtilitiesService { ] as unknown as T[]; case 'readinglist': return [ - ReadingListFilterField.Writer, ReadingListFilterField.Artist, ReadingListFilterField.Tags + ReadingListFilterField.Writer, ReadingListFilterField.Artist, ReadingListFilterField.Tags, ReadingListFilterField.Provider ] as unknown as T[]; } } @@ -307,7 +308,7 @@ export class FilterUtilitiesService { ] as unknown as T[]; case 'readinglist': return [ - ReadingListFilterField.ItemCount + ReadingListFilterField.ItemCount, ReadingListFilterField.MissingItemCount ] as unknown as T[]; } } @@ -387,7 +388,7 @@ export class FilterUtilitiesService { case 'person': return [] as unknown as T[]; case 'readinglist': - return [] as unknown as T[]; + return [ReadingListFilterField.Provider] as unknown as T[]; } } @@ -432,17 +433,17 @@ export class FilterUtilitiesService { return this.getFieldsThatShouldIncludeIsEmpty(type); } - getDefaultSettings(entityType: ValidFilterEntity | 'other' | undefined): FilterSettingsBase { - if (entityType === 'other' || entityType === undefined) { - // It doesn't matter, return series type - return new SeriesFilterSettings(); + getDefaultSettings(entityType: ValidFilterEntity): FilterSettingsBase { + switch (entityType) { + case "series": + return new SeriesFilterSettings(); + case "person": + return new PersonFilterSettings(); + case "annotation": + return new AnnotationFilterSettings(); + case "readinglist": + return new ReadingListFilterSettings(); } - - if (entityType == 'series') return new SeriesFilterSettings(); - if (entityType == 'person') return new PersonFilterSettings(); - if (entityType == 'readinglist') return new ReadingListFilterSettings(); - - return new SeriesFilterSettings(); } /** @@ -458,6 +459,15 @@ export class FilterUtilitiesService { FilterComparison.LessThan, FilterComparison.LessThanEqual ] } + break; + case 'readinglist': + switch (field) { + case ReadingListFilterField.Provider: + return [ + FilterComparison.Equal, FilterComparison.NotEqual + ] + } + break; } return null; diff --git a/UI/Web/src/app/shared/person-badge/person-badge.component.scss b/UI/Web/src/app/shared/person-badge/person-badge.component.scss index 3c63b2f8d..6f5e9b0dc 100644 --- a/UI/Web/src/app/shared/person-badge/person-badge.component.scss +++ b/UI/Web/src/app/shared/person-badge/person-badge.component.scss @@ -1,3 +1,7 @@ +:host { + --person-badge-size: 6rem; +} + .tagbadge { background-color: var(--tagbadge-bg-color); transition: all .3s ease-out; @@ -6,23 +10,22 @@ font-size: .8rem; display: inline-block; cursor: pointer; - width: 6rem; word-break: break-word; i { - max-height: 3rem; - height: 3rem; - width: 3rem; - font-size: 2.96rem; + max-height: calc((var(--person-badge-size) / 2)); + height: calc((var(--person-badge-size) / 2)); + width: calc((var(--person-badge-size) / 2)); + font-size: calc((var(--person-badge-size) / 2)); font-weight: bold; cursor: pointer; } .image-container { background: var(--card-bg-color); - max-height: 6rem; - height: 6rem; - width: 6rem; + max-height: var(--person-badge-size); + height: var(--person-badge-size); + width: var(--person-badge-size); border-radius: 50%; overflow: hidden; } diff --git a/UI/Web/src/app/shared/person-badge/person-badge.component.ts b/UI/Web/src/app/shared/person-badge/person-badge.component.ts index 489827870..8fc528282 100644 --- a/UI/Web/src/app/shared/person-badge/person-badge.component.ts +++ b/UI/Web/src/app/shared/person-badge/person-badge.component.ts @@ -11,7 +11,8 @@ import {RouterLink} from "@angular/router"; imports: [ImageComponent, RouterLink], templateUrl: './person-badge.component.html', styleUrls: ['./person-badge.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush + changeDetection: ChangeDetectionStrategy.OnPush, + host: { '[style.--person-badge-size]': 'badgeSize()' } }) export class PersonBadgeComponent { @@ -19,9 +20,15 @@ export class PersonBadgeComponent { person = input.required(); isStaff = input(false); + size = input<'normal' | 'medium' | 'small'>('normal'); staff = computed(() => this.person() as SeriesStaff); + badgeSize = computed(() => { + const map = { normal: '6rem', medium: '4rem', small: '3rem' }; + return map[this.size()]; + }); + hasCoverImage = computed(() => { return this.isStaff() || !!(this.person() as Person).coverImage; }); diff --git a/UI/Web/src/app/shared/tag-badge/tag-badge.component.html b/UI/Web/src/app/shared/tag-badge/tag-badge.component.html index bb814c9ad..8f70472c3 100644 --- a/UI/Web/src/app/shared/tag-badge/tag-badge.component.html +++ b/UI/Web/src/app/shared/tag-badge/tag-badge.component.html @@ -7,6 +7,7 @@ [class.color-error]="color() === 'error'" [class.selectable-cursor]="selectionMode() === TagBadgeCursor.Selectable" [class.not-allowed-cursor]="selectionMode() === TagBadgeCursor.NotAllowed" - [class.clickable-cursor]="selectionMode() === TagBadgeCursor.Clickable"> + [class.clickable-cursor]="selectionMode() === TagBadgeCursor.Clickable" + [class.shape-pill]="shape() === 'pill'">
diff --git a/UI/Web/src/app/shared/tag-badge/tag-badge.component.scss b/UI/Web/src/app/shared/tag-badge/tag-badge.component.scss index 70ae2d657..0a398bd60 100644 --- a/UI/Web/src/app/shared/tag-badge/tag-badge.component.scss +++ b/UI/Web/src/app/shared/tag-badge/tag-badge.component.scss @@ -68,6 +68,15 @@ } } +.shape-pill { + border-radius: 999px; + padding: 0.1875rem 0.5625rem; + + &:hover { + background: var(--tagbadge-pill-hover-bg-color); + } +} + .outline { border: 1px solid var(--tagbadge-border-color); color: var(--tagbadge-text-color); @@ -85,4 +94,4 @@ --tagbadge-border-color: var(--error-color); --tagbadge-text-color: var(--error-color); } -} \ No newline at end of file +} diff --git a/UI/Web/src/app/shared/tag-badge/tag-badge.component.ts b/UI/Web/src/app/shared/tag-badge/tag-badge.component.ts index e0e4eb57b..fc607dc88 100644 --- a/UI/Web/src/app/shared/tag-badge/tag-badge.component.ts +++ b/UI/Web/src/app/shared/tag-badge/tag-badge.component.ts @@ -35,6 +35,7 @@ export class TagBadgeComponent { fillStyle = input<'filled' | 'outline'>('outline'); color = input('default'); size = input<'default' | 'sm'>('default'); + shape = input<'default' | 'pill'>('default'); protected readonly TagBadgeCursor = TagBadgeCursor; } diff --git a/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.html b/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.html index 15e10bc7e..e5587e8ce 100644 --- a/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.html +++ b/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.html @@ -66,7 +66,11 @@ @case (SideNavStreamType.ReadingLists) { + icon="fa-list-ol" [title]="t('reading-lists')" link="/lists/"> + + + + } @case (SideNavStreamType.Collections) { diff --git a/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts b/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts index 3e97730e1..7fb4ab3b8 100644 --- a/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts +++ b/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts @@ -59,6 +59,7 @@ export class SideNavComponent { cachedData: SideNavStream[] | null = null; actions: ActionItem[] = this.actionFactoryService.getLibraryActions(); homeActions: ActionItem<{}>[] = this.actionFactoryService.getSideNavHomeActions(); + readingListActions: ActionItem<{}>[] = this.actionFactoryService.getSideNavReadingListActions(); filterQuery: string = ''; filterLibrary = (stream: SideNavStream) => { @@ -170,6 +171,12 @@ export class SideNavComponent { this.showMore(true); } } + performReadingListAction(event: ActionItem<{}> | ActionResult<{}>) { + if (event.action === Action.Navigate) { + this.router.navigateByUrl('/settings#cbl-import'); + return; + } + } getLibraryTypeIcon(format: LibraryType) { switch (format) { diff --git a/UI/Web/src/app/typeahead/_components/typeahead.component.ts b/UI/Web/src/app/typeahead/_components/typeahead.component.ts index 07d052637..957e94939 100644 --- a/UI/Web/src/app/typeahead/_components/typeahead.component.ts +++ b/UI/Web/src/app/typeahead/_components/typeahead.component.ts @@ -349,7 +349,10 @@ export class TypeaheadComponent implements OnInit { this.typeaheadControl.setValue(this.typeaheadControl.value); this.hasFocus = true; if (this.useOverlay) { - this.triggerWidth = this.triggerEl().nativeElement.getBoundingClientRect().width; + this.triggerWidth = Math.max( + this.triggerEl().nativeElement.getBoundingClientRect().width, + this.settings.overlayMinWidth ?? 0 + ); } }); } @@ -373,7 +376,10 @@ export class TypeaheadComponent implements OnInit { inputElem.nativeElement.focus(); this.hasFocus = true; if (this.useOverlay) { - this.triggerWidth = this.triggerEl().nativeElement.getBoundingClientRect().width; + this.triggerWidth = Math.max( + this.triggerEl().nativeElement.getBoundingClientRect().width, + this.settings.overlayMinWidth ?? 0 + ); } } diff --git a/UI/Web/src/app/typeahead/_models/typeahead-settings.ts b/UI/Web/src/app/typeahead/_models/typeahead-settings.ts index 176c7fa4b..74674cd22 100644 --- a/UI/Web/src/app/typeahead/_models/typeahead-settings.ts +++ b/UI/Web/src/app/typeahead/_models/typeahead-settings.ts @@ -77,6 +77,12 @@ export class TypeaheadSettings { * 'body' renders via CDK overlay attached to the document body, avoiding overflow: hidden clipping. */ dropdownPosition: 'relative' | 'body' = 'relative'; + /** + * Minimum width (px) for the CDK overlay dropdown when dropdownPosition is 'body'. + * The overlay will be at least this wide, even if the trigger element is narrower. + * Defaults to 0 (overlay matches trigger width exactly). + */ + overlayMinWidth: number = 0; } /** diff --git a/UI/Web/src/app/user-settings/_modals/import-cbl-modal/import-cbl-modal.component.html b/UI/Web/src/app/user-settings/_modals/import-cbl-modal/import-cbl-modal.component.html index 1937d4388..e9eaa55b7 100644 --- a/UI/Web/src/app/user-settings/_modals/import-cbl-modal/import-cbl-modal.component.html +++ b/UI/Web/src/app/user-settings/_modals/import-cbl-modal/import-cbl-modal.component.html @@ -278,8 +278,21 @@ }
-
- +
+ @if (currentSummary()) { + + + + + } + @@ -17,14 +17,14 @@
-
+
-
+
@@ -59,7 +59,8 @@
-
+
@let selectedItem = selectedList(); @@ -93,17 +94,37 @@
-
+
+

{{selectedItem.title}}

-
- @if (!accountService.hasReadOnlyRole()) { - - } - @if (selectedItem.canSync) { - - } + +
+ +
+ @if (!accountService.hasReadOnlyRole()) { + + } + @if (selectedItem.canSync) { + +
+ + +
+ + +
+
+ } +
+
@@ -141,22 +162,22 @@
}
+
+
-
-
+
+
+ @if (selectedItem.canSync) { +
+
{{t('last-synced', {date: (selectedItem.lastSyncedUtc | utcToLocalDate | timeAgo)})}}
+
{{t('last-checked', {date: (selectedItem.lastSyncCheckUtc | utcToLocalDate | timeAgo)})}}
@if (selectedItem.canSync) { -
-
{{t('last-synced', {date: (selectedItem.lastSyncedUtc | utcToLocalDate | timeAgo)})}}
-
{{t('last-checked', {date: (selectedItem.lastSyncCheckUtc | utcToLocalDate | timeAgo)})}}
- @if (selectedItem.canSync) { - - } + }
-
+ }
diff --git a/UI/Web/src/app/user-settings/cbl-manager/cbl-manager.component.scss b/UI/Web/src/app/user-settings/cbl-manager/cbl-manager.component.scss index e23e813d3..fa8802a1b 100644 --- a/UI/Web/src/app/user-settings/cbl-manager/cbl-manager.component.scss +++ b/UI/Web/src/app/user-settings/cbl-manager/cbl-manager.component.scss @@ -1,23 +1,11 @@ - -ngx-file-drop ::ng-deep > div { - // styling for the outer drop box - width: 100%; - border: 0.125rem solid var(--primary-color); - border-radius: 0.3125rem; - height: 6.25rem; - margin: auto; - - > div { - // styling for the inner box (template) - width: 100%; - display: inline-block; - - } -} - .custom-position { right: 0.9375rem; top: -2.625rem; + @media (max-width: 576px) { + position: static !important; + justify-content: flex-end; + padding: 0 0 0.5rem; + } } .section-header { @@ -31,7 +19,8 @@ ngx-file-drop ::ng-deep > div { flex-direction: column; @media (max-width: 576px) { - max-height: calc(100dvh - 30rem); + max-height: 50dvh; + overflow-y: auto; } > div { @@ -59,8 +48,20 @@ ngx-file-drop ::ng-deep > div { color: var(--primary-color); } -.btn-group .btn { - font-size: 0.8rem; +.filter-row { + @media (max-width: 576px) { + flex-direction: column; + align-items: flex-start !important; + gap: 0.5rem !important; + } +} + +.detail-panel { + @media (max-width: 767px) { + &.is-hidden-mobile { + display: none; + } + } } .detail-cover { @@ -75,3 +76,13 @@ ngx-file-drop ::ng-deep > div { border-radius: 0.25rem; } } + +.btn-group .btn { + font-size: 0.8rem; +} + +.btn-group > .btn.dropdown-toggle-split { + border-top-right-radius: var(--bs-border-radius) !important; + border-bottom-right-radius: var(--bs-border-radius) !important; + border-width: 1px 1px 1px 0 !important; +} diff --git a/UI/Web/src/app/user-settings/cbl-manager/cbl-manager.component.ts b/UI/Web/src/app/user-settings/cbl-manager/cbl-manager.component.ts index aa4951b2e..d029069e9 100644 --- a/UI/Web/src/app/user-settings/cbl-manager/cbl-manager.component.ts +++ b/UI/Web/src/app/user-settings/cbl-manager/cbl-manager.component.ts @@ -32,7 +32,7 @@ import {TimeAgoPipe} from "../../_pipes/time-ago.pipe"; import {AgeRatingImageComponent} from "../../_single-module/age-rating-image/age-rating-image.component"; import {DateYearRangePipe} from "../../_pipes/date-year-range.pipe"; import {SafeUrlPipe} from "../../_pipes/safe-url.pipe"; -import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; +import {NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle, NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; @Component({ selector: 'app-cbl-manager', @@ -52,7 +52,11 @@ import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; TimeAgoPipe, AgeRatingImageComponent, SafeUrlPipe, - NgbTooltip + NgbTooltip, + NgbDropdown, + NgbDropdownItem, + NgbDropdownMenu, + NgbDropdownToggle ], templateUrl: './cbl-manager.component.html', styleUrl: './cbl-manager.component.scss', @@ -185,6 +189,12 @@ export class CblManagerComponent implements OnInit { }); } + manualSyncReadingList(list: ReadingList) { + this.cblService.importFromUrl(list.downloadUrl!).subscribe((savedFile) => { + this.openImportModal([savedFile]); + }); + } + getDateRangeLabel(rl: ReadingList) { if (!rl || rl.startingYear === 0) return null; @@ -192,9 +202,9 @@ export class CblManagerComponent 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); } diff --git a/UI/Web/src/app/volume-detail/volume-detail.component.html b/UI/Web/src/app/volume-detail/volume-detail.component.html index 0cfd51b0e..636d1d88c 100644 --- a/UI/Web/src/app/volume-detail/volume-detail.component.html +++ b/UI/Web/src/app/volume-detail/volume-detail.component.html @@ -232,6 +232,7 @@ [genres]="genres()" [tags]="tags()" [files]="files()" + [basicMetadata]="volumeBasicMetadata()" /> } diff --git a/UI/Web/src/app/volume-detail/volume-detail.component.ts b/UI/Web/src/app/volume-detail/volume-detail.component.ts index 8d276ebcc..d4a21517f 100644 --- a/UI/Web/src/app/volume-detail/volume-detail.component.ts +++ b/UI/Web/src/app/volume-detail/volume-detail.component.ts @@ -45,7 +45,7 @@ import {AgeRating} from '../_models/metadata/age-rating'; import {Volume} from "../_models/volume"; import {VolumeService} from "../_services/volume.service"; import {LoadingComponent} from "../shared/loading/loading.component"; -import {DetailsTabComponent} from "../_single-module/details-tab/details-tab.component"; +import {BasicMetadataInfo, DetailsTabComponent} from "../_single-module/details-tab/details-tab.component"; import {ReadMoreComponent} from "../shared/read-more/read-more.component"; import {Person} from "../_models/metadata/person"; import {IHasCast} from "../_models/common/i-has-cast"; @@ -289,6 +289,18 @@ export class VolumeDetailComponent implements OnInit { return translate(chapterLocaleKey, {num: currentlyReadingChapter.minNumber}); }) + volumeBasicMetadata = computed(() => { + const v = this.volume(); + return { + readingTime: v, + pages: v.pages, + words: v.wordCount, + addedAt: v.createdUtc, + updatedAt: v.lastModifiedUtc, + kavitaId: v.id, + }; + }); + volumeCast = computed(() => { const chapters = this.volume()?.chapters || []; return { diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index b9a7cbfb7..1c8b1f4bf 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -1560,9 +1560,27 @@ "format-title": "{{metadata-filter.format-label}}", "length-title": "{{edit-chapter-modal.words-label}}", "age-rating-title": "{{metadata-fields.age-rating-title}}", - "folder-path-title": "Folder path", + "folder-path-title": "{{edit-series-modal.folder-path-title}}", "file-path-title": "Files", - "external-metadata-title": "External Metadata" + "external-metadata-title": "External Metadata", + "basic-metadata-title": "Basic Metadata", + "read-time-label": "Read time", + "pages-label": "{{edit-chapter-modal.pages-label}}", + "words-label": "{{edit-chapter-modal.words-label}}", + "added-label": "Added", + "updated-label": "Updated", + "kavita-id-label": "Kavita ID", + "sort-order-label": "{{edit-chapter-modal.sort-order-label}}", + "language-label": "Language", + "is-special-label": "Special", + "pub-status-label": "{{metadata-filter.publication-status-label}}", + "yes-label": "Yes", + "no-label": "No", + "file-info-title": "File info", + "not-set-label": "Not set", + "pages-count": "{{series-detail.pages-count}}", + "words-count": "{{series-detail.words-count}}", + "bytes-count": "{{num}} bytes" }, "related-tab": { @@ -2124,6 +2142,7 @@ "no-results": "No matching reading lists", "add": "{{common.add}}", "sync": "Sync", + "sync-manual": "Sync (Manual)", "delete": "{{common.delete}}", "view-list": "View Reading List", "last-synced": "Last Synced: {{date}}", @@ -2173,7 +2192,9 @@ "volume-num": "{{common.volume-num-shorthand}}", "issue-num": "{{common.issue-num-shorthand}}", "refresh": "Refresh", - "wiki": "Wiki" + "wiki": "Wiki", + "promote": "{{actionable.promote}}", + "promote-tooltip": "{{actionable.promote-tooltip}}" }, "manage-remap-rules-modal": { @@ -3545,7 +3566,9 @@ "readinglist-item-count": "{{sort-field-pipe.readinglist-item-count}}", "readinglist-tags": "{{metadata-fields.tags-title}}", "readinglist-writer": "{{person-role-pipe.writer}}", - "readinglist-artist": "{{person-role-pipe.artist}}" + "readinglist-artist": "{{person-role-pipe.artist}}", + "readinglist-provider": "Provider", + "readinglist-missing-item-count": "Missing Item Count" }, @@ -3801,7 +3824,9 @@ "export-v1": "CBL v1", "export-v1-tooltip": "Use CBL v1 (XML)", "export-v2": "CBL v2", - "export-v2-tooltip": "Use CBL v2 (JSON)" + "export-v2-tooltip": "Use CBL v2 (JSON)", + "cbl-manager": "CBL Manager", + "cbl-manager-tooltip": "Manage/Sync CBLs" }, "preferences": { @@ -3944,7 +3969,9 @@ "mangaBakaId-label": "MangaBaka Id", "hardcoverId-label": "Hardcover Id", "comicVineId-label": "Comic Vine Id", - "metronId-label": "Metron Id" + "metronId-label": "Metron Id", + "not-set-label": "Not set", + "isbn-label": "{{edit-chapter-modal.isbn-label}}" }, diff --git a/UI/Web/src/styles.scss b/UI/Web/src/styles.scss index ebca2c1ee..e4b942381 100644 --- a/UI/Web/src/styles.scss +++ b/UI/Web/src/styles.scss @@ -52,6 +52,7 @@ @use './theme/components/typeahead'; @use './theme/components/tooltip'; @use './theme/components/stat-card'; +@use './theme/components/headers'; @use './theme/utilities/headings'; @use './theme/utilities/utilities'; diff --git a/UI/Web/src/theme/components/_headers.scss b/UI/Web/src/theme/components/_headers.scss new file mode 100644 index 000000000..9562789ae --- /dev/null +++ b/UI/Web/src/theme/components/_headers.scss @@ -0,0 +1,10 @@ +/** Needed for Details page to scope into carousel */ +.kv-section-header { + font-size: 0.68rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted-color); + margin-bottom: 0.625rem; + margin-top: 0.625rem; +} diff --git a/UI/Web/src/theme/themes/dark.scss b/UI/Web/src/theme/themes/dark.scss index b7b44e3cb..a742df7ef 100644 --- a/UI/Web/src/theme/themes/dark.scss +++ b/UI/Web/src/theme/themes/dark.scss @@ -236,6 +236,18 @@ --tagbadge-filled-border-color: rgba(239, 239, 239, 0.125); --tagbadge-filled-text-color: var(--body-text-color); --tagbadge-filled-bg-color: var(--primary-color); + --tagbadge-pill-hover-bg-color: rgba(74, 198, 148, 0.15); + + /* Label Card */ + --label-card-bg: rgba(255, 255, 255, 0.04); + --label-card-border: rgba(239, 239, 239, 0.07); + --label-card-icon-color: var(--primary-color); + --label-card-label-color: rgba(255, 255, 255, 0.4); + --label-card-value-color: #efefef; + --label-card-value-muted-color: rgba(255, 255, 255, 0.25); + + /* File path */ + --file-path-color: rgba(255, 255, 255, 0.55); /* Side Nav */ --side-nav-width: 14.375rem; @@ -490,6 +502,7 @@ /** Misc **/ --offwhite-text-color: #8b95a5; + --white-text-color: white; /** Activity Card **/ --activity-card-client-platform-badge-bg-color: #8b5cf6;