diff --git a/API.Tests/AbstractDbTest.cs b/API.Tests/AbstractDbTest.cs index 6f59b55e9..1a9926345 100644 --- a/API.Tests/AbstractDbTest.cs +++ b/API.Tests/AbstractDbTest.cs @@ -56,7 +56,7 @@ public abstract class AbstractDbTest return connection; } - protected async Task SeedDb() + private async Task SeedDb() { await _context.Database.MigrateAsync(); var filesystem = CreateFileSystem(); @@ -86,7 +86,8 @@ public abstract class AbstractDbTest { Path = "C:/data/" } - } + }, + Series = new List() }); return await _context.SaveChangesAsync() > 0; } diff --git a/API.Tests/Helpers/EntityFactory.cs b/API.Tests/Helpers/EntityFactory.cs index 2f46cc1f4..06fbc35bd 100644 --- a/API.Tests/Helpers/EntityFactory.cs +++ b/API.Tests/Helpers/EntityFactory.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using API.Data; using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; @@ -70,13 +71,6 @@ public static class EntityFactory public static CollectionTag CreateCollectionTag(int id, string title, string summary, bool promoted) { - return new CollectionTag() - { - Id = id, - NormalizedTitle = API.Services.Tasks.Scanner.Parser.Parser.Normalize(title).ToUpper(), - Title = title, - Summary = summary, - Promoted = promoted - }; + return DbFactory.CollectionTag(id, title, summary, promoted); } } diff --git a/API.Tests/Services/CollectionTagServiceTests.cs b/API.Tests/Services/CollectionTagServiceTests.cs new file mode 100644 index 000000000..150e8d02b --- /dev/null +++ b/API.Tests/Services/CollectionTagServiceTests.cs @@ -0,0 +1,113 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.Data; +using API.DTOs.CollectionTags; +using API.Entities; +using API.Entities.Enums; +using API.Services; +using API.Services.Tasks.Metadata; +using API.SignalR; +using API.Tests.Helpers; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace API.Tests.Services; + +public class CollectionTagServiceTests : AbstractDbTest +{ + private readonly ICollectionTagService _service; + public CollectionTagServiceTests() + { + _service = new CollectionTagService(_unitOfWork, Substitute.For()); + } + + protected override async Task ResetDb() + { + _context.CollectionTag.RemoveRange(_context.CollectionTag.ToList()); + _context.Library.RemoveRange(_context.Library.ToList()); + + await _unitOfWork.CommitAsync(); + } + + private async Task SeedSeries() + { + if (_context.CollectionTag.Any()) return; + _context.Library.Add(new Library() + { + Name = "Library 2", + Type = LibraryType.Manga, + Series = new List() + { + EntityFactory.CreateSeries("Series 1"), + EntityFactory.CreateSeries("Series 2"), + } + }); + + _context.CollectionTag.Add(DbFactory.CollectionTag(0, "Tag 1", string.Empty, false)); + _context.CollectionTag.Add(DbFactory.CollectionTag(0, "Tag 2", string.Empty, true)); + await _unitOfWork.CommitAsync(); + } + + + [Fact] + public async Task TagExistsByName_ShouldFindTag() + { + await SeedSeries(); + Assert.True(await _service.TagExistsByName("Tag 1")); + Assert.True(await _service.TagExistsByName("tag 1")); + Assert.False(await _service.TagExistsByName("tag5")); + } + + [Fact] + public async Task UpdateTag_ShouldUpdateFields() + { + await SeedSeries(); + _context.CollectionTag.Add(EntityFactory.CreateCollectionTag(3, "UpdateTag_ShouldUpdateFields", + string.Empty, true)); + await _unitOfWork.CommitAsync(); + + await _service.UpdateTag(new CollectionTagDto() + { + Title = "UpdateTag_ShouldUpdateFields", + Id = 3, + Promoted = true, + Summary = "Test Summary", + }); + + var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(3); + Assert.NotNull(tag); + Assert.True(tag.Promoted); + Assert.True(!string.IsNullOrEmpty(tag.Summary)); + } + + [Fact] + public async Task AddTagToSeries_ShouldAddTagToAllSeries() + { + await SeedSeries(); + var ids = new[] {1, 2}; + await _service.AddTagToSeries(await _unitOfWork.CollectionTagRepository.GetFullTagAsync(1), ids); + + var metadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIdsAsync(ids); + Assert.True(metadatas.ElementAt(0).CollectionTags.Any(t => t.Title.Equals("Tag 1"))); + Assert.True(metadatas.ElementAt(1).CollectionTags.Any(t => t.Title.Equals("Tag 1"))); + } + + [Fact] + public async Task RemoveTagFromSeries_ShouldRemoveMultiple() + { + await SeedSeries(); + var ids = new[] {1, 2}; + var tag = await _unitOfWork.CollectionTagRepository.GetFullTagAsync(2); + await _service.AddTagToSeries(tag, ids); + + await _service.RemoveTagFromSeries(tag, new[] {1}); + + var metadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIdsAsync(new[] {1}); + + Assert.Single(metadatas); + Assert.Empty(metadatas.First().CollectionTags); + Assert.NotEmpty(await _unitOfWork.SeriesRepository.GetSeriesMetadataForIdsAsync(new[] {2})); + } +} diff --git a/API.Tests/Services/DeviceServiceTests.cs b/API.Tests/Services/DeviceServiceTests.cs index 5e3b65522..78ec8cfd2 100644 --- a/API.Tests/Services/DeviceServiceTests.cs +++ b/API.Tests/Services/DeviceServiceTests.cs @@ -51,7 +51,6 @@ public class DeviceServiceDbTests : AbstractDbTest }, user); Assert.NotNull(device); - } [Fact] diff --git a/API/Controllers/CollectionController.cs b/API/Controllers/CollectionController.cs index eaec15b0f..cd9402fe7 100644 --- a/API/Controllers/CollectionController.cs +++ b/API/Controllers/CollectionController.cs @@ -6,7 +6,10 @@ using API.Data; using API.DTOs.CollectionTags; using API.Entities.Metadata; using API.Extensions; +using API.Services; +using API.Services.Tasks.Metadata; using API.SignalR; +using Kavita.Common; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -18,13 +21,13 @@ namespace API.Controllers; public class CollectionController : BaseApiController { private readonly IUnitOfWork _unitOfWork; - private readonly IEventHub _eventHub; + private readonly ICollectionTagService _collectionService; /// - public CollectionController(IUnitOfWork unitOfWork, IEventHub eventHub) + public CollectionController(IUnitOfWork unitOfWork, ICollectionTagService collectionService) { _unitOfWork = unitOfWork; - _eventHub = eventHub; + _collectionService = collectionService; } /// @@ -71,8 +74,7 @@ public class CollectionController : BaseApiController [HttpGet("name-exists")] public async Task> DoesNameExists(string name) { - if (string.IsNullOrEmpty(name.Trim())) return Ok(true); - return Ok(await _unitOfWork.CollectionTagRepository.TagExists(name)); + return Ok(await _collectionService.TagExistsByName(name)); } /// @@ -85,28 +87,13 @@ public class CollectionController : BaseApiController [HttpPost("update")] public async Task UpdateTag(CollectionTagDto updatedTag) { - var existingTag = await _unitOfWork.CollectionTagRepository.GetTagAsync(updatedTag.Id); - if (existingTag == null) return BadRequest("This tag does not exist"); - var title = updatedTag.Title.Trim(); - if (string.IsNullOrEmpty(title)) return BadRequest("Title cannot be empty"); - if (!title.Equals(existingTag.Title) && await _unitOfWork.CollectionTagRepository.TagExists(updatedTag.Title)) - return BadRequest("A tag with this name already exists"); - - existingTag.Title = title; - existingTag.Promoted = updatedTag.Promoted; - existingTag.NormalizedTitle = Services.Tasks.Scanner.Parser.Parser.Normalize(updatedTag.Title); - existingTag.Summary = updatedTag.Summary.Trim(); - - if (_unitOfWork.HasChanges()) + try { - if (await _unitOfWork.CommitAsync()) - { - return Ok("Tag updated successfully"); - } + if (await _collectionService.UpdateTag(updatedTag)) return Ok("Tag updated successfully"); } - else + catch (KavitaException ex) { - return Ok("Tag updated successfully"); + return BadRequest(ex.Message); } return BadRequest("Something went wrong, please try again"); @@ -121,29 +108,11 @@ public class CollectionController : BaseApiController [HttpPost("update-for-series")] public async Task AddToMultipleSeries(CollectionTagBulkAddDto dto) { - var tag = await _unitOfWork.CollectionTagRepository.GetFullTagAsync(dto.CollectionTagId); - if (tag == null) - { - tag = DbFactory.CollectionTag(0, dto.CollectionTagTitle, String.Empty, false); - _unitOfWork.CollectionTagRepository.Add(tag); - } + // Create a new tag and save + var tag = await _collectionService.GetTagOrCreate(dto.CollectionTagId, dto.CollectionTagTitle); + if (await _collectionService.AddTagToSeries(tag, dto.SeriesIds)) return Ok(); - var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIdsAsync(dto.SeriesIds); - foreach (var metadata in seriesMetadatas) - { - if (!metadata.CollectionTags.Any(t => t.Title.Equals(tag.Title, StringComparison.InvariantCulture))) - { - metadata.CollectionTags.Add(tag); - _unitOfWork.SeriesMetadataRepository.Update(metadata); - } - } - - if (!_unitOfWork.HasChanges()) return Ok(); - if (await _unitOfWork.CommitAsync()) - { - return Ok(); - } return BadRequest("There was an issue updating series with collection tag"); } @@ -154,7 +123,7 @@ public class CollectionController : BaseApiController /// [Authorize(Policy = "RequireAdminRole")] [HttpPost("update-series")] - public async Task UpdateSeriesForTag(UpdateSeriesForTagDto updateSeriesForTagDto) + public async Task RemoveTagFromMultipleSeries(UpdateSeriesForTagDto updateSeriesForTagDto) { try { @@ -162,41 +131,8 @@ public class CollectionController : BaseApiController if (tag == null) return BadRequest("Not a valid Tag"); tag.SeriesMetadatas ??= new List(); - // Check if Tag has updated (Summary) - if (tag.Summary == null || !tag.Summary.Equals(updateSeriesForTagDto.Tag.Summary)) - { - tag.Summary = updateSeriesForTagDto.Tag.Summary; - _unitOfWork.CollectionTagRepository.Update(tag); - } - - tag.CoverImageLocked = updateSeriesForTagDto.Tag.CoverImageLocked; - - if (!updateSeriesForTagDto.Tag.CoverImageLocked) - { - tag.CoverImageLocked = false; - tag.CoverImage = string.Empty; - await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, - MessageFactory.CoverUpdateEvent(tag.Id, MessageFactoryEntityTypes.CollectionTag), false); - _unitOfWork.CollectionTagRepository.Update(tag); - } - - foreach (var seriesIdToRemove in updateSeriesForTagDto.SeriesIdsToRemove) - { - tag.SeriesMetadatas.Remove(tag.SeriesMetadatas.Single(sm => sm.SeriesId == seriesIdToRemove)); - } - - - if (tag.SeriesMetadatas.Count == 0) - { - _unitOfWork.CollectionTagRepository.Remove(tag); - } - - if (!_unitOfWork.HasChanges()) return Ok("No updates"); - - if (await _unitOfWork.CommitAsync()) - { + if (await _collectionService.RemoveTagFromSeries(tag, updateSeriesForTagDto.SeriesIdsToRemove)) return Ok("Tag updated"); - } } catch (Exception) { diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index b8655322f..316431c6d 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -247,11 +247,9 @@ public class LibraryController : BaseApiController var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.None); if (TaskScheduler.HasScanTaskRunningForLibrary(libraryId)) { - // TODO: Figure out how to cancel a job - _logger.LogInformation("User is attempting to delete a library while a scan is in progress"); return BadRequest( - "You cannot delete a library while a scan is in progress. Please wait for scan to continue then try to delete"); + "You cannot delete a library while a scan is in progress. Please wait for scan to complete or restart Kavita then try to delete"); } // Due to a bad schema that I can't figure out how to fix, we need to erase all RelatedSeries before we delete the library @@ -336,6 +334,7 @@ public class LibraryController : BaseApiController library.IncludeInDashboard = dto.IncludeInDashboard; library.IncludeInRecommended = dto.IncludeInRecommended; library.IncludeInSearch = dto.IncludeInSearch; + library.ManageCollections = dto.CreateCollections; _unitOfWork.LibraryRepository.Update(library); diff --git a/API/DTOs/LibraryDto.cs b/API/DTOs/LibraryDto.cs index 4a451c52a..6d25409e6 100644 --- a/API/DTOs/LibraryDto.cs +++ b/API/DTOs/LibraryDto.cs @@ -30,6 +30,10 @@ public class LibraryDto /// public bool IncludeInRecommended { get; set; } = true; /// + /// Should this library create and manage collections from Metadata + /// + public bool ManageCollections { get; set; } = true; + /// /// Include library series in Search /// public bool IncludeInSearch { get; set; } = true; diff --git a/API/DTOs/UpdateLibraryDto.cs b/API/DTOs/UpdateLibraryDto.cs index 602351328..6a9b7c72d 100644 --- a/API/DTOs/UpdateLibraryDto.cs +++ b/API/DTOs/UpdateLibraryDto.cs @@ -1,17 +1,28 @@ using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using API.Entities.Enums; namespace API.DTOs; public class UpdateLibraryDto { + [Required] public int Id { get; init; } + [Required] public string Name { get; init; } + [Required] public LibraryType Type { get; set; } + [Required] public IEnumerable Folders { get; init; } + [Required] public bool FolderWatching { get; init; } + [Required] public bool IncludeInDashboard { get; init; } + [Required] public bool IncludeInRecommended { get; init; } + [Required] public bool IncludeInSearch { get; init; } + [Required] + public bool CreateCollections { get; init; } } diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index e39b09a45..1606f9c71 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -104,6 +104,9 @@ public sealed class DataContext : IdentityDbContext() .Property(b => b.IncludeInSearch) .HasDefaultValue(true); + builder.Entity() + .Property(b => b.ManageCollections) + .HasDefaultValue(true); } diff --git a/API/Data/DbFactory.cs b/API/Data/DbFactory.cs index 891c10843..08fcc2e9a 100644 --- a/API/Data/DbFactory.cs +++ b/API/Data/DbFactory.cs @@ -95,7 +95,7 @@ public static class DbFactory return new CollectionTag() { Id = id, - NormalizedTitle = Services.Tasks.Scanner.Parser.Parser.Normalize(title?.Trim()).ToUpper(), + NormalizedTitle = Services.Tasks.Scanner.Parser.Parser.Normalize(title?.Trim()), Title = title?.Trim(), Summary = summary?.Trim(), Promoted = promoted @@ -106,7 +106,7 @@ public static class DbFactory { return new ReadingList() { - NormalizedTitle = Services.Tasks.Scanner.Parser.Parser.Normalize(title?.Trim()).ToUpper(), + NormalizedTitle = Services.Tasks.Scanner.Parser.Parser.Normalize(title?.Trim()), Title = title?.Trim(), Summary = summary?.Trim(), Promoted = promoted, diff --git a/API/Data/Migrations/20230130210252_AutoCollections.Designer.cs b/API/Data/Migrations/20230130210252_AutoCollections.Designer.cs new file mode 100644 index 000000000..6406e7335 --- /dev/null +++ b/API/Data/Migrations/20230130210252_AutoCollections.Designer.cs @@ -0,0 +1,1754 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20230130210252_AutoCollections")] + partial class AutoCollections + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.10"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IncludeInDashboard") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IncludeInRecommended") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IncludeInSearch") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NameLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany("WantToRead") + .HasForeignKey("AppUserId"); + + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Devices"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20230130210252_AutoCollections.cs b/API/Data/Migrations/20230130210252_AutoCollections.cs new file mode 100644 index 000000000..86d2dd3c1 --- /dev/null +++ b/API/Data/Migrations/20230130210252_AutoCollections.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class AutoCollections : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ManageCollections", + table: "Library", + type: "INTEGER", + nullable: false, + defaultValue: true); + + migrationBuilder.AddColumn( + name: "SeriesGroup", + table: "Chapter", + type: "TEXT", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ManageCollections", + table: "Library"); + + migrationBuilder.DropColumn( + name: "SeriesGroup", + table: "Chapter"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index e38aac7fb..d5bda4ef4 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -400,6 +400,9 @@ namespace API.Data.Migrations b.Property("ReleaseDate") .HasColumnType("TEXT"); + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + b.Property("Summary") .HasColumnType("TEXT"); @@ -580,6 +583,11 @@ namespace API.Data.Migrations b.Property("LastScanned") .HasColumnType("TEXT"); + b.Property("ManageCollections") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + b.Property("Name") .HasColumnType("TEXT"); diff --git a/API/Entities/Chapter.cs b/API/Entities/Chapter.cs index cc0db195c..311f769dc 100644 --- a/API/Entities/Chapter.cs +++ b/API/Entities/Chapter.cs @@ -73,6 +73,10 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate /// Number of the Total Count (progress the Series is complete) /// public int Count { get; set; } = 0; + /// + /// SeriesGroup tag in ComicInfo + /// + public string SeriesGroup { get; set; } /// /// Total Word count of all chapters in this chapter. diff --git a/API/Entities/Library.cs b/API/Entities/Library.cs index 84b4fa403..18eb69b0f 100644 --- a/API/Entities/Library.cs +++ b/API/Entities/Library.cs @@ -27,6 +27,10 @@ public class Library : IEntityDate /// Include library series in Search /// public bool IncludeInSearch { get; set; } = true; + /// + /// Should this library create and manage collections from Metadata + /// + public bool ManageCollections { get; set; } = true; public DateTime Created { get; set; } public DateTime LastModified { get; set; } /// diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index 327290b33..164b58751 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -55,6 +55,7 @@ public static class ApplicationServiceExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/API/Services/CollectionTagService.cs b/API/Services/CollectionTagService.cs new file mode 100644 index 000000000..986e2e4a8 --- /dev/null +++ b/API/Services/CollectionTagService.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.Data; +using API.DTOs.CollectionTags; +using API.Entities; +using API.Entities.Metadata; +using API.SignalR; +using Kavita.Common; +using Microsoft.Extensions.Logging; + +namespace API.Services; + + +public interface ICollectionTagService +{ + Task TagExistsByName(string name); + Task UpdateTag(CollectionTagDto dto); + Task AddTagToSeries(CollectionTag tag, IEnumerable seriesIds); + Task RemoveTagFromSeries(CollectionTag tag, IEnumerable seriesIds); + Task GetTagOrCreate(int tagId, string title); + void AddTagToSeriesMetadata(CollectionTag tag, SeriesMetadata metadata); +} + + +public class CollectionTagService : ICollectionTagService +{ + private readonly IUnitOfWork _unitOfWork; + private readonly IEventHub _eventHub; + + public CollectionTagService(IUnitOfWork unitOfWork, IEventHub eventHub) + { + _unitOfWork = unitOfWork; + _eventHub = eventHub; + } + + /// + /// Checks if a collection exists with the name + /// + /// If empty or null, will return true as that is invalid + /// + public async Task TagExistsByName(string name) + { + if (string.IsNullOrEmpty(name.Trim())) return true; + return await _unitOfWork.CollectionTagRepository.TagExists(name); + } + + public async Task UpdateTag(CollectionTagDto dto) + { + var existingTag = await _unitOfWork.CollectionTagRepository.GetTagAsync(dto.Id); + if (existingTag == null) throw new KavitaException("This tag does not exist"); + + var title = dto.Title.Trim(); + if (string.IsNullOrEmpty(title)) throw new KavitaException("Title cannot be empty"); + if (!title.Equals(existingTag.Title) && await TagExistsByName(dto.Title)) + throw new KavitaException("A tag with this name already exists"); + + existingTag.SeriesMetadatas ??= new List(); + existingTag.Title = title; + existingTag.NormalizedTitle = Tasks.Scanner.Parser.Parser.Normalize(dto.Title); + existingTag.Promoted = dto.Promoted; + existingTag.CoverImageLocked = dto.CoverImageLocked; + _unitOfWork.CollectionTagRepository.Update(existingTag); + + // Check if Tag has updated (Summary) + var summary = dto.Summary.Trim(); + if (existingTag.Summary == null || !existingTag.Summary.Equals(summary)) + { + existingTag.Summary = summary; + _unitOfWork.CollectionTagRepository.Update(existingTag); + } + + // If we unlock the cover image it means reset + if (!dto.CoverImageLocked) + { + existingTag.CoverImageLocked = false; + existingTag.CoverImage = string.Empty; + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, + MessageFactory.CoverUpdateEvent(existingTag.Id, MessageFactoryEntityTypes.CollectionTag), false); + _unitOfWork.CollectionTagRepository.Update(existingTag); + } + + if (!_unitOfWork.HasChanges()) return true; + return await _unitOfWork.CommitAsync(); + } + + /// + /// Adds a set of Series to a Collection + /// + /// A full Tag + /// + /// + public async Task AddTagToSeries(CollectionTag tag, IEnumerable seriesIds) + { + var metadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIdsAsync(seriesIds); + foreach (var metadata in metadatas) + { + AddTagToSeriesMetadata(tag, metadata); + } + + if (!_unitOfWork.HasChanges()) return true; + return await _unitOfWork.CommitAsync(); + } + + /// + /// Adds a collection tag to a SeriesMetadata + /// + /// Does not commit + /// + /// + /// + public void AddTagToSeriesMetadata(CollectionTag tag, SeriesMetadata metadata) + { + metadata.CollectionTags ??= new List(); + if (metadata.CollectionTags.Any(t => t.Title.Equals(tag.Title, StringComparison.InvariantCulture))) return; + + metadata.CollectionTags.Add(tag); + _unitOfWork.SeriesMetadataRepository.Update(metadata); + } + + public async Task RemoveTagFromSeries(CollectionTag tag, IEnumerable seriesIds) + { + foreach (var seriesIdToRemove in seriesIds) + { + tag.SeriesMetadatas.Remove(tag.SeriesMetadatas.Single(sm => sm.SeriesId == seriesIdToRemove)); + } + + + if (tag.SeriesMetadatas.Count == 0) + { + _unitOfWork.CollectionTagRepository.Remove(tag); + } + + if (!_unitOfWork.HasChanges()) return true; + + return await _unitOfWork.CommitAsync(); + } + + /// + /// Tries to fetch the full tag, else returns a new tag. Adds to tracking but does not commit + /// + /// + /// + /// + public async Task GetTagOrCreate(int tagId, string title) + { + var tag = await _unitOfWork.CollectionTagRepository.GetFullTagAsync(tagId); + if (tag == null) + { + tag = DbFactory.CollectionTag(0, title, string.Empty, false); + _unitOfWork.CollectionTagRepository.Add(tag); + } + + return tag; + } +} diff --git a/API/Services/Tasks/Scanner/ProcessSeries.cs b/API/Services/Tasks/Scanner/ProcessSeries.cs index d47520084..5c1b97604 100644 --- a/API/Services/Tasks/Scanner/ProcessSeries.cs +++ b/API/Services/Tasks/Scanner/ProcessSeries.cs @@ -45,6 +45,7 @@ public class ProcessSeries : IProcessSeries private readonly IFileService _fileService; private readonly IMetadataService _metadataService; private readonly IWordCountAnalyzerService _wordCountAnalyzerService; + private readonly ICollectionTagService _collectionTagService; private IList _genres; private IList _people; @@ -52,7 +53,8 @@ public class ProcessSeries : IProcessSeries public ProcessSeries(IUnitOfWork unitOfWork, ILogger logger, IEventHub eventHub, IDirectoryService directoryService, ICacheHelper cacheHelper, IReadingItemService readingItemService, - IFileService fileService, IMetadataService metadataService, IWordCountAnalyzerService wordCountAnalyzerService) + IFileService fileService, IMetadataService metadataService, IWordCountAnalyzerService wordCountAnalyzerService, + ICollectionTagService collectionTagService) { _unitOfWork = unitOfWork; _logger = logger; @@ -63,6 +65,7 @@ public class ProcessSeries : IProcessSeries _fileService = fileService; _metadataService = metadataService; _wordCountAnalyzerService = wordCountAnalyzerService; + _collectionTagService = collectionTagService; } /// @@ -151,7 +154,7 @@ public class ProcessSeries : IProcessSeries series.NormalizedLocalizedName = Parser.Parser.Normalize(series.LocalizedName); } - UpdateSeriesMetadata(series, library.Type); + await UpdateSeriesMetadata(series, library); // Update series FolderPath here await UpdateSeriesFolderPath(parsedInfos, library, series); @@ -223,10 +226,10 @@ public class ProcessSeries : IProcessSeries BackgroundJob.Enqueue(() => _wordCountAnalyzerService.ScanSeries(libraryId, seriesId, forceUpdate)); } - private static void UpdateSeriesMetadata(Series series, LibraryType libraryType) + private async Task UpdateSeriesMetadata(Series series, Library library) { series.Metadata ??= DbFactory.SeriesMetadata(new List()); - var isBook = libraryType == LibraryType.Book; + var isBook = library.Type == LibraryType.Book; var firstChapter = SeriesService.GetFirstChapterForMetadata(series, isBook); var firstFile = firstChapter?.Files.FirstOrDefault(); @@ -278,6 +281,14 @@ public class ProcessSeries : IProcessSeries series.Metadata.Language = firstChapter.Language; } + if (!string.IsNullOrEmpty(firstChapter.SeriesGroup) && library.ManageCollections) + { + _logger.LogDebug("Collection tag found for {SeriesName}", series.Name); + + var tag = await _collectionTagService.GetTagOrCreate(0, firstChapter.SeriesGroup); + _collectionTagService.AddTagToSeriesMetadata(tag, series.Metadata); + } + // Handle People foreach (var chapter in chapters) { @@ -629,6 +640,11 @@ public class ProcessSeries : IProcessSeries chapter.Language = comicInfo.LanguageISO; } + if (!string.IsNullOrEmpty(comicInfo.SeriesGroup)) + { + chapter.SeriesGroup = comicInfo.SeriesGroup; + } + if (comicInfo.Count > 0) { chapter.TotalCount = comicInfo.Count; diff --git a/UI/Web/src/app/_models/library.ts b/UI/Web/src/app/_models/library.ts index f15fbab8c..665911be3 100644 --- a/UI/Web/src/app/_models/library.ts +++ b/UI/Web/src/app/_models/library.ts @@ -15,4 +15,5 @@ export interface Library { includeInDashboard: boolean; includeInRecommended: boolean; includeInSearch: boolean; + manageCollections: boolean; } \ No newline at end of file diff --git a/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.ts b/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.ts index 7d0b9ee6a..8522c02c6 100644 --- a/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.ts +++ b/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.ts @@ -151,16 +151,15 @@ export class EditCollectionTagsComponent implements OnInit, OnDestroy { async save() { const selectedIndex = this.collectionTagForm.get('coverImageIndex')?.value || 0; const unselectedIds = this.selections.unselected().map(s => s.id); - const tag: CollectionTag = {...this.tag}; - tag.summary = this.collectionTagForm.get('summary')?.value; - tag.coverImageLocked = this.collectionTagForm.get('coverImageLocked')?.value; - tag.promoted = this.collectionTagForm.get('promoted')?.value; + const tag = this.collectionTagForm.value; + tag.id = this.tag.id; if (unselectedIds.length == this.series.length && !await this.confirmSerivce.confirm('Warning! No series are selected, saving will delete the tag. Are you sure you want to continue?')) { return; } - const apis = [this.collectionService.updateTag(tag), + const apis = [ + this.collectionService.updateTag(tag), this.collectionService.updateSeriesForTag(tag, this.selections.unselected().map(s => s.id)) ]; 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 2b48436b3..f04b5abab 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 @@ -17,7 +17,12 @@ Cannot Read -
+ +
+
+
+
+
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 cd3b100cf..88d0038a5 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 @@ -67,15 +67,28 @@ $image-width: 160px; right: 30%; } +.badge-container { + border-radius: 4px; + display: block; + height: $image-height; + left: 0; + overflow: hidden; + pointer-events: none; + position: absolute; + top: 0; + width: 158px; + +} + .not-read-badge { position: absolute; - top: 0px; - right: 0px; - width: 0; - height: 0; - border-style: solid; - border-width: 0 var(--card-progress-triangle-size) var(--card-progress-triangle-size) 0; - border-color: transparent var(--primary-color) transparent transparent; + top: calc(-1 * (var(--card-progress-triangle-size) / 2)); + right: -14px; + z-index: 1000; + height: var(--card-progress-triangle-size); + width: var(--card-progress-triangle-size); + background-color: var(--primary-color); + transform: rotate(45deg); } @@ -109,8 +122,8 @@ $image-width: 160px; .overlay { height: $image-height; - - + border-top-left-radius: 4px; + border-top-right-radius: 4px; &:hover { visibility: visible; @@ -169,4 +182,6 @@ $image-width: 160px; height: $image-height; z-index: 10; transition: all 0.2s; + border-top-left-radius: 4px; + border-top-right-radius: 4px; } diff --git a/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.html b/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.html index cb16cf113..79adc4623 100644 --- a/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.html +++ b/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.html @@ -92,6 +92,19 @@
  • {{TabID.Advanced}} +
    +
    +
    +
    + + +
    +
    +

    + Should Kavita create and update Collections from SeriesGroup tags found within ComicInfo.xml files +

    +
    +
    diff --git a/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts b/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts index 7aea4c7eb..24ddb7fad 100644 --- a/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts +++ b/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts @@ -46,6 +46,7 @@ export class LibrarySettingsModalComponent implements OnInit, OnDestroy { includeInDashboard: new FormControl(true, { nonNullable: true, validators: [Validators.required] }), includeInRecommended: new FormControl(true, { nonNullable: true, validators: [Validators.required] }), includeInSearch: new FormControl(true, { nonNullable: true, validators: [Validators.required] }), + manageCollections: new FormControl(true, { nonNullable: true, validators: [Validators.required] }), }); selectedFolders: string[] = []; @@ -117,6 +118,7 @@ export class LibrarySettingsModalComponent implements OnInit, OnDestroy { this.libraryForm.get('includeInDashboard')?.setValue(this.library.includeInDashboard); this.libraryForm.get('includeInRecommended')?.setValue(this.library.includeInRecommended); this.libraryForm.get('includeInSearch')?.setValue(this.library.includeInSearch); + this.libraryForm.get('manageCollections')?.setValue(this.library.manageCollections); this.selectedFolders = this.library.folders; this.madeChanges = false; this.cdRef.markForCheck(); diff --git a/UI/Web/src/theme/themes/dark.scss b/UI/Web/src/theme/themes/dark.scss index a3a4505fc..2b325f0ba 100644 --- a/UI/Web/src/theme/themes/dark.scss +++ b/UI/Web/src/theme/themes/dark.scss @@ -201,7 +201,7 @@ --card-progress-bar-color: var(--primary-color); --card-overlay-bg-color: rgba(0, 0, 0, 0); --card-overlay-hover-bg-color: rgba(0, 0, 0, 0.2); - --card-progress-triangle-size: 20px; + --card-progress-triangle-size: 28px; /* Slider */ --slider-text-color: white; diff --git a/openapi.json b/openapi.json index 0a355a7e8..c3289d528 100644 --- a/openapi.json +++ b/openapi.json @@ -7,7 +7,7 @@ "name": "GPL-3.0", "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" }, - "version": "0.6.1.31" + "version": "0.6.1.32" }, "servers": [ { @@ -9943,6 +9943,11 @@ "description": "Number of the Total Count (progress the Series is complete)", "format": "int32" }, + "seriesGroup": { + "type": "string", + "description": "SeriesGroup tag in ComicInfo", + "nullable": true + }, "wordCount": { "type": "integer", "description": "Total Word count of all chapters in this chapter.", @@ -11159,6 +11164,10 @@ "type": "boolean", "description": "Include library series in Search" }, + "manageCollections": { + "type": "boolean", + "description": "Should this library create and manage collections from Metadata" + }, "created": { "type": "string", "format": "date-time" @@ -11232,6 +11241,10 @@ "type": "boolean", "description": "Include Library series on Recommended Streams" }, + "manageCollections": { + "type": "boolean", + "description": "Should this library create and manage collections from Metadata" + }, "includeInSearch": { "type": "boolean", "description": "Include library series in Search" @@ -13711,6 +13724,17 @@ "additionalProperties": false }, "UpdateLibraryDto": { + "required": [ + "createCollections", + "folders", + "folderWatching", + "id", + "includeInDashboard", + "includeInRecommended", + "includeInSearch", + "name", + "type" + ], "type": "object", "properties": { "id": { @@ -13718,8 +13742,8 @@ "format": "int32" }, "name": { - "type": "string", - "nullable": true + "minLength": 1, + "type": "string" }, "type": { "$ref": "#/components/schemas/LibraryType" @@ -13728,8 +13752,7 @@ "type": "array", "items": { "type": "string" - }, - "nullable": true + } }, "folderWatching": { "type": "boolean" @@ -13742,6 +13765,9 @@ }, "includeInSearch": { "type": "boolean" + }, + "createCollections": { + "type": "boolean" } }, "additionalProperties": false