diff --git a/API.Tests/Parsing/ParsingTests.cs b/API.Tests/Parsing/ParsingTests.cs index 6a184e3f9..e30bf6554 100644 --- a/API.Tests/Parsing/ParsingTests.cs +++ b/API.Tests/Parsing/ParsingTests.cs @@ -250,6 +250,7 @@ public class ParsingTests [InlineData("@recycle/Love Hina/", true)] [InlineData("E:/Test/__MACOSX/Love Hina/", true)] [InlineData("E:/Test/.caltrash/Love Hina/", true)] + [InlineData("E:/Test/.yacreaderlibrary/Love Hina/", true)] public void HasBlacklistedFolderInPathTest(string inputPath, bool expected) { Assert.Equal(expected, HasBlacklistedFolderInPath(inputPath)); diff --git a/API/API.csproj b/API/API.csproj index 57097c64d..5046bff05 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -12,10 +12,10 @@ latestmajor - - - - + + + + false diff --git a/API/Controllers/CBLController.cs b/API/Controllers/CBLController.cs index c49f915da..b4e674a1d 100644 --- a/API/Controllers/CBLController.cs +++ b/API/Controllers/CBLController.cs @@ -7,6 +7,7 @@ using API.Extensions; using API.Services; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; namespace API.Controllers; @@ -31,16 +32,17 @@ public class CblController : BaseApiController /// If this returns errors, the cbl will always be rejected by Kavita. /// /// FormBody with parameter name of cbl - /// Use comic vine matching or not. Defaults to false + /// Use comic vine matching or not. Defaults to false /// [HttpPost("validate")] - public async Task> ValidateCbl(IFormFile cbl, [FromQuery] bool comicVineMatching = false) + [SwaggerIgnore] + public async Task> ValidateCbl(IFormFile cbl, [FromQuery] bool useComicVineMatching = false) { var userId = User.GetUserId(); try { var cblReadingList = await SaveAndLoadCblFile(cbl); - var importSummary = await _readingListService.ValidateCblFile(userId, cblReadingList, comicVineMatching); + var importSummary = await _readingListService.ValidateCblFile(userId, cblReadingList, useComicVineMatching); importSummary.FileName = cbl.FileName; return Ok(importSummary); } @@ -82,16 +84,17 @@ public class CblController : BaseApiController /// /// FormBody with parameter name of cbl /// If true, will only emulate the import but not perform. This should be done to preview what will happen - /// Use comic vine matching or not. Defaults to false + /// Use comic vine matching or not. Defaults to false /// [HttpPost("import")] - public async Task> ImportCbl(IFormFile cbl, [FromQuery] bool dryRun = false, [FromQuery] bool comicVineMatching = false) + [SwaggerIgnore] + public async Task> ImportCbl(IFormFile cbl, [FromQuery] bool dryRun = false, [FromQuery] bool useComicVineMatching = false) { try { var userId = User.GetUserId(); var cblReadingList = await SaveAndLoadCblFile(cbl); - var importSummary = await _readingListService.CreateReadingListFromCbl(userId, cblReadingList, dryRun, comicVineMatching); + var importSummary = await _readingListService.CreateReadingListFromCbl(userId, cblReadingList, dryRun, useComicVineMatching); importSummary.FileName = cbl.FileName; return Ok(importSummary); diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index 1b405f3b1..41621f6cc 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -19,6 +19,7 @@ using API.Services.Tasks.Scanner; using API.SignalR; using AutoMapper; using EasyCaching.Core; +using Hangfire; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -132,13 +133,19 @@ public class LibraryController : BaseApiController if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-library")); - await _libraryWatcher.RestartWatching(); - await _taskScheduler.ScanLibrary(library.Id); + await _libraryCacheProvider.RemoveByPrefixAsync(CacheKey); + + if (library.FolderWatching) + { + await _libraryWatcher.RestartWatching(); + } + + BackgroundJob.Enqueue(() => _taskScheduler.ScanLibrary(library.Id, false)); await _eventHub.SendMessageAsync(MessageFactory.LibraryModified, MessageFactory.LibraryModifiedEvent(library.Id, "create"), false); await _eventHub.SendMessageAsync(MessageFactory.SideNavUpdate, MessageFactory.SideNavUpdateEvent(User.GetUserId()), false); - await _libraryCacheProvider.RemoveByPrefixAsync(CacheKey); + return Ok(); } @@ -409,7 +416,7 @@ public class LibraryController : BaseApiController _taskScheduler.CleanupChapters(chapterIds); } - await _libraryWatcher.RestartWatching(); + BackgroundJob.Enqueue(() => _libraryWatcher.RestartWatching()); foreach (var seriesId in seriesIds) { @@ -496,16 +503,17 @@ public class LibraryController : BaseApiController _unitOfWork.LibraryRepository.Update(library); if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(userId, "generic-library-update")); + + if (folderWatchingUpdate || originalFoldersCount != dto.Folders.Count() || typeUpdate) + { + BackgroundJob.Enqueue(() => _libraryWatcher.RestartWatching()); + } + if (originalFoldersCount != dto.Folders.Count() || typeUpdate) { - await _libraryWatcher.RestartWatching(); await _taskScheduler.ScanLibrary(library.Id); } - if (folderWatchingUpdate) - { - await _libraryWatcher.RestartWatching(); - } await _eventHub.SendMessageAsync(MessageFactory.LibraryModified, MessageFactory.LibraryModifiedEvent(library.Id, "update"), false); diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index d25321ad8..b025be4a4 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -873,6 +873,7 @@ public class OpdsController : BaseApiController feed.Links.Add(CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/series-cover?seriesId={seriesId}&apiKey={apiKey}")); var chapterDict = new Dictionary(); + var fileDict = new Dictionary(); var seriesDetail = await _seriesService.GetSeriesDetail(seriesId, userId); foreach (var volume in seriesDetail.Volumes) { @@ -881,12 +882,14 @@ public class OpdsController : BaseApiController foreach (var chapter in chaptersForVolume) { var chapterId = chapter.Id; - if (chapterDict.ContainsKey(chapterId)) continue; + if (!chapterDict.TryAdd(chapterId, 0)) continue; var chapterDto = _mapper.Map(chapter); foreach (var mangaFile in chapter.Files) { - chapterDict.Add(chapterId, 0); + // If a chapter has multiple files that are within one chapter, this dict prevents duplicate key exception + if (!fileDict.TryAdd(mangaFile.Id, 0)) continue; + feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volume.Id, chapterId, _mapper.Map(mangaFile), series, chapterDto, apiKey, prefix, baseUrl)); } @@ -905,6 +908,8 @@ public class OpdsController : BaseApiController var chapterDto = _mapper.Map(chapter); foreach (var mangaFile in files) { + // If a chapter has multiple files that are within one chapter, this dict prevents duplicate key exception + if (!fileDict.TryAdd(mangaFile.Id, 0)) continue; feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, chapter.VolumeId, chapter.Id, _mapper.Map(mangaFile), series, chapterDto, apiKey, prefix, baseUrl)); } @@ -916,6 +921,9 @@ public class OpdsController : BaseApiController var chapterDto = _mapper.Map(special); foreach (var mangaFile in files) { + // If a chapter has multiple files that are within one chapter, this dict prevents duplicate key exception + if (!fileDict.TryAdd(mangaFile.Id, 0)) continue; + feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, special.VolumeId, special.Id, _mapper.Map(mangaFile), series, chapterDto, apiKey, prefix, baseUrl)); } diff --git a/API/Controllers/PluginController.cs b/API/Controllers/PluginController.cs index ce2e4eced..87cfaf2c2 100644 --- a/API/Controllers/PluginController.cs +++ b/API/Controllers/PluginController.cs @@ -46,6 +46,7 @@ public class PluginController(IUnitOfWork unitOfWork, ITokenService tokenService } var user = await unitOfWork.UserRepository.GetUserByIdAsync(userId); logger.LogInformation("Plugin {PluginName} has authenticated with {UserName} ({UserId})'s API Key", pluginName.Replace(Environment.NewLine, string.Empty), user!.UserName, userId); + return new UserDto { Username = user.UserName!, diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index ef3f20fc6..c5acd4bf1 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -319,11 +319,12 @@ public class SeriesController : BaseApiController /// /// [HttpPost("all-v2")] - public async Task>> GetAllSeriesV2(FilterV2Dto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0) + public async Task>> GetAllSeriesV2(FilterV2Dto filterDto, [FromQuery] UserParams userParams, + [FromQuery] int libraryId = 0, [FromQuery] QueryContext context = QueryContext.None) { var userId = User.GetUserId(); var series = - await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, userParams, filterDto); + await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, userParams, filterDto, context); // Apply progress/rating information (I can't work out how to do this in initial query) if (series == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-series")); diff --git a/API/Controllers/ServerController.cs b/API/Controllers/ServerController.cs index 819320469..24b73f0a2 100644 --- a/API/Controllers/ServerController.cs +++ b/API/Controllers/ServerController.cs @@ -277,4 +277,16 @@ public class ServerController : BaseApiController return Ok(); } + /// + /// Runs the Sync Themes task + /// + /// + [Authorize("RequireAdminRole")] + [HttpPost("sync-themes")] + public async Task SyncThemes() + { + await _taskScheduler.SyncThemes(); + return Ok(); + } + } diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs index 8ed9fdd1c..5d0c207d1 100644 --- a/API/Controllers/SettingsController.cs +++ b/API/Controllers/SettingsController.cs @@ -23,6 +23,7 @@ using Kavita.Common.Helpers; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; +using Swashbuckle.AspNetCore.Annotations; namespace API.Controllers; @@ -370,7 +371,7 @@ public class SettingsController : BaseApiController return Ok(updateSettingsDto); } - public void UpdateBookmarkDirectory(string originalBookmarkDirectory, string bookmarkDirectory) + private void UpdateBookmarkDirectory(string originalBookmarkDirectory, string bookmarkDirectory) { _directoryService.ExistOrCreate(bookmarkDirectory); _directoryService.CopyDirectoryToDirectory(originalBookmarkDirectory, bookmarkDirectory); diff --git a/API/DTOs/Filtering/v2/FilterComparision.cs b/API/DTOs/Filtering/v2/FilterComparision.cs index 109667dad..59bb86a8a 100644 --- a/API/DTOs/Filtering/v2/FilterComparision.cs +++ b/API/DTOs/Filtering/v2/FilterComparision.cs @@ -53,4 +53,8 @@ public enum FilterComparison /// Is Date not between now and X seconds ago /// IsNotInLast = 15, + /// + /// There are no records + /// + IsEmpty = 16 } diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index 3b6ed9bb6..88b9b0a75 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -159,7 +159,7 @@ public interface ISeriesRepository Task GetAverageUserRating(int seriesId, int userId); Task RemoveFromOnDeck(int seriesId, int userId); Task ClearOnDeckRemoval(int seriesId, int userId); - Task> GetSeriesDtoForLibraryIdV2Async(int userId, UserParams userParams, FilterV2Dto filterDto); + Task> GetSeriesDtoForLibraryIdV2Async(int userId, UserParams userParams, FilterV2Dto filterDto, QueryContext queryContext = QueryContext.None); Task GetPlusSeriesDto(int seriesId); } @@ -693,9 +693,9 @@ public class SeriesRepository : ISeriesRepository return await query.ToListAsync(); } - public async Task> GetSeriesDtoForLibraryIdV2Async(int userId, UserParams userParams, FilterV2Dto filterDto) + public async Task> GetSeriesDtoForLibraryIdV2Async(int userId, UserParams userParams, FilterV2Dto filterDto, QueryContext queryContext = QueryContext.None) { - var query = await CreateFilteredSearchQueryableV2(userId, filterDto, QueryContext.None); + var query = await CreateFilteredSearchQueryableV2(userId, filterDto, queryContext); var retSeries = query .ProjectTo(_mapper.ConfigurationProvider) @@ -979,7 +979,7 @@ public class SeriesRepository : ISeriesRepository .HasReleaseYear(hasReleaseYearMaxFilter, FilterComparison.LessThanEqual, filter.ReleaseYearRange?.Max) .HasReleaseYear(hasReleaseYearMinFilter, FilterComparison.GreaterThanEqual, filter.ReleaseYearRange?.Min) .HasName(hasSeriesNameFilter, FilterComparison.Matches, filter.SeriesNameQuery) - .HasRating(hasRatingFilter, FilterComparison.GreaterThanEqual, filter.Rating, userId) + .HasRating(hasRatingFilter, FilterComparison.GreaterThanEqual, filter.Rating / 100f, userId) .HasAgeRating(hasAgeRating, FilterComparison.Contains, filter.AgeRating) .HasPublicationStatus(hasPublicationFilter, FilterComparison.Contains, filter.PublicationStatus) .HasTags(hasTagsFilter, FilterComparison.Contains, filter.Tags) @@ -987,7 +987,7 @@ public class SeriesRepository : ISeriesRepository .HasGenre(hasGenresFilter, FilterComparison.Contains, filter.Genres) .HasFormat(filter.Formats != null && filter.Formats.Count > 0, FilterComparison.Contains, filter.Formats!) .HasAverageReadTime(true, FilterComparison.GreaterThanEqual, 0) - .HasPeople(hasPeopleFilter, FilterComparison.Contains, allPeopleIds) + .HasPeopleLegacy(hasPeopleFilter, FilterComparison.Contains, allPeopleIds) .WhereIf(onlyParentSeries, s => s.RelationOf.Count == 0 || s.RelationOf.All(p => p.RelationKind == RelationKind.Prequel)) @@ -1215,6 +1215,7 @@ public class SeriesRepository : ISeriesRepository private static IQueryable BuildFilterGroup(int userId, FilterStatementDto statement, IQueryable query) { + var value = FilterFieldValueConverter.ConvertValue(statement.Field, statement.Value); return statement.Field switch { @@ -1226,21 +1227,21 @@ public class SeriesRepository : ISeriesRepository (IList) value), FilterField.Languages => query.HasLanguage(true, statement.Comparison, (IList) value), FilterField.AgeRating => query.HasAgeRating(true, statement.Comparison, (IList) value), - FilterField.UserRating => query.HasRating(true, statement.Comparison, (int) value, userId), + FilterField.UserRating => query.HasRating(true, statement.Comparison, (float) value , userId), FilterField.Tags => query.HasTags(true, statement.Comparison, (IList) value), - FilterField.Translators => query.HasPeople(true, statement.Comparison, (IList) value), - FilterField.Characters => query.HasPeople(true, statement.Comparison, (IList) value), - FilterField.Publisher => query.HasPeople(true, statement.Comparison, (IList) value), - FilterField.Editor => query.HasPeople(true, statement.Comparison, (IList) value), - FilterField.CoverArtist => query.HasPeople(true, statement.Comparison, (IList) value), - FilterField.Letterer => query.HasPeople(true, statement.Comparison, (IList) value), - FilterField.Colorist => query.HasPeople(true, statement.Comparison, (IList) value), - FilterField.Inker => query.HasPeople(true, statement.Comparison, (IList) value), - FilterField.Imprint => query.HasPeople(true, statement.Comparison, (IList) value), - FilterField.Team => query.HasPeople(true, statement.Comparison, (IList) value), - FilterField.Location => query.HasPeople(true, statement.Comparison, (IList) value), - FilterField.Penciller => query.HasPeople(true, statement.Comparison, (IList) value), - FilterField.Writers => query.HasPeople(true, statement.Comparison, (IList) value), + FilterField.Translators => query.HasPeople(true, statement.Comparison, (IList) value, PersonRole.Translator), + FilterField.Characters => query.HasPeople(true, statement.Comparison, (IList) value, PersonRole.Character), + FilterField.Publisher => query.HasPeople(true, statement.Comparison, (IList) value, PersonRole.Publisher), + FilterField.Editor => query.HasPeople(true, statement.Comparison, (IList) value, PersonRole.Editor), + FilterField.CoverArtist => query.HasPeople(true, statement.Comparison, (IList) value, PersonRole.CoverArtist), + FilterField.Letterer => query.HasPeople(true, statement.Comparison, (IList) value, PersonRole.Letterer), + FilterField.Colorist => query.HasPeople(true, statement.Comparison, (IList) value, PersonRole.Inker), + FilterField.Inker => query.HasPeople(true, statement.Comparison, (IList) value, PersonRole.Inker), + FilterField.Imprint => query.HasPeople(true, statement.Comparison, (IList) value, PersonRole.Imprint), + FilterField.Team => query.HasPeople(true, statement.Comparison, (IList) value, PersonRole.Team), + FilterField.Location => query.HasPeople(true, statement.Comparison, (IList) value, PersonRole.Location), + FilterField.Penciller => query.HasPeople(true, statement.Comparison, (IList) value, PersonRole.Penciller), + FilterField.Writers => query.HasPeople(true, statement.Comparison, (IList) value, PersonRole.Writer), FilterField.Genres => query.HasGenre(true, statement.Comparison, (IList) value), FilterField.CollectionTags => // This is handled in the code before this as it's handled in a more general, combined manner diff --git a/API/Entities/AppUserRating.cs b/API/Entities/AppUserRating.cs index 91734b445..5d66a06e4 100644 --- a/API/Entities/AppUserRating.cs +++ b/API/Entities/AppUserRating.cs @@ -1,4 +1,6 @@  +using System; + namespace API.Entities; #nullable enable public class AppUserRating @@ -9,7 +11,7 @@ public class AppUserRating /// public float Rating { get; set; } /// - /// If the rating has been explicitly set. Otherwise the 0.0 rating should be ignored as it's not rated + /// If the rating has been explicitly set. Otherwise, the 0.0 rating should be ignored as it's not rated /// public bool HasBeenRated { get; set; } /// @@ -19,6 +21,7 @@ public class AppUserRating /// /// An optional tagline for the review /// + [Obsolete("No longer used")] public string? Tagline { get; set; } public int SeriesId { get; set; } public Series Series { get; set; } = null!; diff --git a/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs b/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs index ce1a9700b..c5b044665 100644 --- a/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs +++ b/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs @@ -43,6 +43,7 @@ public static class SeriesFilter case FilterComparison.IsAfter: case FilterComparison.IsInLast: case FilterComparison.IsNotInLast: + case FilterComparison.IsEmpty: default: throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); } @@ -71,6 +72,8 @@ public static class SeriesFilter return queryable.Where(s => s.Metadata.ReleaseYear >= DateTime.Now.Year - (int) releaseYear); case FilterComparison.IsNotInLast: return queryable.Where(s => s.Metadata.ReleaseYear < DateTime.Now.Year - (int) releaseYear); + case FilterComparison.IsEmpty: + return queryable.Where(s => s.Metadata.ReleaseYear == 0); case FilterComparison.Matches: case FilterComparison.Contains: case FilterComparison.NotContains: @@ -86,14 +89,20 @@ public static class SeriesFilter public static IQueryable HasRating(this IQueryable queryable, bool condition, - FilterComparison comparison, int rating, int userId) + FilterComparison comparison, float rating, int userId) { if (rating < 0 || !condition || userId <= 0) return queryable; + // Users see rating as %, so they are likely to pass 10%. We need to turn that into the underlying float encoding + if (rating.IsNot(0f)) + { + rating /= 100f; + } + switch (comparison) { case FilterComparison.Equal: - return queryable.Where(s => s.Ratings.Any(r => Math.Abs(r.Rating - rating) < FloatingPointTolerance && r.AppUserId == userId)); + return queryable.Where(s => s.Ratings.Any(r => Math.Abs(r.Rating - rating) <= FloatingPointTolerance && r.AppUserId == userId)); case FilterComparison.GreaterThan: return queryable.Where(s => s.Ratings.Any(r => r.Rating > rating && r.AppUserId == userId)); case FilterComparison.GreaterThanEqual: @@ -102,10 +111,13 @@ public static class SeriesFilter return queryable.Where(s => s.Ratings.Any(r => r.Rating < rating && r.AppUserId == userId)); case FilterComparison.LessThanEqual: return queryable.Where(s => s.Ratings.Any(r => r.Rating <= rating && r.AppUserId == userId)); + case FilterComparison.NotEqual: + return queryable.Where(s => s.Ratings.Any(r => Math.Abs(r.Rating - rating) >= FloatingPointTolerance && r.AppUserId == userId)); + case FilterComparison.IsEmpty: + return queryable.Where(s => s.Ratings.All(r => r.AppUserId != userId)); case FilterComparison.Contains: case FilterComparison.Matches: case FilterComparison.NotContains: - case FilterComparison.NotEqual: case FilterComparison.BeginsWith: case FilterComparison.EndsWith: case FilterComparison.IsBefore: @@ -124,7 +136,7 @@ public static class SeriesFilter { if (!condition || ratings.Count == 0) return queryable; - var firstRating = ratings.First(); + var firstRating = ratings[0]; switch (comparison) { case FilterComparison.Equal: @@ -151,6 +163,7 @@ public static class SeriesFilter case FilterComparison.IsInLast: case FilterComparison.IsNotInLast: case FilterComparison.MustContains: + case FilterComparison.IsEmpty: throw new KavitaException($"{comparison} not applicable for Series.AgeRating"); default: throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); @@ -185,6 +198,7 @@ public static class SeriesFilter case FilterComparison.IsInLast: case FilterComparison.IsNotInLast: case FilterComparison.MustContains: + case FilterComparison.IsEmpty: throw new KavitaException($"{comparison} not applicable for Series.AverageReadTime"); default: throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); @@ -196,7 +210,7 @@ public static class SeriesFilter { if (!condition || pubStatues.Count == 0) return queryable; - var firstStatus = pubStatues.First(); + var firstStatus = pubStatues[0]; switch (comparison) { case FilterComparison.Equal: @@ -219,6 +233,7 @@ public static class SeriesFilter case FilterComparison.IsInLast: case FilterComparison.IsNotInLast: case FilterComparison.Matches: + case FilterComparison.IsEmpty: throw new KavitaException($"{comparison} not applicable for Series.PublicationStatus"); default: throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); @@ -269,6 +284,7 @@ public static class SeriesFilter case FilterComparison.NotEqual: subQuery = subQuery.Where(s => Math.Abs(s.Percentage - readProgress) > FloatingPointTolerance); break; + case FilterComparison.IsEmpty: case FilterComparison.Matches: case FilterComparison.Contains: case FilterComparison.NotContains: @@ -293,6 +309,7 @@ public static class SeriesFilter { if (!condition) return queryable; + var subQuery = queryable .Where(s => s.ExternalSeriesMetadata != null) .Include(s => s.ExternalSeriesMetadata) @@ -334,6 +351,7 @@ public static class SeriesFilter case FilterComparison.IsInLast: case FilterComparison.IsNotInLast: case FilterComparison.MustContains: + case FilterComparison.IsEmpty: throw new KavitaException($"{comparison} not applicable for Series.AverageRating"); default: throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); @@ -393,6 +411,7 @@ public static class SeriesFilter case FilterComparison.IsInLast: case FilterComparison.IsNotInLast: case FilterComparison.MustContains: + case FilterComparison.IsEmpty: throw new KavitaException($"{comparison} not applicable for Series.ReadProgress"); default: throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); @@ -424,6 +443,8 @@ public static class SeriesFilter queries.AddRange(tags.Select(gId => queryable.Where(s => s.Metadata.Tags.Any(p => p.Id == gId)))); return queries.Aggregate((q1, q2) => q1.Intersect(q2)); + case FilterComparison.IsEmpty: + return queryable.Where(s => s.Metadata.Tags == null || s.Metadata.Tags.Count == 0); case FilterComparison.GreaterThan: case FilterComparison.GreaterThanEqual: case FilterComparison.LessThan: @@ -442,6 +463,48 @@ public static class SeriesFilter } public static IQueryable HasPeople(this IQueryable queryable, bool condition, + FilterComparison comparison, IList people, PersonRole role) + { + if (!condition || (comparison != FilterComparison.IsEmpty && people.Count == 0)) return queryable; + + switch (comparison) + { + case FilterComparison.Equal: + case FilterComparison.Contains: + return queryable.Where(s => s.Metadata.People.Any(p => people.Contains(p.Id))); + case FilterComparison.NotEqual: + case FilterComparison.NotContains: + return queryable.Where(s => s.Metadata.People.All(t => !people.Contains(t.Id))); + case FilterComparison.MustContains: + // Deconstruct and do a Union of a bunch of where statements since this doesn't translate + var queries = new List>() + { + queryable + }; + queries.AddRange(people.Select(gId => queryable.Where(s => s.Metadata.People.Any(p => p.Id == gId)))); + + return queries.Aggregate((q1, q2) => q1.Intersect(q2)); + case FilterComparison.IsEmpty: + // Check if there are no people with specific roles (e.g., Writer, Penciller, etc.) + return queryable.Where(s => !s.Metadata.People.Any(p => p.Role == role)); + case FilterComparison.GreaterThan: + case FilterComparison.GreaterThanEqual: + case FilterComparison.LessThan: + case FilterComparison.LessThanEqual: + case FilterComparison.BeginsWith: + case FilterComparison.EndsWith: + case FilterComparison.IsBefore: + case FilterComparison.IsAfter: + case FilterComparison.IsInLast: + case FilterComparison.IsNotInLast: + case FilterComparison.Matches: + throw new KavitaException($"{comparison} not applicable for Series.People"); + default: + throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); + } + } + + public static IQueryable HasPeopleLegacy(this IQueryable queryable, bool condition, FilterComparison comparison, IList people) { if (!condition || people.Count == 0) return queryable; @@ -463,6 +526,7 @@ public static class SeriesFilter queries.AddRange(people.Select(gId => queryable.Where(s => s.Metadata.People.Any(p => p.Id == gId)))); return queries.Aggregate((q1, q2) => q1.Intersect(q2)); + case FilterComparison.IsEmpty: case FilterComparison.GreaterThan: case FilterComparison.GreaterThanEqual: case FilterComparison.LessThan: @@ -502,6 +566,8 @@ public static class SeriesFilter queries.AddRange(genres.Select(gId => queryable.Where(s => s.Metadata.Genres.Any(p => p.Id == gId)))); return queries.Aggregate((q1, q2) => q1.Intersect(q2)); + case FilterComparison.IsEmpty: + return queryable.Where(s => s.Metadata.Genres.Count == 0); case FilterComparison.GreaterThan: case FilterComparison.GreaterThanEqual: case FilterComparison.LessThan: @@ -544,6 +610,7 @@ public static class SeriesFilter case FilterComparison.IsAfter: case FilterComparison.IsInLast: case FilterComparison.IsNotInLast: + case FilterComparison.IsEmpty: throw new KavitaException($"{comparison} not applicable for Series.Format"); default: throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); @@ -573,6 +640,8 @@ public static class SeriesFilter queries.AddRange(collectionSeries.Select(gId => queryable.Where(s => collectionSeries.Any(p => p == s.Id)))); return queries.Aggregate((q1, q2) => q1.Intersect(q2)); + case FilterComparison.IsEmpty: + return queryable.Where(s => collectionSeries.All(c => c != s.Id)); case FilterComparison.GreaterThan: case FilterComparison.GreaterThanEqual: case FilterComparison.LessThan: @@ -633,6 +702,7 @@ public static class SeriesFilter case FilterComparison.IsInLast: case FilterComparison.IsNotInLast: case FilterComparison.MustContains: + case FilterComparison.IsEmpty: throw new KavitaException($"{comparison} not applicable for Series.Name"); default: throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported"); @@ -656,6 +726,8 @@ public static class SeriesFilter return queryable.Where(s => EF.Functions.Like(s.Metadata.Summary, $"%{queryString}%")); case FilterComparison.NotEqual: return queryable.Where(s => s.Metadata.Summary != queryString); + case FilterComparison.IsEmpty: + return queryable.Where(s => string.IsNullOrEmpty(s.Metadata.Summary)); case FilterComparison.NotContains: case FilterComparison.GreaterThan: case FilterComparison.GreaterThanEqual: @@ -703,6 +775,7 @@ public static class SeriesFilter case FilterComparison.IsInLast: case FilterComparison.IsNotInLast: case FilterComparison.MustContains: + case FilterComparison.IsEmpty: throw new KavitaException($"{comparison} not applicable for Series.FolderPath"); default: throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported"); @@ -779,6 +852,7 @@ public static class SeriesFilter case FilterComparison.IsInLast: case FilterComparison.IsNotInLast: case FilterComparison.MustContains: + case FilterComparison.IsEmpty: throw new KavitaException($"{comparison} not applicable for Series.FolderPath"); default: throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported"); diff --git a/API/Helpers/Converters/FilterFieldValueConverter.cs b/API/Helpers/Converters/FilterFieldValueConverter.cs index 448feb943..46012f256 100644 --- a/API/Helpers/Converters/FilterFieldValueConverter.cs +++ b/API/Helpers/Converters/FilterFieldValueConverter.cs @@ -18,75 +18,94 @@ public static class FilterFieldValueConverter FilterField.SeriesName => value, FilterField.Path => value, FilterField.FilePath => value, - FilterField.ReleaseYear => int.Parse(value), + FilterField.ReleaseYear => string.IsNullOrEmpty(value) ? 0 : int.Parse(value), FilterField.Languages => value.Split(',').ToList(), FilterField.PublicationStatus => value.Split(',') + .Where(s => !string.IsNullOrEmpty(s)) .Select(x => (PublicationStatus) Enum.Parse(typeof(PublicationStatus), x)) .ToList(), FilterField.Summary => value, FilterField.AgeRating => value.Split(',') + .Where(s => !string.IsNullOrEmpty(s)) .Select(x => (AgeRating) Enum.Parse(typeof(AgeRating), x)) .ToList(), - FilterField.UserRating => int.Parse(value), + FilterField.UserRating => string.IsNullOrEmpty(value) ? 0 : float.Parse(value), FilterField.Tags => value.Split(',') + .Where(s => !string.IsNullOrEmpty(s)) .Select(int.Parse) .ToList(), FilterField.CollectionTags => value.Split(',') + .Where(s => !string.IsNullOrEmpty(s)) .Select(int.Parse) .ToList(), FilterField.Translators => value.Split(',') + .Where(s => !string.IsNullOrEmpty(s)) .Select(int.Parse) .ToList(), FilterField.Characters => value.Split(',') + .Where(s => !string.IsNullOrEmpty(s)) .Select(int.Parse) .ToList(), FilterField.Publisher => value.Split(',') + .Where(s => !string.IsNullOrEmpty(s)) .Select(int.Parse) .ToList(), FilterField.Editor => value.Split(',') + .Where(s => !string.IsNullOrEmpty(s)) .Select(int.Parse) .ToList(), FilterField.CoverArtist => value.Split(',') + .Where(s => !string.IsNullOrEmpty(s)) .Select(int.Parse) .ToList(), FilterField.Letterer => value.Split(',') + .Where(s => !string.IsNullOrEmpty(s)) .Select(int.Parse) .ToList(), FilterField.Colorist => value.Split(',') + .Where(s => !string.IsNullOrEmpty(s)) .Select(int.Parse) .ToList(), FilterField.Inker => value.Split(',') + .Where(s => !string.IsNullOrEmpty(s)) .Select(int.Parse) .ToList(), FilterField.Imprint => value.Split(',') + .Where(s => !string.IsNullOrEmpty(s)) .Select(int.Parse) .ToList(), FilterField.Team => value.Split(',') + .Where(s => !string.IsNullOrEmpty(s)) .Select(int.Parse) .ToList(), FilterField.Location => value.Split(',') + .Where(s => !string.IsNullOrEmpty(s)) .Select(int.Parse) .ToList(), FilterField.Penciller => value.Split(',') + .Where(s => !string.IsNullOrEmpty(s)) .Select(int.Parse) .ToList(), FilterField.Writers => value.Split(',') + .Where(s => !string.IsNullOrEmpty(s)) .Select(int.Parse) .ToList(), FilterField.Genres => value.Split(',') + .Where(s => !string.IsNullOrEmpty(s)) .Select(int.Parse) .ToList(), FilterField.Libraries => value.Split(',') + .Where(s => !string.IsNullOrEmpty(s)) .Select(int.Parse) .ToList(), FilterField.WantToRead => bool.Parse(value), - FilterField.ReadProgress => value.AsFloat(), + FilterField.ReadProgress => string.IsNullOrEmpty(value) ? 0f : value.AsFloat(), FilterField.ReadingDate => DateTime.Parse(value), FilterField.Formats => value.Split(',') .Select(x => (MangaFormat) Enum.Parse(typeof(MangaFormat), x)) .ToList(), - FilterField.ReadTime => int.Parse(value), - FilterField.AverageRating => value.AsFloat(), + FilterField.ReadTime => string.IsNullOrEmpty(value) ? 0 : int.Parse(value), + FilterField.AverageRating => string.IsNullOrEmpty(value) ? 0f : value.AsFloat(), _ => throw new ArgumentException("Invalid field type") }; } diff --git a/API/Services/DirectoryService.cs b/API/Services/DirectoryService.cs index b290573e2..c19914668 100644 --- a/API/Services/DirectoryService.cs +++ b/API/Services/DirectoryService.cs @@ -94,7 +94,7 @@ public class DirectoryService : IDirectoryService private const RegexOptions MatchOptions = RegexOptions.Compiled | RegexOptions.IgnoreCase; private static readonly Regex ExcludeDirectories = new Regex( - @"@eaDir|\.DS_Store|\.qpkg|__MACOSX|@Recently-Snapshot|@recycle|\.@__thumb|\.caltrash|#recycle", + @"@eaDir|\.DS_Store|\.qpkg|__MACOSX|@Recently-Snapshot|@recycle|\.@__thumb|\.caltrash|#recycle|\.yacreaderlibrary", MatchOptions, Tasks.Scanner.Parser.Parser.RegexTimeout); private static readonly Regex FileCopyAppend = new Regex(@"\(\d+\)", diff --git a/API/Services/StatisticService.cs b/API/Services/StatisticService.cs index b2c5cbaeb..350aa613c 100644 --- a/API/Services/StatisticService.cs +++ b/API/Services/StatisticService.cs @@ -69,7 +69,8 @@ public class StatisticService : IStatisticService var totalPagesRead = await _context.AppUserProgresses .Where(p => p.AppUserId == userId) .Where(p => libraryIds.Contains(p.LibraryId)) - .SumAsync(p => p.PagesRead); + .Select(p => (int?) p.PagesRead) + .SumAsync() ?? 0; var timeSpentReading = await TimeSpentReadingForUsersAsync(new List() {userId}, libraryIds); diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index cda8f68dd..18133c88c 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -37,7 +37,7 @@ public interface ITaskScheduler void CovertAllCoversToEncoding(); Task CleanupDbEntries(); Task CheckForUpdate(); - + Task SyncThemes(); } public class TaskScheduler : ITaskScheduler { @@ -165,8 +165,8 @@ public class TaskScheduler : ITaskScheduler RecurringJob.AddOrUpdate(UpdateYearlyStatsTaskId, () => _statisticService.UpdateServerStatistics(), Cron.Monthly, RecurringJobOptions); - RecurringJob.AddOrUpdate(SyncThemesTaskId, () => _themeService.SyncThemes(), - Cron.Weekly, RecurringJobOptions); + RecurringJob.AddOrUpdate(SyncThemesTaskId, () => SyncThemes(), + Cron.Daily, RecurringJobOptions); await ScheduleKavitaPlusTasks(); } @@ -444,6 +444,11 @@ public class TaskScheduler : ITaskScheduler await _versionUpdaterService.PushUpdate(update); } + public async Task SyncThemes() + { + await _themeService.SyncThemes(); + } + /// /// If there is an enqueued or scheduled task for method /// diff --git a/API/Services/Tasks/Scanner/Parser/Parser.cs b/API/Services/Tasks/Scanner/Parser/Parser.cs index 840e7a6d8..56ed6fe16 100644 --- a/API/Services/Tasks/Scanner/Parser/Parser.cs +++ b/API/Services/Tasks/Scanner/Parser/Parser.cs @@ -1152,6 +1152,7 @@ public static class Parser return path.Contains("__MACOSX") || path.StartsWith("@Recently-Snapshot") || path.StartsWith("@recycle") || path.StartsWith("._") || Path.GetFileName(path).StartsWith("._") || path.Contains(".qpkg") || path.StartsWith("#recycle") + || path.Contains(".yacreaderlibrary") || path.Contains(".caltrash"); } diff --git a/API/Services/Tasks/SiteThemeService.cs b/API/Services/Tasks/SiteThemeService.cs index b2f81363d..aaa06a846 100644 --- a/API/Services/Tasks/SiteThemeService.cs +++ b/API/Services/Tasks/SiteThemeService.cs @@ -120,7 +120,11 @@ public class ThemeService : IThemeService public async Task> GetDownloadableThemes() { const string cacheKey = "browse"; - var existingThemes = (await _unitOfWork.SiteThemeRepository.GetThemeDtos()).ToDictionary(k => k.Name); + // Avoid a duplicate Dark issue some users faced during migration + var existingThemes = (await _unitOfWork.SiteThemeRepository.GetThemeDtos()) + .GroupBy(k => k.Name) + .ToDictionary(g => g.Key, g => g.First()); + if (_cache.TryGetValue(cacheKey, out List? themes) && themes != null) { foreach (var t in themes) @@ -204,6 +208,13 @@ public class ThemeService : IThemeService /// private async Task> GetReadme() { + // Try and delete a Readme file if it already exists + var existingReadmeFile = _directoryService.FileSystem.Path.Join(_directoryService.TempDirectory, "README.md"); + if (_directoryService.FileSystem.File.Exists(existingReadmeFile)) + { + _directoryService.DeleteFiles([existingReadmeFile]); + } + var tempDownloadFile = await GithubReadme.DownloadFileAsync(_directoryService.TempDirectory); // Read file into Markdown diff --git a/API/config/appsettings.Development.json b/API/config/appsettings.Development.json index 0c6352d06..83a13e7ce 100644 --- a/API/config/appsettings.Development.json +++ b/API/config/appsettings.Development.json @@ -2,7 +2,7 @@ "TokenKey": "super secret unguessable key that is longer because we require it", "Port": 5000, "IpAddresses": "0.0.0.0,::", - "BaseUrl": "/", + "BaseUrl": "/test/", "Cache": 75, "AllowIFraming": false } \ No newline at end of file diff --git a/UI/Web/src/_card-item-common.scss b/UI/Web/src/_card-item-common.scss index f418ed5bd..093b1ddd9 100644 --- a/UI/Web/src/_card-item-common.scss +++ b/UI/Web/src/_card-item-common.scss @@ -149,9 +149,6 @@ $image-width: 160px; } .card-actions { - position: absolute; - bottom: 0; - right: 0; z-index: 115; } @@ -161,8 +158,41 @@ $image-width: 160px; margin-top: 0px; } +.card-title-container { + display: flex; + justify-content: end; + align-items: center; + padding: 0 5px; + + .card-title { + font-size: 0.8rem; + margin: 0; + padding: 10px 0; + text-align: center; + max-width: 120px; + + a { + overflow: hidden; + text-overflow: ellipsis; + } + } +} + +::ng-deep app-card-actionables .dropdown .dropdown-toggle { + padding: 0 5px; +} + .card-title { font-size: 0.8rem; + margin: 0; + padding: 10px; + text-align: center; + max-width: 120px; + + a { + overflow: hidden; + text-overflow: ellipsis; + } } .card-body > div:nth-child(2) { diff --git a/UI/Web/src/app/_models/metadata/v2/filter-comparison.ts b/UI/Web/src/app/_models/metadata/v2/filter-comparison.ts index fa30dc786..2dafc0e48 100644 --- a/UI/Web/src/app/_models/metadata/v2/filter-comparison.ts +++ b/UI/Web/src/app/_models/metadata/v2/filter-comparison.ts @@ -43,4 +43,5 @@ export enum FilterComparison { /// Is Date not between now and X seconds ago /// IsNotInLast = 15, + IsEmpty = 16 } diff --git a/UI/Web/src/app/_models/metadata/v2/query-context.ts b/UI/Web/src/app/_models/metadata/v2/query-context.ts new file mode 100644 index 000000000..63a5c0032 --- /dev/null +++ b/UI/Web/src/app/_models/metadata/v2/query-context.ts @@ -0,0 +1,7 @@ +export enum QueryContext +{ + None = 1, + Search = 2, + Recommended = 3, + Dashboard = 4, +} diff --git a/UI/Web/src/app/_pipes/book-page-layout-mode.pipe.ts b/UI/Web/src/app/_pipes/book-page-layout-mode.pipe.ts index f56e31194..41758552f 100644 --- a/UI/Web/src/app/_pipes/book-page-layout-mode.pipe.ts +++ b/UI/Web/src/app/_pipes/book-page-layout-mode.pipe.ts @@ -1,6 +1,7 @@ import { Pipe, PipeTransform } from '@angular/core'; import {translate} from "@jsverse/transloco"; import {BookPageLayoutMode} from "../_models/readers/book-page-layout-mode"; +import {ScalingOption} from "../_models/preferences/scaling-option"; @Pipe({ name: 'bookPageLayoutMode', @@ -9,7 +10,8 @@ import {BookPageLayoutMode} from "../_models/readers/book-page-layout-mode"; export class BookPageLayoutModePipe implements PipeTransform { transform(value: BookPageLayoutMode): string { - switch (value) { + const v = parseInt(value + '', 10) as BookPageLayoutMode; + switch (v) { case BookPageLayoutMode.Column1: return translate('preferences.1-column'); case BookPageLayoutMode.Column2: return translate('preferences.2-column'); case BookPageLayoutMode.Default: return translate('preferences.scroll'); diff --git a/UI/Web/src/app/_pipes/filter-comparison.pipe.ts b/UI/Web/src/app/_pipes/filter-comparison.pipe.ts index df4600a38..6af542d80 100644 --- a/UI/Web/src/app/_pipes/filter-comparison.pipe.ts +++ b/UI/Web/src/app/_pipes/filter-comparison.pipe.ts @@ -42,6 +42,8 @@ export class FilterComparisonPipe implements PipeTransform { return translate('filter-comparison-pipe.is-not-in-last'); case FilterComparison.MustContains: return translate('filter-comparison-pipe.must-contains'); + case FilterComparison.IsEmpty: + return translate('filter-comparison-pipe.is-empty'); default: throw new Error(`Invalid FilterComparison value: ${value}`); } diff --git a/UI/Web/src/app/_pipes/layout-mode.pipe.ts b/UI/Web/src/app/_pipes/layout-mode.pipe.ts index 1e0e51c84..ab598a7f4 100644 --- a/UI/Web/src/app/_pipes/layout-mode.pipe.ts +++ b/UI/Web/src/app/_pipes/layout-mode.pipe.ts @@ -1,6 +1,7 @@ import { Pipe, PipeTransform } from '@angular/core'; import {translate} from "@jsverse/transloco"; import {LayoutMode} from "../manga-reader/_models/layout-mode"; +import {ScalingOption} from "../_models/preferences/scaling-option"; @Pipe({ name: 'layoutMode', @@ -9,7 +10,8 @@ import {LayoutMode} from "../manga-reader/_models/layout-mode"; export class LayoutModePipe implements PipeTransform { transform(value: LayoutMode): string { - switch (value) { + const v = parseInt(value + '', 10) as LayoutMode; + switch (v) { case LayoutMode.Single: return translate('preferences.single'); case LayoutMode.Double: return translate('preferences.double'); case LayoutMode.DoubleReversed: return translate('preferences.double-manga'); diff --git a/UI/Web/src/app/_pipes/page-split-option.pipe.ts b/UI/Web/src/app/_pipes/page-split-option.pipe.ts index 0720a9a9b..da6251f72 100644 --- a/UI/Web/src/app/_pipes/page-split-option.pipe.ts +++ b/UI/Web/src/app/_pipes/page-split-option.pipe.ts @@ -1,6 +1,7 @@ import { Pipe, PipeTransform } from '@angular/core'; import {translate} from "@jsverse/transloco"; import {PageSplitOption} from "../_models/preferences/page-split-option"; +import {ScalingOption} from "../_models/preferences/scaling-option"; @Pipe({ name: 'pageSplitOption', @@ -9,7 +10,8 @@ import {PageSplitOption} from "../_models/preferences/page-split-option"; export class PageSplitOptionPipe implements PipeTransform { transform(value: PageSplitOption): string { - switch (value) { + const v = parseInt(value + '', 10) as PageSplitOption; + switch (v) { case PageSplitOption.FitSplit: return translate('preferences.fit-to-screen'); case PageSplitOption.NoSplit: return translate('preferences.no-split'); case PageSplitOption.SplitLeftToRight: return translate('preferences.split-left-to-right'); diff --git a/UI/Web/src/app/_pipes/pdf-scroll-mode.pipe.ts b/UI/Web/src/app/_pipes/pdf-scroll-mode.pipe.ts index 71ea22b1f..d395911d6 100644 --- a/UI/Web/src/app/_pipes/pdf-scroll-mode.pipe.ts +++ b/UI/Web/src/app/_pipes/pdf-scroll-mode.pipe.ts @@ -9,7 +9,8 @@ import {PdfScrollMode} from "../_models/preferences/pdf-scroll-mode"; export class PdfScrollModePipe implements PipeTransform { transform(value: PdfScrollMode): string { - switch (value) { + const v = parseInt(value + '', 10) as PdfScrollMode; + switch (v) { case PdfScrollMode.Wrapped: return translate('preferences.pdf-multiple'); case PdfScrollMode.Page: return translate('preferences.pdf-page'); case PdfScrollMode.Horizontal: return translate('preferences.pdf-horizontal'); diff --git a/UI/Web/src/app/_pipes/pdf-spread-mode.pipe.ts b/UI/Web/src/app/_pipes/pdf-spread-mode.pipe.ts index 88b9b1c3f..2f02363a3 100644 --- a/UI/Web/src/app/_pipes/pdf-spread-mode.pipe.ts +++ b/UI/Web/src/app/_pipes/pdf-spread-mode.pipe.ts @@ -1,6 +1,7 @@ import { Pipe, PipeTransform } from '@angular/core'; import {PdfSpreadMode} from "../_models/preferences/pdf-spread-mode"; import {translate} from "@jsverse/transloco"; +import {ScalingOption} from "../_models/preferences/scaling-option"; @Pipe({ name: 'pdfSpreadMode', @@ -9,7 +10,8 @@ import {translate} from "@jsverse/transloco"; export class PdfSpreadModePipe implements PipeTransform { transform(value: PdfSpreadMode): string { - switch (value) { + const v = parseInt(value + '', 10) as PdfSpreadMode; + switch (v) { case PdfSpreadMode.None: return translate('preferences.pdf-none'); case PdfSpreadMode.Odd: return translate('preferences.pdf-odd'); case PdfSpreadMode.Even: return translate('preferences.pdf-even'); diff --git a/UI/Web/src/app/_pipes/pdf-theme.pipe.ts b/UI/Web/src/app/_pipes/pdf-theme.pipe.ts index 7fb0c2d7e..4e64d85e8 100644 --- a/UI/Web/src/app/_pipes/pdf-theme.pipe.ts +++ b/UI/Web/src/app/_pipes/pdf-theme.pipe.ts @@ -1,6 +1,7 @@ import { Pipe, PipeTransform } from '@angular/core'; import {PdfTheme} from "../_models/preferences/pdf-theme"; import {translate} from "@jsverse/transloco"; +import {ScalingOption} from "../_models/preferences/scaling-option"; @Pipe({ name: 'pdfTheme', @@ -9,7 +10,8 @@ import {translate} from "@jsverse/transloco"; export class PdfThemePipe implements PipeTransform { transform(value: PdfTheme): string { - switch (value) { + const v = parseInt(value + '', 10) as PdfTheme; + switch (v) { case PdfTheme.Dark: return translate('preferences.pdf-dark'); case PdfTheme.Light: return translate('preferences.pdf-light'); } diff --git a/UI/Web/src/app/_pipes/reading-direction.pipe.ts b/UI/Web/src/app/_pipes/reading-direction.pipe.ts index 7626a6cab..d9a4097b0 100644 --- a/UI/Web/src/app/_pipes/reading-direction.pipe.ts +++ b/UI/Web/src/app/_pipes/reading-direction.pipe.ts @@ -9,7 +9,8 @@ import {translate} from "@jsverse/transloco"; export class ReadingDirectionPipe implements PipeTransform { transform(value: ReadingDirection): string { - switch (value) { + const v = parseInt(value + '', 10) as ReadingDirection; + switch (v) { case ReadingDirection.LeftToRight: return translate('preferences.left-to-right'); case ReadingDirection.RightToLeft: return translate('preferences.right-to-left'); } diff --git a/UI/Web/src/app/_pipes/reading-mode.pipe.ts b/UI/Web/src/app/_pipes/reading-mode.pipe.ts index f38f0779a..0404e0ffb 100644 --- a/UI/Web/src/app/_pipes/reading-mode.pipe.ts +++ b/UI/Web/src/app/_pipes/reading-mode.pipe.ts @@ -1,6 +1,7 @@ import { Pipe, PipeTransform } from '@angular/core'; import {ReaderMode} from "../_models/preferences/reader-mode"; import {translate} from "@jsverse/transloco"; +import {ScalingOption} from "../_models/preferences/scaling-option"; @Pipe({ name: 'readerMode', @@ -9,7 +10,8 @@ import {translate} from "@jsverse/transloco"; export class ReaderModePipe implements PipeTransform { transform(value: ReaderMode): string { - switch (value) { + const v = parseInt(value + '', 10) as ReaderMode; + switch (v) { case ReaderMode.UpDown: return translate('preferences.up-to-down'); case ReaderMode.Webtoon: return translate('preferences.webtoon'); case ReaderMode.LeftRight: return translate('preferences.left-to-right'); diff --git a/UI/Web/src/app/_pipes/scaling-option.pipe.ts b/UI/Web/src/app/_pipes/scaling-option.pipe.ts index 6dc25c2ca..d2124be7f 100644 --- a/UI/Web/src/app/_pipes/scaling-option.pipe.ts +++ b/UI/Web/src/app/_pipes/scaling-option.pipe.ts @@ -1,6 +1,7 @@ import { Pipe, PipeTransform } from '@angular/core'; import {translate} from "@jsverse/transloco"; import {ScalingOption} from "../_models/preferences/scaling-option"; +import {ReadingDirection} from "../_models/preferences/reading-direction"; @Pipe({ name: 'scalingOption', @@ -9,7 +10,8 @@ import {ScalingOption} from "../_models/preferences/scaling-option"; export class ScalingOptionPipe implements PipeTransform { transform(value: ScalingOption): string { - switch (value) { + const v = parseInt(value + '', 10) as ScalingOption; + switch (v) { case ScalingOption.Automatic: return translate('preferences.automatic'); case ScalingOption.FitToHeight: return translate('preferences.fit-to-height'); case ScalingOption.FitToWidth: return translate('preferences.fit-to-width'); diff --git a/UI/Web/src/app/_pipes/utc-to-local-time.pipe.ts b/UI/Web/src/app/_pipes/utc-to-local-time.pipe.ts index 22c8c4639..42bac615c 100644 --- a/UI/Web/src/app/_pipes/utc-to-local-time.pipe.ts +++ b/UI/Web/src/app/_pipes/utc-to-local-time.pipe.ts @@ -16,7 +16,10 @@ type UtcToLocalTimeFormat = 'full' | 'short' | 'shortDate' | 'shortTime'; export class UtcToLocalTimePipe implements PipeTransform { transform(utcDate: string | undefined | null, format: UtcToLocalTimeFormat = 'short'): string { - if (utcDate === undefined || utcDate === null) return ''; + if (utcDate === '' || utcDate === null || utcDate === undefined || utcDate.split('T')[0] === '0001-01-01') { + return ''; + } + const browserLanguage = navigator.language; const dateTime = DateTime.fromISO(utcDate, { zone: 'utc' }).toLocal().setLocale(browserLanguage); diff --git a/UI/Web/src/app/_pipes/writing-style.pipe.ts b/UI/Web/src/app/_pipes/writing-style.pipe.ts index 8136595d6..140978950 100644 --- a/UI/Web/src/app/_pipes/writing-style.pipe.ts +++ b/UI/Web/src/app/_pipes/writing-style.pipe.ts @@ -1,6 +1,7 @@ import { Pipe, PipeTransform } from '@angular/core'; import {translate} from "@jsverse/transloco"; import {WritingStyle} from "../_models/preferences/writing-style"; +import {ScalingOption} from "../_models/preferences/scaling-option"; @Pipe({ name: 'writingStyle', @@ -9,7 +10,8 @@ import {WritingStyle} from "../_models/preferences/writing-style"; export class WritingStylePipe implements PipeTransform { transform(value: WritingStyle): string { - switch (value) { + const v = parseInt(value + '', 10) as WritingStyle; + switch (v) { case WritingStyle.Horizontal: return translate('preferences.horizontal'); case WritingStyle.Vertical: return translate('preferences.vertical'); } diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index f8c47441c..54b54931f 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -14,6 +14,7 @@ import { AgeRating } from '../_models/metadata/age-rating'; import { AgeRestriction } from '../_models/metadata/age-restriction'; import { TextResonse } from '../_types/text-response'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {Action} from "./action-factory.service"; export enum Role { Admin = 'Admin', @@ -78,6 +79,18 @@ export class AccountService { }); } + canInvokeAction(user: User, action: Action) { + const isAdmin = this.hasAdminRole(user); + const canDownload = this.hasDownloadRole(user); + const canPromote = this.hasPromoteRole(user); + + if (isAdmin) return true; + if (action === Action.Download) return canDownload; + if (action === Action.Promote || action === Action.UnPromote) return canPromote; + if (action === Action.Delete) return isAdmin; + return true; + } + hasAnyRole(user: User, roles: Array) { if (!user || !user.roles) { return false; @@ -168,7 +181,7 @@ export class AccountService { ); } - setCurrentUser(user?: User) { + setCurrentUser(user?: User, refreshConnections = true) { if (user) { user.roles = []; const roles = this.getDecodedToken(user.token).role; @@ -189,6 +202,8 @@ export class AccountService { this.currentUser = user; this.currentUserSource.next(user); + if (!refreshConnections) return; + this.stopRefreshTokenTimer(); if (this.currentUser) { @@ -311,7 +326,7 @@ export class AccountService { return this.httpClient.post(this.baseUrl + 'users/update-preferences', userPreferences).pipe(map(settings => { if (this.currentUser !== undefined && this.currentUser !== null) { this.currentUser.preferences = settings; - this.setCurrentUser(this.currentUser); + this.setCurrentUser(this.currentUser, false); // Update the locale on disk (for logout and compact-number pipe) localStorage.setItem(AccountService.localeKey, this.currentUser.preferences.locale); diff --git a/UI/Web/src/app/_services/action-factory.service.ts b/UI/Web/src/app/_services/action-factory.service.ts index 1bc5cf9e1..beda9209c 100644 --- a/UI/Web/src/app/_services/action-factory.service.ts +++ b/UI/Web/src/app/_services/action-factory.service.ts @@ -161,6 +161,7 @@ export class ActionFactoryService { isAdmin = false; + constructor(private accountService: AccountService, private deviceService: DeviceService) { this.accountService.currentUser$.subscribe((user) => { if (user) { diff --git a/UI/Web/src/app/_services/colorscape.service.ts b/UI/Web/src/app/_services/colorscape.service.ts index 1d72cced8..8632c8218 100644 --- a/UI/Web/src/app/_services/colorscape.service.ts +++ b/UI/Web/src/app/_services/colorscape.service.ts @@ -39,7 +39,7 @@ const colorScapeSelector = 'colorscape'; }) export class ColorscapeService { private colorSubject = new BehaviorSubject(null); - public colors$ = this.colorSubject.asObservable(); + public readonly colors$ = this.colorSubject.asObservable(); private minDuration = 1000; // minimum duration private maxDuration = 4000; // maximum duration diff --git a/UI/Web/src/app/_services/series.service.ts b/UI/Web/src/app/_services/series.service.ts index c5e46c75b..ba3fadde1 100644 --- a/UI/Web/src/app/_services/series.service.ts +++ b/UI/Web/src/app/_services/series.service.ts @@ -20,6 +20,7 @@ import {Rating} from "../_models/rating"; import {Recommendation} from "../_models/series-detail/recommendation"; import {ExternalSeriesDetail} from "../_models/series-detail/external-series-detail"; import {NextExpectedChapter} from "../_models/series-detail/next-expected-chapter"; +import {QueryContext} from "../_models/metadata/v2/query-context"; @Injectable({ providedIn: 'root' @@ -33,12 +34,12 @@ export class SeriesService { constructor(private httpClient: HttpClient, private imageService: ImageService, private utilityService: UtilityService) { } - getAllSeriesV2(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2) { + getAllSeriesV2(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2, context: QueryContext = QueryContext.None) { let params = new HttpParams(); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); const data = filter || {}; - return this.httpClient.post>(this.baseUrl + 'series/all-v2', data, {observe: 'response', params}).pipe( + return this.httpClient.post>(this.baseUrl + 'series/all-v2?context=' + context, data, {observe: 'response', params}).pipe( map((response: any) => { return this.utilityService.createPaginatedResult(response, this.paginatedResults); }) diff --git a/UI/Web/src/app/_services/server.service.ts b/UI/Web/src/app/_services/server.service.ts index a70ef5a07..617bc1eb1 100644 --- a/UI/Web/src/app/_services/server.service.ts +++ b/UI/Web/src/app/_services/server.service.ts @@ -17,6 +17,9 @@ export class ServerService { constructor(private http: HttpClient) { } + getVersion(apiKey: string) { + return this.http.get(this.baseUrl + 'plugin/version?apiKey=' + apiKey, TextResonse); + } getServerInfo() { return this.http.get(this.baseUrl + 'server/server-info-slim'); @@ -38,6 +41,10 @@ export class ServerService { return this.http.post(this.baseUrl + 'server/analyze-files', {}); } + syncThemes() { + return this.http.post(this.baseUrl + 'server/sync-themes', {}); + } + checkForUpdate() { return this.http.get(this.baseUrl + 'server/check-update'); } diff --git a/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.html b/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.html index c033b535b..ca84104d4 100644 --- a/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.html +++ b/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.html @@ -21,7 +21,7 @@ } @else { @if (shouldRenderSubMenu(action, action.children?.[0].dynamicList | async)) { -
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 6eb3a9c9a..5f96f206c 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 @@ -2,7 +2,7 @@

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

- + {{item.title}} @@ -11,7 +11,7 @@

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

- + {{item.title}} diff --git a/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.html b/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.html index 54561a254..62f9e4197 100644 --- a/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.html +++ b/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.html @@ -10,463 +10,478 @@
+ } + + +
-
-
-
- +
+ - - - {{item.title}} - - - {{item.title}} ({{item.isoCode}}) - - + @if (editForm.get('sortOrder'); as formControl) { +
+ + + @if (formControl.errors; as errors) { +
+ @if (errors.required) { +
{{t('required-field')}}
+ } +
+ } +
+ }
-
-
- +
+
+ -
- - -
+ @if (editForm.get('isbn'); as formControl) { +
+ + + @if (formControl.errors; as errors) { +
+ @if (errors.required) { +
{{t('required-field')}}
+ } +
+ } +
+ } +
+
+
+ +
+ + + @if (editForm.get('ageRating'); as formControl) { +
+ + +
+ }
-
-
-
- - - @if (editForm.get('summary'); as formControl) { -
- - -
- } -
-
+
+
+
+ + + + + {{item.title}} + + + {{item.title}} ({{item.isoCode}}) + + + + +
+
+ +
+
+ + +
+ + +
+
+
+
+
-
- - + +
+
+ + + @if (editForm.get('summary'); as formControl) { +
+ + +
+ } +
+
+
+
+ + + } + -
  • - {{t(TabID.Tags)}} - - -
    -
    -
    - - - - - {{item.title}} - - - {{item.title}} - - - - + @if (user && accountService.hasAdminRole(user)) + { +
  • + {{t(TabID.Tags)}} + + +
    +
    +
    + + + + + {{item.title}} + + + {{item.title}} + + + + +
    +
    + +
    +
    + + + + + {{item.name}} + + + {{item.name}} + + + + +
    -
    -
    - - - - - {{item.name}} - - - {{item.name}} - - - - + +
    +
    +
    + + + + + {{item.name}} + + + {{item.name}} + + + + +
    -
    -
    - -
    -
    -
    - - - - - {{item.name}} - - - {{item.name}} - - - - +
    +
    + + + + + {{item.name}} + + + {{item.name}} + + + + +
    -
    -
    - - - - - {{item.name}} - - - {{item.name}} - - - - + +
    +
    +
    + + + + + {{item.name}} + + + {{item.name}} + + + + +
    -
    -
    - -
    -
    -
    - - - - - {{item.name}} - - - {{item.name}} - - - - +
    +
    + + + + + {{item.name}} + + + {{item.name}} + + + + +
    -
    -
    - - - - - {{item.name}} - - - {{item.name}} - - - - + +
    +
    +
    + + + + + {{item.name}} + + + {{item.name}} + + + + +
    -
    - -
    -
    -
    - - - - - {{item.name}} - - - {{item.name}} - - - - -
    -
    -
    + +
  • + } - - -
  • - {{t(TabID.People)}} - - -
    -
    -
    - - - - - {{item.name}} - - - {{item.name}} - - - - + @if (user && accountService.hasAdminRole(user)) + { +
  • + {{t(TabID.People)}} + + +
    +
    +
    + + + + + {{item.name}} + + + {{item.name}} + + + + +
    +
    + +
    +
    + + + + + {{item.name}} + + + {{item.name}} + + + + +
    -
    -
    - - - - - {{item.name}} - - - {{item.name}} - - - - + +
    +
    +
    + + + + + {{item.name}} + + + {{item.name}} + + + + +
    -
    -
    - -
    -
    -
    - - - - - {{item.name}} - - - {{item.name}} - - - - +
    +
    + + + + + {{item.name}} + + + {{item.name}} + + + + +
    -
    -
    - - - - - {{item.name}} - - - {{item.name}} - - - - + +
    +
    +
    + + + + + {{item.name}} + + + {{item.name}} + + + + +
    -
    -
    - -
    -
    -
    - - - - - {{item.name}} - - - {{item.name}} - - - - +
    +
    + + + + + {{item.name}} + + + {{item.name}} + + + + +
    -
    -
    - - - - - {{item.name}} - - - {{item.name}} - - - - + +
    +
    +
    + + + + + {{item.name}} + + + {{item.name}} + + + + +
    -
    - -
    -
    -
    - - - - - {{item.name}} - - - {{item.name}} - - - - -
    -
    -
    + +
  • + } - - -
  • - {{t(TabID.CoverImage)}} - - - - -
  • + @if (user && accountService.hasAdminRole(user)) + { +
  • + {{t(TabID.CoverImage)}} + + + + +
  • + }
  • @@ -539,30 +554,34 @@ @if (WebLinks.length > 0) {
    -
    -
    - + +
    +
    +
    {{t('links-label')}}
    +
    @for(link of WebLinks; track link) { } - +
    } @if (accountService.isAdmin$ | async) { - - - @for (file of chapter.files; track file.id) { -
    - {{file.filePath}}{{file.bytes | bytes}} -
    - } -
    -
    +
    + + + @for (file of chapter.files; track file.id) { +
    + {{file.filePath}}{{file.bytes | bytes}} +
    + } +
    +
    +
    } @@ -581,11 +600,13 @@ {{t(TabID.Tasks)}} @for(task of tasks; track task.action) { -
    - - - -
    + @if (accountService.canInvokeAction(user, task.action)) { +
    + + + +
    + } }
  • 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 5a0b5bac4..00e3c0edf 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 @@ -36,7 +36,7 @@ import {ActionService} from "../../_services/action.service"; import {DownloadService} from "../../shared/_services/download.service"; import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component"; import {TypeaheadComponent} from "../../typeahead/_components/typeahead.component"; -import {forkJoin, Observable, of} from "rxjs"; +import {forkJoin, Observable, of, tap} from "rxjs"; import {map} from "rxjs/operators"; import {EntityTitleComponent} from "../../cards/entity-title/entity-title.component"; import {SettingButtonComponent} from "../../settings/_components/setting-button/setting-button.component"; @@ -55,6 +55,8 @@ import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe"; import {ReadTimePipe} from "../../_pipes/read-time.pipe"; import {ChapterService} from "../../_services/chapter.service"; import {AgeRating} from "../../_models/metadata/age-rating"; +import {User} from "../../_models/user"; +import {SettingTitleComponent} from "../../settings/_components/setting-title/setting-title.component"; enum TabID { General = 'general-tab', @@ -109,7 +111,8 @@ const blackList = [Action.Edit, Action.IncognitoRead, Action.AddToReadingList]; SafeHtmlPipe, DecimalPipe, DatePipe, - ReadTimePipe + ReadTimePipe, + SettingTitleComponent ], templateUrl: './edit-chapter-modal.component.html', styleUrl: './edit-chapter-modal.component.scss', @@ -163,6 +166,7 @@ export class EditChapterModalComponent implements OnInit { initChapter!: Chapter; imageUrls: Array = []; size: number = 0; + user!: User; get WebLinks() { if (this.chapter.webLinks === '') return []; @@ -176,7 +180,16 @@ export class EditChapterModalComponent implements OnInit { this.imageUrls.push(this.imageService.getChapterCoverImage(this.chapter.id)); this.size = this.utilityService.asChapter(this.chapter).files.reduce((sum, v) => sum + v.bytes, 0); + this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef), tap(u => { + if (!u) return; + this.user = u; + if (!this.accountService.hasAdminRole(this.user)) { + this.activeId = TabID.Info; + } + this.cdRef.markForCheck(); + + })).subscribe(); this.editForm.addControl('titleName', new FormControl(this.chapter.titleName, [])); this.editForm.addControl('sortOrder', new FormControl(this.chapter.sortOrder, [Validators.required, Validators.min(0)])); @@ -239,6 +252,7 @@ export class EditChapterModalComponent implements OnInit { } + close() { this.modal.dismiss(); } diff --git a/UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.html b/UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.html index c148d959c..81b8ade5e 100644 --- a/UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.html +++ b/UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.html @@ -8,18 +8,6 @@
    - @if (accountService.isAdmin$ | async) { + @if (user && accountService.hasAdminRole(user)) { @for (file of files; track file.id) { @@ -103,6 +91,20 @@ + + @if (user && accountService.hasAdminRole(user)) { +
  • + {{t(TabID.CoverImage)}} + + + + +
  • + } +
  • {{t(TabID.Progress)}} @@ -120,11 +122,13 @@ {{t(TabID.Tasks)}} @for(task of tasks; track task.action) { -
    - - - -
    + @if (accountService.canInvokeAction(user, task.action)) { +
    + + + +
    + } }
  • diff --git a/UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.ts b/UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.ts index 36d1183dd..736118e84 100644 --- a/UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.ts +++ b/UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.ts @@ -40,6 +40,8 @@ import {forkJoin} from "rxjs"; import { MangaFormat } from 'src/app/_models/manga-format'; import {MangaFile} from "../../_models/manga-file"; import {VolumeService} from "../../_services/volume.service"; +import {User} from "../../_models/user"; +import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; enum TabID { General = 'general-tab', @@ -121,10 +123,11 @@ export class EditVolumeModalComponent implements OnInit { @Input({required: true}) libraryId!: number; @Input({required: true}) seriesId!: number; - activeId = TabID.CoverImage; + activeId = TabID.Info; editForm: FormGroup = new FormGroup({}); selectedCover: string = ''; coverImageReset = false; + user!: User; tasks = this.actionFactoryService.getActionablesForSettingsPage(this.actionFactoryService.getVolumeActions(this.runTask.bind(this)), blackList); @@ -136,6 +139,16 @@ export class EditVolumeModalComponent implements OnInit { size: number = 0; files: Array = []; + constructor() { + this.accountService.currentUser$.subscribe(user => { + this.user = user!; + + if (!this.accountService.hasAdminRole(user!)) { + this.activeId = TabID.Info; + } + this.cdRef.markForCheck(); + }); + } ngOnInit() { diff --git a/UI/Web/src/app/_single-modules/related-tab/related-tab.component.html b/UI/Web/src/app/_single-modules/related-tab/related-tab.component.html index 02c3d8584..8ee584c4b 100644 --- a/UI/Web/src/app/_single-modules/related-tab/related-tab.component.html +++ b/UI/Web/src/app/_single-modules/related-tab/related-tab.component.html @@ -13,7 +13,7 @@ + (clicked)="openCollection(item)" [linkUrl]="'/collections/' + item.id" [showFormat]="false"> } @@ -24,7 +24,7 @@ + (clicked)="openReadingList(item)" [linkUrl]="'/lists/' + item.id" [showFormat]="false"> } diff --git a/UI/Web/src/app/admin/edit-user/edit-user.component.html b/UI/Web/src/app/admin/edit-user/edit-user.component.html index f111fc39c..1d9db290c 100644 --- a/UI/Web/src/app/admin/edit-user/edit-user.component.html +++ b/UI/Web/src/app/admin/edit-user/edit-user.component.html @@ -9,42 +9,49 @@ diff --git a/UI/Web/src/app/admin/manage-library/manage-library.component.html b/UI/Web/src/app/admin/manage-library/manage-library.component.html index 323fd73f8..32f6690bd 100644 --- a/UI/Web/src/app/admin/manage-library/manage-library.component.html +++ b/UI/Web/src/app/admin/manage-library/manage-library.component.html @@ -5,6 +5,8 @@
    + + @@ -31,15 +33,13 @@ {{library.lastScanned | timeAgo | defaultDate}} } diff --git a/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts b/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts index b99edc688..02603d510 100644 --- a/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts +++ b/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts @@ -105,6 +105,12 @@ export class ManageTasksSettingsComponent implements OnInit { api: this.serverService.analyzeFiles(), successMessage: 'analyze-files-task-success' }, + { + name: 'sync-themes-task', + description: 'sync-themes-desc', + api: this.serverService.syncThemes(), + successMessage: 'sync-themes-success' + }, { name: 'check-for-updates-task', description: 'check-for-updates-task-desc', diff --git a/UI/Web/src/app/all-filters/all-filters.component.html b/UI/Web/src/app/all-filters/all-filters.component.html index bde282b6d..04656f821 100644 --- a/UI/Web/src/app/all-filters/all-filters.component.html +++ b/UI/Web/src/app/all-filters/all-filters.component.html @@ -1,20 +1,19 @@ - -

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

    -
    -
    - {{t('count', {count: filters.length | number})}} - {{t('create')}} -
    - -
    - -
    - - +
    + +

    + {{t('title')}} +

    +
    +
    + {{t('count', {count: filters.length | number})}} + {{t('create')}} +
    +
    +
    + +
    diff --git a/UI/Web/src/app/all-filters/all-filters.component.scss b/UI/Web/src/app/all-filters/all-filters.component.scss index 139597f9c..dc52bc49c 100644 --- a/UI/Web/src/app/all-filters/all-filters.component.scss +++ b/UI/Web/src/app/all-filters/all-filters.component.scss @@ -1,2 +1,4 @@ - - +.main-container { + margin-top: 10px; + padding: 0 0 0 10px; +} diff --git a/UI/Web/src/app/all-series/_components/all-series/all-series.component.scss b/UI/Web/src/app/all-series/_components/all-series/all-series.component.scss index e69de29bb..dc52bc49c 100644 --- a/UI/Web/src/app/all-series/_components/all-series/all-series.component.scss +++ b/UI/Web/src/app/all-series/_components/all-series/all-series.component.scss @@ -0,0 +1,4 @@ +.main-container { + margin-top: 10px; + padding: 0 0 0 10px; +} diff --git a/UI/Web/src/app/announcements/_components/announcements/announcements.component.html b/UI/Web/src/app/announcements/_components/announcements/announcements.component.html index f390304f6..28a1146f1 100644 --- a/UI/Web/src/app/announcements/_components/announcements/announcements.component.html +++ b/UI/Web/src/app/announcements/_components/announcements/announcements.component.html @@ -1,9 +1,9 @@
    -

    +

    {{t('title')}} -

    +
    diff --git a/UI/Web/src/app/announcements/_components/announcements/announcements.component.scss b/UI/Web/src/app/announcements/_components/announcements/announcements.component.scss index e69de29bb..dc52bc49c 100644 --- a/UI/Web/src/app/announcements/_components/announcements/announcements.component.scss +++ b/UI/Web/src/app/announcements/_components/announcements/announcements.component.scss @@ -0,0 +1,4 @@ +.main-container { + margin-top: 10px; + padding: 0 0 0 10px; +} diff --git a/UI/Web/src/app/app.component.ts b/UI/Web/src/app/app.component.ts index 691b8e04a..aac0cb8e1 100644 --- a/UI/Web/src/app/app.component.ts +++ b/UI/Web/src/app/app.component.ts @@ -22,6 +22,7 @@ import {ServerService} from "./_services/server.service"; import {OutOfDateModalComponent} from "./announcements/_components/out-of-date-modal/out-of-date-modal.component"; import {PreferenceNavComponent} from "./sidenav/preference-nav/preference-nav.component"; import {Breakpoint, UtilityService} from "./shared/_services/utility.service"; +import {translate} from "@jsverse/transloco"; @Component({ selector: 'app-root', @@ -88,6 +89,8 @@ export class AppComponent implements OnInit { if (!user) return false; return user.preferences.noTransitions; }), takeUntilDestroyed(this.destroyRef)); + + } @HostListener('window:resize', ['$event']) @@ -110,28 +113,40 @@ export class AppComponent implements OnInit { const user = this.accountService.getUserFromLocalStorage(); this.accountService.setCurrentUser(user); - if (user) { - // Bootstrap anything that's needed - this.themeService.getThemes().subscribe(); - this.libraryService.getLibraryNames().pipe(take(1), shareReplay({refCount: true, bufferSize: 1})).subscribe(); + if (!user) return; - // Every hour, have the UI check for an update. People seriously stay out of date - interval(2* 60 * 60 * 1000) // 2 hours in milliseconds - .pipe( - switchMap(() => this.accountService.currentUser$), - filter(u => u !== undefined && this.accountService.hasAdminRole(u)), - switchMap(_ => this.serverService.checkHowOutOfDate()), - filter(versionOutOfDate => { - return !isNaN(versionOutOfDate) && versionOutOfDate > 2; - }), - tap(versionOutOfDate => { - if (!this.ngbModal.hasOpenModals()) { - const ref = this.ngbModal.open(OutOfDateModalComponent, {size: 'xl', fullscreen: 'md'}); - ref.componentInstance.versionsOutOfDate = versionOutOfDate; - } - }) - ) - .subscribe(); - } + // Bootstrap anything that's needed + this.themeService.getThemes().subscribe(); + this.libraryService.getLibraryNames().pipe(take(1), shareReplay({refCount: true, bufferSize: 1})).subscribe(); + + // Get the server version, compare vs localStorage, and if different bust locale cache + this.serverService.getVersion(user.apiKey).subscribe(version => { + const cachedVersion = localStorage.getItem('kavita--version'); + if (cachedVersion == null || cachedVersion != version) { + // Bust locale cache + localStorage.removeItem('@transloco/translations/timestamp'); + localStorage.removeItem('@transloco/translations'); + location.reload(); + } + localStorage.setItem('kavita--version', version); + }); + + // Every hour, have the UI check for an update. People seriously stay out of date + interval(2* 60 * 60 * 1000) // 2 hours in milliseconds + .pipe( + switchMap(() => this.accountService.currentUser$), + filter(u => u !== undefined && this.accountService.hasAdminRole(u)), + switchMap(_ => this.serverService.checkHowOutOfDate()), + filter(versionOutOfDate => { + return !isNaN(versionOutOfDate) && versionOutOfDate > 2; + }), + tap(versionOutOfDate => { + if (!this.ngbModal.hasOpenModals()) { + const ref = this.ngbModal.open(OutOfDateModalComponent, {size: 'xl', fullscreen: 'md'}); + ref.componentInstance.versionsOutOfDate = versionOutOfDate; + } + }) + ) + .subscribe(); } } diff --git a/UI/Web/src/app/bookmark/_components/bookmarks/bookmarks.component.scss b/UI/Web/src/app/bookmark/_components/bookmarks/bookmarks.component.scss index e69de29bb..dc52bc49c 100644 --- a/UI/Web/src/app/bookmark/_components/bookmarks/bookmarks.component.scss +++ b/UI/Web/src/app/bookmark/_components/bookmarks/bookmarks.component.scss @@ -0,0 +1,4 @@ +.main-container { + margin-top: 10px; + padding: 0 0 0 10px; +} diff --git a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html index f825df447..2445e351c 100644 --- a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html +++ b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html @@ -712,12 +712,16 @@
  • {{t(tabs[TabID.Tasks])}} - @for(task of tasks; track task.action) { -
    - - - -
    + @if (accountService.currentUser$ | async; as user) { + @for(task of tasks; track task.action) { + @if (accountService.canInvokeAction(user, task.action)) { +
    + + + +
    + } + } }
  • diff --git a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts index 0ce3179ed..2152c2095 100644 --- a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts +++ b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts @@ -12,7 +12,7 @@ import { Input, OnChanges, OnInit, - Output, + Output, SimpleChange, SimpleChanges, TemplateRef, TrackByFunction, ViewChild @@ -153,13 +153,21 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges { } - ngOnChanges(): void { + ngOnChanges(changes: SimpleChanges): void { this.jumpBarKeysToRender = [...this.jumpBarKeys]; this.resizeJumpBar(); const startIndex = this.jumpbarService.getResumePosition(this.router.url); if (startIndex > 0) { setTimeout(() => this.virtualScroller.scrollToIndex(startIndex, true, 0, ANIMATION_TIME_MS), 10); + return; + } + + if (changes.hasOwnProperty('isLoading')) { + const loadingChange = changes['isLoading'] as SimpleChange; + if (loadingChange.previousValue === true && loadingChange.currentValue === false) { + setTimeout(() => this.virtualScroller.scrollToIndex(0, true, 0, ANIMATION_TIME_MS), 10); + } } } diff --git a/UI/Web/src/app/cards/card-item/card-item.component.html b/UI/Web/src/app/cards/card-item/card-item.component.html index d24017a48..415f4c30a 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.html +++ b/UI/Web/src/app/cards/card-item/card-item.component.html @@ -73,22 +73,28 @@
    @if (title.length > 0 || actions.length > 0) {
    -
    - - - - @if (linkUrl) { - {{title}} - } @else { - {{title}} - } - - @if (actions && actions.length > 0) { - - - + @if (showFormat) { + + } + + + @if (isPromoted(); as isPromoted) { + } -
    + @if (linkUrl) { + {{title}} + } @else { + {{title}} + } + + + @if (actions && actions.length > 0) { + + + + } @else { + } +
    } diff --git a/UI/Web/src/app/cards/card-item/card-item.component.scss b/UI/Web/src/app/cards/card-item/card-item.component.scss index 1fb2791ee..09471a3ff 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.scss +++ b/UI/Web/src/app/cards/card-item/card-item.component.scss @@ -1 +1,5 @@ @use '../../../card-item-common'; + +.card-title-container { + justify-content: center; +} diff --git a/UI/Web/src/app/cards/card-item/card-item.component.ts b/UI/Web/src/app/cards/card-item/card-item.component.ts index d39aa8c0a..bd4f3d0a1 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.ts +++ b/UI/Web/src/app/cards/card-item/card-item.component.ts @@ -149,6 +149,10 @@ export class CardItemComponent implements OnInit { * A method that if defined will return the url */ @Input() linkUrl?: string; + /** + * Show the format of the series + */ + @Input() showFormat: boolean = true; /** * Event emitted when item is clicked */ diff --git a/UI/Web/src/app/cards/chapter-card/chapter-card.component.html b/UI/Web/src/app/cards/chapter-card/chapter-card.component.html index c57c533ca..f249a922a 100644 --- a/UI/Web/src/app/cards/chapter-card/chapter-card.component.html +++ b/UI/Web/src/app/cards/chapter-card/chapter-card.component.html @@ -73,21 +73,21 @@ diff --git a/UI/Web/src/app/cards/series-card/series-card.component.html b/UI/Web/src/app/cards/series-card/series-card.component.html index b557f8e3b..964893420 100644 --- a/UI/Web/src/app/cards/series-card/series-card.component.html +++ b/UI/Web/src/app/cards/series-card/series-card.component.html @@ -62,15 +62,15 @@
    - - - - {{series.name}} - - + + + + {{series.name}} + + @if (actions && actions.length > 0) { - + } diff --git a/UI/Web/src/app/cards/volume-card/volume-card.component.html b/UI/Web/src/app/cards/volume-card/volume-card.component.html index 93e2d0228..66a15d8b3 100644 --- a/UI/Web/src/app/cards/volume-card/volume-card.component.html +++ b/UI/Web/src/app/cards/volume-card/volume-card.component.html @@ -60,15 +60,16 @@
    - - {{volume.name}} - + + {{volume.name}} + - @if (actions && actions.length > 0) { - - - - } + + @if (actions && actions.length > 0) { + + + + }
    diff --git a/UI/Web/src/app/collections/_components/all-collections/all-collections.component.html b/UI/Web/src/app/collections/_components/all-collections/all-collections.component.html index 962eaa90c..b57c51eb0 100644 --- a/UI/Web/src/app/collections/_components/all-collections/all-collections.component.html +++ b/UI/Web/src/app/collections/_components/all-collections/all-collections.component.html @@ -19,7 +19,7 @@ [linkUrl]="'/collections/' + item.id" (clicked)="loadCollection(item)" (selection)="bulkSelectionService.handleCardSelection('collection', position, collections.length, $event)" - [selected]="bulkSelectionService.isCardSelected('collection', position)" [allowSelection]="true"> + [selected]="bulkSelectionService.isCardSelected('collection', position)" [allowSelection]="true" [showFormat]="false"> diff --git a/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.html b/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.html index eb6dd5831..b316f7d5f 100644 --- a/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.html +++ b/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.html @@ -7,11 +7,12 @@ {{collectionTag.title}}() +
    {{t('item-count', {num: series.length})}}
    -
    +
    @if (summary.length > 0 || collectionTag.source !== ScrobbleProvider.Kavita) {
    @@ -43,13 +44,11 @@ diff --git a/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.scss b/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.scss index 400d6c154..4ac45a62a 100644 --- a/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.scss +++ b/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.scss @@ -1,3 +1,9 @@ +.main-container { + margin-top: 10px; + padding: 0 0 0 10px; +} + + .poster { width: 100%; max-width: 300px; @@ -30,14 +36,6 @@ max-height: calc(var(--vh)*100 - 170px); } -// This is responsible for ensuring we scroll down and only tabs and companion bar is visible -.main-container { - // Height set dynamically by get ScrollingBlockHeight() - overflow-y: auto; - position: relative; - overscroll-behavior-y: none; -} - h2 { margin-bottom: 0; word-break: break-all; diff --git a/UI/Web/src/app/dashboard/_components/dashboard.component.ts b/UI/Web/src/app/dashboard/_components/dashboard.component.ts index 4d32cbe31..b78de749b 100644 --- a/UI/Web/src/app/dashboard/_components/dashboard.component.ts +++ b/UI/Web/src/app/dashboard/_components/dashboard.component.ts @@ -34,6 +34,7 @@ import {ScrobbleProvider, ScrobblingService} from "../../_services/scrobbling.se import {ToastrService} from "ngx-toastr"; import {SettingsTabId} from "../../sidenav/preference-nav/preference-nav.component"; import {ReaderService} from "../../_services/reader.service"; +import {QueryContext} from "../../_models/metadata/v2/query-context"; enum StreamId { OnDeck, @@ -157,7 +158,7 @@ export class DashboardComponent implements OnInit { case StreamType.SmartFilter: s.api = this.filterUtilityService.decodeFilter(s.smartFilterEncoded!).pipe( switchMap(filter => { - return this.seriesService.getAllSeriesV2(0, 20, filter); + return this.seriesService.getAllSeriesV2(0, 20, filter, QueryContext.Dashboard); })) .pipe(map(d => d.result),tap(() => this.increment()), takeUntilDestroyed(this.destroyRef), shareReplay({bufferSize: 1, refCount: true})); break; 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 64df0772d..74a35f4fb 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 @@ -1721,7 +1721,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { // menu only code savePref() { - const modelSettings = this.generalSettingsForm.value; + const modelSettings = this.generalSettingsForm.getRawValue(); // Get latest preferences from user, overwrite with what we manage in this UI, then save this.accountService.currentUser$.pipe(take(1)).subscribe(user => { if (!user) return; diff --git a/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.html b/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.html index 8808e4d60..d88283ef2 100644 --- a/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.html +++ b/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.html @@ -9,62 +9,69 @@
    + @if (formGroup.get('comparison')?.value != FilterComparison.IsEmpty) { - - - - - - - - - - - -
    - - -
    - - -
    - - - - - - - - - + + + + + + + + + + +
    + + +
    + + +
    + + + + + + + + + +
    + } +
    - - {{t(UiLabel.unit)}} - - + @if (UiLabel !== null) { + {{t(UiLabel.unit)}} + @if (UiLabel.tooltip) { + + } + }
    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 ecc926cdb..5a38810a1 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 @@ -55,6 +55,7 @@ const unitLabels: Map = new Map([ [FilterField.ReadingDate, new FilterRowUi('unit-reading-date')], [FilterField.AverageRating, new FilterRowUi('unit-average-rating')], [FilterField.ReadProgress, new FilterRowUi('unit-reading-progress')], + [FilterField.UserRating, new FilterRowUi('unit-user-rating')], ]); const StringFields = [FilterField.SeriesName, FilterField.Summary, FilterField.Path, FilterField.FilePath]; @@ -80,6 +81,17 @@ const NumberFieldsThatIncludeDateComparisons = [ FilterField.ReleaseYear ]; +const FieldsThatShouldIncludeIsEmpty = [ + FilterField.Summary, FilterField.UserRating, FilterField.Genres, + FilterField.CollectionTags, FilterField.Tags, FilterField.ReleaseYear, + FilterField.Translators, FilterField.Characters, FilterField.Publisher, + FilterField.Editor, FilterField.CoverArtist, FilterField.Letterer, + FilterField.Colorist, FilterField.Inker, FilterField.Penciller, + FilterField.Writers, FilterField.Imprint, FilterField.Team, + FilterField.Location, + +]; + const StringComparisons = [ FilterComparison.Equal, FilterComparison.NotEqual, @@ -221,7 +233,10 @@ export class MetadataFilterRowComponent implements OnInit { stmt.value = stmt.value + ''; } - if (!stmt.value && (![FilterField.SeriesName, FilterField.Summary].includes(stmt.field) && !BooleanFields.includes(stmt.field))) return; + if (stmt.comparison !== FilterComparison.IsEmpty) { + if (!stmt.value && (![FilterField.SeriesName, FilterField.Summary].includes(stmt.field) && !BooleanFields.includes(stmt.field))) return; + } + this.filterStatement.emit(stmt); } @@ -317,8 +332,15 @@ export class MetadataFilterRowComponent implements OnInit { handleFieldChange(val: string) { const inputVal = parseInt(val, 10) as FilterField; + if (StringFields.includes(inputVal)) { - this.validComparisons$.next(StringComparisons); + const comps = [...StringComparisons]; + + if (FieldsThatShouldIncludeIsEmpty.includes(inputVal)) { + comps.push(FilterComparison.IsEmpty); + } + + this.validComparisons$.next(comps); this.predicateType$.next(PredicateType.Text); if (this.loaded) { @@ -330,9 +352,14 @@ export class MetadataFilterRowComponent implements OnInit { if (NumberFields.includes(inputVal)) { const comps = [...NumberComparisons]; + if (NumberFieldsThatIncludeDateComparisons.includes(inputVal)) { comps.push(...DateComparisons); } + if (FieldsThatShouldIncludeIsEmpty.includes(inputVal)) { + comps.push(FilterComparison.IsEmpty); + } + this.validComparisons$.next(comps); this.predicateType$.next(PredicateType.Number); if (this.loaded) { @@ -343,7 +370,12 @@ export class MetadataFilterRowComponent implements OnInit { } if (DateFields.includes(inputVal)) { - this.validComparisons$.next(DateComparisons); + const comps = [...DateComparisons]; + if (FieldsThatShouldIncludeIsEmpty.includes(inputVal)) { + comps.push(FilterComparison.IsEmpty); + } + + this.validComparisons$.next(comps); this.predicateType$.next(PredicateType.Date); if (this.loaded) { @@ -354,7 +386,12 @@ export class MetadataFilterRowComponent implements OnInit { } if (BooleanFields.includes(inputVal)) { - this.validComparisons$.next(BooleanComparisons); + const comps = [...DateComparisons]; + if (FieldsThatShouldIncludeIsEmpty.includes(inputVal)) { + comps.push(FilterComparison.IsEmpty); + } + + this.validComparisons$.next(comps); this.predicateType$.next(PredicateType.Boolean); if (this.loaded) { @@ -372,6 +409,10 @@ export class MetadataFilterRowComponent implements OnInit { if (DropdownFieldsWithoutMustContains.includes(inputVal)) { comps = comps.filter(c => c !== FilterComparison.MustContains); } + if (FieldsThatShouldIncludeIsEmpty.includes(inputVal)) { + comps.push(FilterComparison.IsEmpty); + } + this.validComparisons$.next(comps); this.predicateType$.next(PredicateType.Dropdown); if (this.loaded) { @@ -391,4 +432,5 @@ export class MetadataFilterRowComponent implements OnInit { this.propagateFilterUpdate(); } + protected readonly FilterComparison = FilterComparison; } diff --git a/UI/Web/src/app/nav/_components/events-widget/events-widget.component.scss b/UI/Web/src/app/nav/_components/events-widget/events-widget.component.scss index a7b113493..8ff9695e9 100644 --- a/UI/Web/src/app/nav/_components/events-widget/events-widget.component.scss +++ b/UI/Web/src/app/nav/_components/events-widget/events-widget.component.scss @@ -117,7 +117,7 @@ } .btn-close { - top: 10px; + top: 35px; right: 10px; font-size: 11px; position: absolute; diff --git a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.scss b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.scss index 75c495a3c..281507fff 100644 --- a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.scss +++ b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.scss @@ -1,3 +1,10 @@ +.main-container { + margin-top: 10px; + padding: 0 0 0 10px; +} + + + .content-container { width: 100%; } @@ -24,8 +31,8 @@ flex-direction: row; height: calc((var(--vh) *100) - 173px); margin-bottom: 10px; - + &.empty { height: auto; } -} \ No newline at end of file +} 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 5c49198cd..68b7c602f 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 @@ -26,7 +26,7 @@ [linkUrl]="'/lists/' + item.id" (clicked)="handleClick(item)" (selection)="bulkSelectionService.handleCardSelection('readingList', position, lists.length, $event)" - [selected]="bulkSelectionService.isCardSelected('readingList', position)" [allowSelection]="true"> + [selected]="bulkSelectionService.isCardSelected('readingList', position)" [allowSelection]="true" [showFormat]="false"> 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 e69de29bb..dc52bc49c 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 @@ -0,0 +1,4 @@ +.main-container { + margin-top: 10px; + padding: 0 0 0 10px; +} diff --git a/UI/Web/src/app/registration/_components/confirm-email-change/confirm-email-change.component.html b/UI/Web/src/app/registration/_components/confirm-email-change/confirm-email-change.component.html index 07e01004f..45c37ba3f 100644 --- a/UI/Web/src/app/registration/_components/confirm-email-change/confirm-email-change.component.html +++ b/UI/Web/src/app/registration/_components/confirm-email-change/confirm-email-change.component.html @@ -2,9 +2,9 @@

    {{t('title')}}

    -

    {{t('non-confirm-description')}}

    - - + @if (!confirmed) { + {{t('non-confirm-description')}} + } @else {
    @@ -13,7 +13,7 @@

    {{t('confirm-description')}}

    - + } diff --git a/UI/Web/src/app/registration/_components/confirm-email-change/confirm-email-change.component.ts b/UI/Web/src/app/registration/_components/confirm-email-change/confirm-email-change.component.ts index da178f9ab..aa28f6363 100644 --- a/UI/Web/src/app/registration/_components/confirm-email-change/confirm-email-change.component.ts +++ b/UI/Web/src/app/registration/_components/confirm-email-change/confirm-email-change.component.ts @@ -4,7 +4,6 @@ import { ToastrService } from 'ngx-toastr'; import { AccountService } from 'src/app/_services/account.service'; import { NavService } from 'src/app/_services/nav.service'; import { ThemeService } from 'src/app/_services/theme.service'; -import { NgIf } from '@angular/common'; import { SplashContainerComponent } from '../splash-container/splash-container.component'; import {translate, TranslocoDirective} from "@jsverse/transloco"; @@ -17,7 +16,7 @@ import {translate, TranslocoDirective} from "@jsverse/transloco"; styleUrls: ['./confirm-email-change.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [SplashContainerComponent, NgIf, TranslocoDirective] + imports: [SplashContainerComponent, TranslocoDirective] }) export class ConfirmEmailChangeComponent implements OnInit { diff --git a/UI/Web/src/app/registration/_components/confirm-email/confirm-email.component.html b/UI/Web/src/app/registration/_components/confirm-email/confirm-email.component.html index c0295170d..e61cc9c91 100644 --- a/UI/Web/src/app/registration/_components/confirm-email/confirm-email.component.html +++ b/UI/Web/src/app/registration/_components/confirm-email/confirm-email.component.html @@ -3,37 +3,48 @@

    {{t('title')}}

    {{t('description')}}

    -
    -

    {{t('error-label')}}

    -
      -
    • {{error}}
    • -
    -
    + @if (errors.length > 0) { +
    +

    {{t('error-label')}}

    +
    + @for (error of errors; track error) { +
    {{error}}
    + } +
    +
    + } +
    -
    -
    - {{t('required-field')}} + @if (registerForm.dirty || registerForm.touched) { +
    + @if (registerForm.get('username')?.errors?.required) { +
    {{t('required-field')}}
    + }
    -
    + } +
    -
    -
    - {{t('required-field')}} + + @if (registerForm.dirty || registerForm.touched) { +
    + @if (registerForm.get('email')?.errors?.required) { +
    {{t('required-field')}}
    + } + @if (registerForm.get('email')?.errors?.email) { +
    {{t('valid-email')}}
    + }
    -
    - {{t('valid-email')}} -
    -
    + }
    @@ -43,14 +54,17 @@ -
    -
    - {{t('required-field')}} + + @if (registerForm.dirty || registerForm.touched) { +
    + @if (registerForm.get('password')?.errors?.required) { +
    {{t('required-field')}}
    + } + @if (registerForm.get('password')?.errors?.minlength || registerForm.get('password')?.errors?.maxLength || registerForm.get('password')?.errors?.pattern) { +
    {{t('password-validation')}}
    + }
    -
    - {{t('password-validation')}} -
    -
    + }
    diff --git a/UI/Web/src/app/registration/_components/confirm-email/confirm-email.component.ts b/UI/Web/src/app/registration/_components/confirm-email/confirm-email.component.ts index 01425d4f4..e240ae933 100644 --- a/UI/Web/src/app/registration/_components/confirm-email/confirm-email.component.ts +++ b/UI/Web/src/app/registration/_components/confirm-email/confirm-email.component.ts @@ -6,7 +6,7 @@ import { ThemeService } from 'src/app/_services/theme.service'; import { AccountService } from 'src/app/_services/account.service'; import { NavService } from 'src/app/_services/nav.service'; import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; -import { NgIf, NgFor, NgTemplateOutlet } from '@angular/common'; +import { NgTemplateOutlet } from '@angular/common'; import { SplashContainerComponent } from '../splash-container/splash-container.component'; import {translate, TranslocoDirective} from "@jsverse/transloco"; import {take} from "rxjs/operators"; @@ -17,7 +17,7 @@ import {take} from "rxjs/operators"; styleUrls: ['./confirm-email.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [SplashContainerComponent, NgIf, NgFor, ReactiveFormsModule, NgbTooltip, NgTemplateOutlet, TranslocoDirective] + imports: [SplashContainerComponent, ReactiveFormsModule, NgbTooltip, NgTemplateOutlet, TranslocoDirective] }) export class ConfirmEmailComponent implements OnDestroy { /** 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 d766fd1f1..1e91b18c0 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 @@ -96,7 +96,7 @@ }
    -
    +
    diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.scss b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.scss index a8586fbbb..e19cfeb2c 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.scss +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.scss @@ -6,7 +6,7 @@ left: 20px; } -.card-container{ +.card-container { display: grid; grid-template-columns: repeat(auto-fill, 160px); grid-gap: 0.5rem; diff --git a/UI/Web/src/app/shared/series-format/series-format.component.html b/UI/Web/src/app/shared/series-format/series-format.component.html index a288890fa..e5b3b8fcb 100644 --- a/UI/Web/src/app/shared/series-format/series-format.component.html +++ b/UI/Web/src/app/shared/series-format/series-format.component.html @@ -1,8 +1,8 @@ @if (format !== MangaFormat.UNKNOWN) { @if (useTitle) { - + } @else { - + } } diff --git a/UI/Web/src/app/shared/series-format/series-format.component.scss b/UI/Web/src/app/shared/series-format/series-format.component.scss index e69de29bb..5ff9b39bb 100644 --- a/UI/Web/src/app/shared/series-format/series-format.component.scss +++ b/UI/Web/src/app/shared/series-format/series-format.component.scss @@ -0,0 +1,3 @@ +i { + padding: 0 5px; +} \ No newline at end of file diff --git a/UI/Web/src/app/sidenav/_components/manage-external-sources/manage-external-sources.component.html b/UI/Web/src/app/sidenav/_components/manage-external-sources/manage-external-sources.component.html index 797e868f3..5afd0f273 100644 --- a/UI/Web/src/app/sidenav/_components/manage-external-sources/manage-external-sources.component.html +++ b/UI/Web/src/app/sidenav/_components/manage-external-sources/manage-external-sources.component.html @@ -13,7 +13,7 @@
    -
    +
    diff --git a/UI/Web/src/app/sidenav/_components/manage-smart-filters/manage-smart-filters.component.html b/UI/Web/src/app/sidenav/_components/manage-smart-filters/manage-smart-filters.component.html index d9b170f10..05ea56b13 100644 --- a/UI/Web/src/app/sidenav/_components/manage-smart-filters/manage-smart-filters.component.html +++ b/UI/Web/src/app/sidenav/_components/manage-smart-filters/manage-smart-filters.component.html @@ -19,7 +19,7 @@ {{t('errored')}} } - {{f.name}} + {{f.name}}
    diff --git a/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.html b/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.html index ac07ca58b..9e74e887c 100644 --- a/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.html +++ b/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.html @@ -1,7 +1,7 @@
    - @if (selectedTheme !== undefined && (hasAdmin$ | async)) { + @if ((hasAdmin$ | async)) { @@ -16,12 +16,20 @@
      - @for (theme of themeService.themes$ | async; track theme.name) { - + + + @for (theme of downloadedThemes; track theme.name) { + } + + @for (theme of downloadableThemes; track theme.name) { - + }
    @@ -114,25 +122,27 @@
    - + @if (item !== undefined) {
  • {{item.name | sentenceCase}}
    - @if (item.hasOwnProperty('provider')) { - {{item.provider | siteThemeProvider}} + @if (item.provider !== ThemeProvider.System && item.compatibleVersion) { + v{{item.compatibleVersion}} } @else if (item.hasOwnProperty('lastCompatibleVersion')) { - {{ThemeProvider.Custom | siteThemeProvider}}v{{item.lastCompatibleVersion}} + v{{item.lastCompatibleVersion}} } + @if (currentTheme && item.name === currentTheme.name) { {{t('active-theme')}} } + + @if (item.hasOwnProperty('isDefault') && item.isDefault) { + {{t('default-theme')}} + }
    - @if (item.hasOwnProperty('isDefault') && item.isDefault) { - - }
  • }
    diff --git a/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.scss b/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.scss index cb6565585..3c84d930a 100644 --- a/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.scss +++ b/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.scss @@ -37,11 +37,15 @@ background-color: var(--card-bg-color); border-radius: 5px; } + + } + .list-group-item, .list-group-item.active { border-top-width: 0; border-bottom-width: 0; + } .card { @@ -68,3 +72,7 @@ ngx-file-drop ::ng-deep > div { right: 15px; top: -42px; } + +.section-header { + color: var(--primary-color); +} diff --git a/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.ts b/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.ts index 3e4b57f13..b70831c53 100644 --- a/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.ts +++ b/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.ts @@ -6,7 +6,7 @@ import { inject, } from '@angular/core'; import { ToastrService } from 'ngx-toastr'; -import {distinctUntilChanged, map, take} from 'rxjs'; +import {distinctUntilChanged, map, take, tap} from 'rxjs'; import { ThemeService } from 'src/app/_services/theme.service'; import {SiteTheme, ThemeProvider} from 'src/app/_models/preferences/site-theme'; import { User } from 'src/app/_models/user'; @@ -65,9 +65,11 @@ export class ThemeManagerComponent { user: User | undefined; selectedTheme: ThemeContainer | undefined; downloadableThemes: Array = []; + downloadedThemes: Array = []; hasAdmin$ = this.accountService.currentUser$.pipe( - takeUntilDestroyed(this.destroyRef), shareReplay({refCount: true, bufferSize: 1}), - map(c => c && this.accountService.hasAdminRole(c)) + takeUntilDestroyed(this.destroyRef), + map(c => c && this.accountService.hasAdminRole(c)), + shareReplay({refCount: true, bufferSize: 1}), ); files: NgxFileDropEntry[] = []; @@ -78,6 +80,11 @@ export class ThemeManagerComponent { constructor() { + this.themeService.themes$.pipe(tap(themes => { + this.downloadedThemes = themes; + this.cdRef.markForCheck(); + })).subscribe(); + this.loadDownloadableThemes(); this.themeService.currentTheme$.pipe(takeUntilDestroyed(this.destroyRef), distinctUntilChanged()).subscribe(theme => { @@ -118,7 +125,6 @@ export class ThemeManagerComponent { pref.theme = theme; this.accountService.updatePreferences(pref).subscribe(); // Updating theme emits the new theme to load on the themes$ - }); } @@ -152,8 +158,15 @@ export class ThemeManagerComponent { } downloadTheme(theme: DownloadableSiteTheme) { - this.themeService.downloadTheme(theme).subscribe(theme => { - this.removeDownloadedTheme(theme); + this.themeService.downloadTheme(theme).subscribe(downloadedTheme => { + this.removeDownloadedTheme(downloadedTheme); + this.themeService.getThemes().subscribe(themes => { + this.downloadedThemes = themes; + const oldTheme = this.downloadedThemes.filter(d => d.name === theme.name)[0]; + this.selectTheme(oldTheme); + this.cdRef.markForCheck(); + }); + }); } 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 7f3cb442e..bd1e78c50 100644 --- a/UI/Web/src/app/volume-detail/volume-detail.component.html +++ b/UI/Web/src/app/volume-detail/volume-detail.component.html @@ -76,7 +76,7 @@ }
    -
    +
    diff --git a/UI/Web/src/app/want-to-read/_components/want-to-read/want-to-read.component.html b/UI/Web/src/app/want-to-read/_components/want-to-read/want-to-read.component.html index efd471e11..71ad6f220 100644 --- a/UI/Web/src/app/want-to-read/_components/want-to-read/want-to-read.component.html +++ b/UI/Web/src/app/want-to-read/_components/want-to-read/want-to-read.component.html @@ -1,47 +1,49 @@ - -
    - - -

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

    -
    -
    {{t('series-count', {num: (pagination.totalItems | number)})}}
    -
    -
    +
    + +
    + + +

    + {{t('title')}} +

    +
    +
    {{t('series-count', {num: (pagination.totalItems | number)})}}
    +
    +
    -
    - +
    + - - - - + + + + -
    - - {{t('no-items')}} - -
    +
    + + {{t('no-items')}} + +
    -
    - - {{t('no-items-filtered')}} - -
    +
    + + {{t('no-items-filtered')}} + +
    -
    -
    - + +
    +
    +
    diff --git a/UI/Web/src/app/want-to-read/_components/want-to-read/want-to-read.component.scss b/UI/Web/src/app/want-to-read/_components/want-to-read/want-to-read.component.scss index 0634ecd2d..dc52bc49c 100644 --- a/UI/Web/src/app/want-to-read/_components/want-to-read/want-to-read.component.scss +++ b/UI/Web/src/app/want-to-read/_components/want-to-read/want-to-read.component.scss @@ -1,48 +1,4 @@ -.virtual-scroller, virtual-scroller { - width: 100%; - height: calc(100vh - 85px); - max-height: calc(var(--vh)*100 - 170px); - } - - // This is responsible for ensuring we scroll down and only tabs and companion bar is visible - .main-container { - // Height set dynamically by get ScrollingBlockHeight() - overflow: auto; - position: relative; - overscroll-behavior-y: none; - scrollbar-gutter: stable; - scrollbar-width: thin; - - // For firefox - @supports (-moz-appearance:none) { - scrollbar-color: transparent transparent; - scrollbar-width: thin; - } - - &::-webkit-scrollbar { - background-color: transparent; /*make scrollbar space invisible */ - width: inherit; - display: none; - visibility: hidden; - background: transparent; - } - - &::-webkit-scrollbar-thumb { - background: transparent; /*makes it invisible when not hovering*/ - } - - &:hover { - scrollbar-width: thin; - overflow-y: auto; - - // For firefox - @supports (-moz-appearance:none) { - scrollbar-color: rgba(255,255,255,0.3) rgba(0, 0, 0, 0); - } - - &::-webkit-scrollbar-thumb { - visibility: visible; - background-color: rgba(255,255,255,0.3); /*On hover, it will turn grey*/ - } - } - } \ No newline at end of file +.main-container { + margin-top: 10px; + padding: 0 0 0 10px; +} diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index b0419343c..0c499eac7 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -27,7 +27,8 @@ "not-valid-email": "{{validation.valid-email}}", "cancel": "{{common.cancel}}", "saving": "Saving…", - "update": "Update" + "update": "Update", + "account-detail-title": "Account Details" }, "user-scrobble-history": { @@ -199,8 +200,10 @@ "description": "Kavita comes in my colors, find a color scheme that meets your needs or build one yourself and share it. Themes may be applied for your account or applied to all accounts.", "site-themes": "Site Themes", "set-default": "Set Default", - "default-theme": "Default theme", + "default-theme": "Default", "download": "{{changelog.download}}", + "downloaded": "Downloaded", + "downloadable": "Downloadable", "apply": "{{common.apply}}", "applied": "Applied", "active-theme": "Active", @@ -258,7 +261,7 @@ "title": "Edit Device", "device-name-label": "{{manage-devices.name-label}}", "platform-label": "{{manage-devices.platform-label}}", - + "email-label": "{{common.email}}", "email-tooltip": "This email will be used to accept the file via Send To", "device-platform-label": "Device Platform", @@ -731,7 +734,6 @@ "confirm-email": { "title": "Register", "description": "Complete the form to complete your registration", - "error-label": "Errors: ", "username-label": "{{common.username}}", "password-label": "{{common.password}}", "email-label": "{{common.email}}", @@ -853,7 +855,8 @@ "teams-title": "Teams", "locations-title": "Locations", "language-title": "Language", - "age-rating-title": "Age Rating" + "age-rating-title": "Age Rating", + "links-title": "Weblinks" }, "download-button": { @@ -1356,6 +1359,10 @@ "analyze-files-task-desc": "Runs a long-running task which will analyze files to generate extension and size. This should only be ran once for the v0.7 release. Not needed if you installed post v0.7.", "analyze-files-task-success": "File analysis has been queued", + "sync-themes-task": "Sync Themes", + "sync-themes-task-desc": "Synchronize downloaded themes with upstream changes if version matches.", + "sync-themes-success": "Synchronization of themes has been queued", + "check-for-updates-task": "Check for Updates", "check-for-updates-task-desc": "See if there are any Stable releases ahead of your version." }, @@ -1463,7 +1470,8 @@ "title-alt": "Kavita - {{collectionName}} Collection", "series-header": "Series", "sync-progress": "Series Collected: {{title}}", - "last-sync": "Last Sync: {{date}}" + "last-sync": "Last Sync: {{date}}", + "item-count": "{{common.item-count}}" }, "all-collections": { @@ -1783,8 +1791,9 @@ "metadata-filter-row": { "unit-reading-date": "Date", - "unit-average-rating": "Average Rating (Kavita+) - only for cached series", - "unit-reading-progress": "Percent" + "unit-average-rating": "Kavita+ external rating, percent", + "unit-reading-progress": "Percent", + "unit-user-rating": "{{metadata-filter-row.unit-reading-progress}}" }, "sort-field-pipe": { @@ -1946,7 +1955,7 @@ "pages-count": "{{edit-chapter-modal.pages-count}}", "words-count": "{{edit-chapter-modal.words-count}}", "reading-time-label": "{{edit-chapter-modal.reading-time-label}}", - "date-added-label": "{{edit-chapter-modal.date-added-title}}", + "date-added-label": "{{edit-chapter-modal.date-added-label}}", "size-label": "{{edit-chapter-modal.size-label}}", "id-label": "{{edit-chapter-modal.id-label}}", "links-label": "{{series-metadata-detail.links-label}}", @@ -2223,7 +2232,8 @@ "is-after": "Is after", "is-in-last": "Is in last", "is-not-in-last": "Is not in last", - "must-contains": "Must Contains" + "must-contains": "Must Contains", + "is-empty": "Is Empty" }, @@ -2450,7 +2460,7 @@ "validation": { "required-field": "This field is required", "valid-email": "This must be a valid email", - "password-validation": "Password must be between 6 and 32 characters in length", + "password-validation": "Password must be between 6 and 256 characters in length", "year-validation": "This must be a valid year greater than 1000 and 4 characters long" }, diff --git a/openapi.json b/openapi.json index 9d287bff6..d78f16318 100644 --- a/openapi.json +++ b/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.1", "info": { "title": "Kavita", - "description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.2.6", + "description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.2.9", "license": { "name": "GPL-3.0", "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" @@ -1114,139 +1114,6 @@ } } }, - "/api/Cbl/validate": { - "post": { - "tags": [ - "Cbl" - ], - "summary": "The first step in a cbl import. This validates the cbl file that if an import occured, would it be successful.\r\nIf this returns errors, the cbl will always be rejected by Kavita.", - "parameters": [ - { - "name": "comicVineMatching", - "in": "query", - "description": "Use comic vine matching or not. Defaults to false", - "schema": { - "type": "boolean", - "default": false - } - } - ], - "requestBody": { - "description": "FormBody with parameter name of cbl", - "content": { - "multipart/form-data": { - "schema": { - "type": "object", - "properties": { - "cbl": { - "type": "string", - "format": "binary" - } - } - }, - "encoding": { - "cbl": { - "style": "form" - } - } - } - } - }, - "responses": { - "200": { - "description": "OK", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/CblImportSummaryDto" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/CblImportSummaryDto" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/CblImportSummaryDto" - } - } - } - } - } - } - }, - "/api/Cbl/import": { - "post": { - "tags": [ - "Cbl" - ], - "summary": "Performs the actual import (assuming dryRun = false)", - "parameters": [ - { - "name": "dryRun", - "in": "query", - "description": "If true, will only emulate the import but not perform. This should be done to preview what will happen", - "schema": { - "type": "boolean", - "default": false - } - }, - { - "name": "comicVineMatching", - "in": "query", - "description": "Use comic vine matching or not. Defaults to false", - "schema": { - "type": "boolean", - "default": false - } - } - ], - "requestBody": { - "description": "FormBody with parameter name of cbl", - "content": { - "multipart/form-data": { - "schema": { - "type": "object", - "properties": { - "cbl": { - "type": "string", - "format": "binary" - } - } - }, - "encoding": { - "cbl": { - "style": "form" - } - } - } - } - }, - "responses": { - "200": { - "description": "OK", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/CblImportSummaryDto" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/CblImportSummaryDto" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/CblImportSummaryDto" - } - } - } - } - } - } - }, "/api/Chapter": { "get": { "tags": [ @@ -9356,6 +9223,23 @@ "format": "int32", "default": 0 } + }, + { + "name": "context", + "in": "query", + "description": "For complex queries, Library has certain restrictions where the library should not be included in results.\r\nThis enum dictates which field to use for the lookup.", + "schema": { + "enum": [ + 1, + 2, + 3, + 4 + ], + "type": "integer", + "description": "For complex queries, Library has certain restrictions where the library should not be included in results.\r\nThis enum dictates which field to use for the lookup.", + "format": "int32", + "default": 1 + } } ], "requestBody": { @@ -10577,6 +10461,19 @@ } } }, + "/api/Server/sync-themes": { + "post": { + "tags": [ + "Server" + ], + "summary": "Runs the Sync Themes task", + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/api/Settings/base-url": { "get": { "tags": [ @@ -14593,7 +14490,7 @@ }, "hasBeenRated": { "type": "boolean", - "description": "If the rating has been explicitly set. Otherwise the 0.0 rating should be ignored as it's not rated" + "description": "If the rating has been explicitly set. Otherwise, the 0.0 rating should be ignored as it's not rated" }, "review": { "type": "string", @@ -14603,7 +14500,8 @@ "tagline": { "type": "string", "description": "An optional tagline for the review", - "nullable": true + "nullable": true, + "deprecated": true }, "seriesId": { "type": "integer", @@ -15020,8 +14918,7 @@ "type": "object", "additionalProperties": { "type": "integer", - "format": "int32", - "nullable": true + "format": "int32" }, "description": "For Double Page reader, this will contain snap points to ensure the reader always resumes on correct page", "nullable": true @@ -15094,99 +14991,6 @@ }, "additionalProperties": false }, - "CblBookResult": { - "type": "object", - "properties": { - "order": { - "type": "integer", - "description": "Order in the CBL", - "format": "int32" - }, - "series": { - "type": "string", - "nullable": true - }, - "volume": { - "type": "string", - "nullable": true - }, - "number": { - "type": "string", - "nullable": true - }, - "libraryId": { - "type": "integer", - "description": "Used on Series conflict", - "format": "int32" - }, - "seriesId": { - "type": "integer", - "description": "Used on Series conflict", - "format": "int32" - }, - "readingListName": { - "type": "string", - "description": "The name of the reading list", - "nullable": true - }, - "reason": { - "enum": [ - 0, - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9 - ], - "type": "integer", - "format": "int32" - } - }, - "additionalProperties": false - }, - "CblImportSummaryDto": { - "type": "object", - "properties": { - "cblName": { - "type": "string", - "nullable": true - }, - "fileName": { - "type": "string", - "description": "Used only for Kavita's UI, the filename of the cbl", - "nullable": true - }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CblBookResult" - }, - "nullable": true - }, - "success": { - "enum": [ - 0, - 1, - 2 - ], - "type": "integer", - "format": "int32" - }, - "successfulInserts": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CblBookResult" - }, - "nullable": true - } - }, - "additionalProperties": false, - "description": "Represents the summary from the Import of a given CBL" - }, "Chapter": { "required": [ "number", @@ -15970,8 +15774,7 @@ "type": "object", "additionalProperties": { "type": "integer", - "format": "int32", - "nullable": true + "format": "int32" }, "description": "For Double Page reader, this will contain snap points to ensure the reader always resumes on correct page", "nullable": true @@ -17468,7 +17271,8 @@ 12, 13, 14, - 15 + 15, + 16 ], "type": "integer", "format": "int32" @@ -23277,10 +23081,6 @@ "name": "Account", "description": "All Account matters" }, - { - "name": "Cbl", - "description": "Responsible for the CBL import flow" - }, { "name": "Collection", "description": "APIs for Collections"
    -
    - @if (useActionables$ | async) { - - } @else { - - - - } -
    + @if (useActionables$ | async) { + + } @else { + + + + }