diff --git a/API.Tests/Parser/MangaParserTests.cs b/API.Tests/Parser/MangaParserTests.cs index 899cb8317..34c41015c 100644 --- a/API.Tests/Parser/MangaParserTests.cs +++ b/API.Tests/Parser/MangaParserTests.cs @@ -168,6 +168,7 @@ namespace API.Tests.Parser [InlineData("Seraph of the End - Vampire Reign 093 (2020) (Digital) (LuCaZ).cbz", "Seraph of the End - Vampire Reign")] [InlineData("Love Hina - Volume 01 [Scans].pdf", "Love Hina")] [InlineData("It's Witching Time! 001 (Digital) (Anonymous1234)", "It's Witching Time!")] + [InlineData("Zettai Karen Children v02 c003 - The Invisible Guardian (2) [JS Scans]", "Zettai Karen Children")] public void ParseSeriesTest(string filename, string expected) { Assert.Equal(expected, API.Parser.Parser.ParseSeries(filename)); diff --git a/API.Tests/Services/ReaderServiceTests.cs b/API.Tests/Services/ReaderServiceTests.cs index fbe895317..d4edddf72 100644 --- a/API.Tests/Services/ReaderServiceTests.cs +++ b/API.Tests/Services/ReaderServiceTests.cs @@ -1343,6 +1343,48 @@ public class ReaderServiceTests Assert.Equal("31", nextChapter.Range); } + [Fact] + public async Task GetContinuePoint_ShouldReturnFirstChapter_WhenNonRead_LooseLeafChaptersAndVolumes() + { + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("230", false, new List(), 1), + EntityFactory.CreateChapter("231", false, new List(), 1), + }), + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("1", false, new List(), 1), + EntityFactory.CreateChapter("2", false, new List(), 1), + }), + EntityFactory.CreateVolume("2", new List() + { + EntityFactory.CreateChapter("21", false, new List(), 1), + }), + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + var nextChapter = await readerService.GetContinuePoint(1, 1); + + Assert.Equal("1", nextChapter.Range); + } + [Fact] public async Task GetContinuePoint_ShouldReturnFirstChapter_WhenAllRead() { diff --git a/API/Controllers/ImageController.cs b/API/Controllers/ImageController.cs index a875be14e..b1061997f 100644 --- a/API/Controllers/ImageController.cs +++ b/API/Controllers/ImageController.cs @@ -106,6 +106,8 @@ namespace API.Controllers (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value; var file = new FileInfo(Path.Join(bookmarkDirectory, bookmark.FileName)); var format = Path.GetExtension(file.FullName).Replace(".", ""); + + Response.AddCacheHeader(file.FullName); return PhysicalFile(file.FullName, "image/" + format, Path.GetFileName(file.FullName)); } } diff --git a/API/Controllers/MetadataController.cs b/API/Controllers/MetadataController.cs index d3e0806ed..39c7264d2 100644 --- a/API/Controllers/MetadataController.cs +++ b/API/Controllers/MetadataController.cs @@ -123,18 +123,30 @@ public class MetadataController : BaseApiController public async Task>> GetAllLanguages(string? libraryIds) { var ids = libraryIds?.Split(",").Select(int.Parse).ToList(); - if (ids != null && ids.Count > 0) + if (ids is {Count: > 0}) { return Ok(await _unitOfWork.SeriesRepository.GetAllLanguagesForLibrariesAsync(ids)); } + var englishTag = CultureInfo.GetCultureInfo("en"); return Ok(new List() { new () { - Title = CultureInfo.GetCultureInfo("en").DisplayName, - IsoCode = "en" + Title = englishTag.DisplayName, + IsoCode = englishTag.IetfLanguageTag } }); } + + [HttpGet("all-languages")] + public IEnumerable GetAllValidLanguages() + { + return CultureInfo.GetCultures(CultureTypes.AllCultures).Select(c => + new LanguageDto() + { + Title = c.DisplayName, + IsoCode = c.IetfLanguageTag + }).Where(l => !string.IsNullOrEmpty(l.IsoCode)); + } } diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index a71d73e42..ef291a66d 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -63,6 +63,7 @@ namespace API.Controllers if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No such image for page {page}"); var format = Path.GetExtension(path).Replace(".", ""); + Response.AddCacheHeader(path); return PhysicalFile(path, "image/" + format, Path.GetFileName(path)); } catch (Exception) diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index 1d4da4253..d1bce5975 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -121,6 +121,12 @@ namespace API.Controllers return Ok(await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId)); } + [HttpGet("chapter-metadata")] + public async Task> GetChapterMetadata(int chapterId) + { + return Ok(await _unitOfWork.ChapterRepository.GetChapterMetadataDtoAsync(chapterId)); + } + [HttpPost("update-rating")] public async Task UpdateSeriesRating(UpdateSeriesRatingDto updateSeriesRatingDto) @@ -143,10 +149,27 @@ namespace API.Controllers { return BadRequest("A series already exists in this library with this name. Series Names must be unique to a library."); } - series.Name = updateSeries.Name.Trim(); - series.LocalizedName = updateSeries.LocalizedName.Trim(); - series.SortName = updateSeries.SortName?.Trim(); - series.Metadata.Summary = updateSeries.Summary?.Trim(); + + if (!series.Name.Equals(updateSeries.Name.Trim())) + { + series.Name = updateSeries.Name.Trim(); + series.NameLocked = true; + } + if (!series.SortName.Equals(updateSeries.SortName.Trim())) + { + series.SortName = updateSeries.SortName.Trim(); + series.SortNameLocked = true; + } + if (!series.LocalizedName.Equals(updateSeries.LocalizedName.Trim())) + { + series.LocalizedName = updateSeries.LocalizedName.Trim(); + series.LocalizedNameLocked = true; + } + + + if (!series.NameLocked) series.NameLocked = false; + if (!series.SortNameLocked) series.SortNameLocked = false; + if (!series.LocalizedNameLocked) series.LocalizedNameLocked = false; var needsRefreshMetadata = false; // This is when you hit Reset diff --git a/API/DTOs/ChapterDto.cs b/API/DTOs/ChapterDto.cs index fe9cfd6f5..e22046b60 100644 --- a/API/DTOs/ChapterDto.cs +++ b/API/DTOs/ChapterDto.cs @@ -61,31 +61,5 @@ namespace API.DTOs /// /// Metadata field public string TitleName { get; set; } - /// - /// Summary for the Chapter/Issue - /// - public string Summary { get; set; } - /// - /// Language for the Chapter/Issue - /// - public string Language { get; set; } - /// - /// Number in the TotalCount of issues - /// - public int Count { get; set; } - /// - /// Total number of issues for the series - /// - public int TotalCount { get; set; } - public ICollection Writers { get; set; } = new List(); - public ICollection Penciller { get; set; } = new List(); - public ICollection Inker { get; set; } = new List(); - public ICollection Colorist { get; set; } = new List(); - public ICollection Letterer { get; set; } = new List(); - public ICollection CoverArtist { get; set; } = new List(); - public ICollection Editor { get; set; } = new List(); - public ICollection Publisher { get; set; } = new List(); - public ICollection Translators { get; set; } = new List(); - public ICollection Tags { get; set; } = new List(); } } diff --git a/API/DTOs/Metadata/ChapterMetadataDto.cs b/API/DTOs/Metadata/ChapterMetadataDto.cs index 77b89fe94..7b81fb099 100644 --- a/API/DTOs/Metadata/ChapterMetadataDto.cs +++ b/API/DTOs/Metadata/ChapterMetadataDto.cs @@ -1,19 +1,52 @@ using System.Collections.Generic; +using API.Entities.Enums; namespace API.DTOs.Metadata { + /// + /// Exclusively metadata about a given chapter + /// public class ChapterMetadataDto { public int Id { get; set; } + public int ChapterId { get; set; } public string Title { get; set; } public ICollection Writers { get; set; } = new List(); - public ICollection Penciller { get; set; } = new List(); - public ICollection Inker { get; set; } = new List(); - public ICollection Colorist { get; set; } = new List(); - public ICollection Letterer { get; set; } = new List(); - public ICollection CoverArtist { get; set; } = new List(); - public ICollection Editor { get; set; } = new List(); - public ICollection Publisher { get; set; } = new List(); - public int ChapterId { get; set; } + public ICollection CoverArtists { get; set; } = new List(); + public ICollection Publishers { get; set; } = new List(); + public ICollection Characters { get; set; } = new List(); + public ICollection Pencillers { get; set; } = new List(); + public ICollection Inkers { get; set; } = new List(); + public ICollection Colorists { get; set; } = new List(); + public ICollection Letterers { get; set; } = new List(); + public ICollection Editors { get; set; } = new List(); + public ICollection Translators { get; set; } = new List(); + + public ICollection Genres { get; set; } = new List(); + + /// + /// Collection of all Tags from underlying chapters for a Series + /// + public ICollection Tags { get; set; } = new List(); + public AgeRating AgeRating { get; set; } + public string ReleaseDate { get; set; } + public PublicationStatus PublicationStatus { get; set; } + /// + /// Summary for the Chapter/Issue + /// + public string Summary { get; set; } + /// + /// Language for the Chapter/Issue + /// + public string Language { get; set; } + /// + /// Number in the TotalCount of issues + /// + public int Count { get; set; } + /// + /// Total number of issues for the series + /// + public int TotalCount { get; set; } + } } diff --git a/API/DTOs/SeriesDto.cs b/API/DTOs/SeriesDto.cs index 18d706a2e..5f76634ff 100644 --- a/API/DTOs/SeriesDto.cs +++ b/API/DTOs/SeriesDto.cs @@ -33,6 +33,10 @@ namespace API.DTOs public DateTime Created { get; set; } + public bool NameLocked { get; set; } + public bool SortNameLocked { get; set; } + public bool LocalizedNameLocked { get; set; } + public int LibraryId { get; set; } public string LibraryName { get; set; } } diff --git a/API/DTOs/SeriesMetadataDto.cs b/API/DTOs/SeriesMetadataDto.cs index fbee305ac..23e8f4e52 100644 --- a/API/DTOs/SeriesMetadataDto.cs +++ b/API/DTOs/SeriesMetadataDto.cs @@ -56,6 +56,30 @@ namespace API.DTOs /// public PublicationStatus PublicationStatus { get; set; } + public bool LanguageLocked { get; set; } + public bool SummaryLocked { get; set; } + /// + /// Locked by user so metadata updates from scan loop will not override AgeRating + /// + public bool AgeRatingLocked { get; set; } + /// + /// Locked by user so metadata updates from scan loop will not override PublicationStatus + /// + public bool PublicationStatusLocked { get; set; } + public bool GenresLocked { get; set; } + public bool TagsLocked { get; set; } + public bool WriterLocked { get; set; } + public bool CharacterLocked { get; set; } + public bool ColoristLocked { get; set; } + public bool EditorLocked { get; set; } + public bool InkerLocked { get; set; } + public bool LettererLocked { get; set; } + public bool PencillerLocked { get; set; } + public bool PublisherLocked { get; set; } + public bool TranslatorLocked { get; set; } + public bool CoverArtistLocked { get; set; } + + public int SeriesId { get; set; } } } diff --git a/API/DTOs/UpdateSeriesDto.cs b/API/DTOs/UpdateSeriesDto.cs index 39054a032..676178643 100644 --- a/API/DTOs/UpdateSeriesDto.cs +++ b/API/DTOs/UpdateSeriesDto.cs @@ -6,10 +6,10 @@ public string Name { get; init; } public string LocalizedName { get; init; } public string SortName { get; init; } - public string Summary { get; init; } - public byte[] CoverImage { get; init; } - public int UserRating { get; set; } - public string UserReview { get; set; } public bool CoverImageLocked { get; set; } + + public bool UnlockName { get; set; } + public bool UnlockSortName { get; set; } + public bool UnlockLocalizedName { get; set; } } } diff --git a/API/DTOs/UpdateSeriesMetadataDto.cs b/API/DTOs/UpdateSeriesMetadataDto.cs index dd43167c9..08d3e77e6 100644 --- a/API/DTOs/UpdateSeriesMetadataDto.cs +++ b/API/DTOs/UpdateSeriesMetadataDto.cs @@ -6,6 +6,6 @@ namespace API.DTOs public class UpdateSeriesMetadataDto { public SeriesMetadataDto SeriesMetadata { get; set; } - public ICollection Tags { get; set; } + public ICollection CollectionTags { get; set; } } -} \ No newline at end of file +} diff --git a/API/Data/Metadata/ComicInfo.cs b/API/Data/Metadata/ComicInfo.cs index 0f213d848..f2f3734a4 100644 --- a/API/Data/Metadata/ComicInfo.cs +++ b/API/Data/Metadata/ComicInfo.cs @@ -25,7 +25,7 @@ namespace API.Data.Metadata public int PageCount { get; set; } // ReSharper disable once InconsistentNaming /// - /// ISO 639-1 Code to represent the language of the content + /// IETF BCP 47 Code to represent the language of the content /// public string LanguageISO { get; set; } = string.Empty; /// diff --git a/API/Data/Migrations/20220303205301_SeriesLockedFields.Designer.cs b/API/Data/Migrations/20220303205301_SeriesLockedFields.Designer.cs new file mode 100644 index 000000000..00fc7a10f --- /dev/null +++ b/API/Data/Migrations/20220303205301_SeriesLockedFields.Designer.cs @@ -0,0 +1,1448 @@ +// +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("20220303205301_SeriesLockedFields")] + partial class SeriesLockedFields + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.2"); + + 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("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .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("FileName") + .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("BookReaderDarkMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .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("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("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("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .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.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.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("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + 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("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + 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("Count") + .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("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("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("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.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("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .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("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NameLocked") + .HasColumnType("INTEGER"); + + 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.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId", "Format") + .IsUnique(); + + 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.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("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .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.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.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.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("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + }); + + 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("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20220303205301_SeriesLockedFields.cs b/API/Data/Migrations/20220303205301_SeriesLockedFields.cs new file mode 100644 index 000000000..e3903db9e --- /dev/null +++ b/API/Data/Migrations/20220303205301_SeriesLockedFields.cs @@ -0,0 +1,224 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class SeriesLockedFields : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AgeRatingLocked", + table: "SeriesMetadata", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "CharacterLocked", + table: "SeriesMetadata", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "ColoristLocked", + table: "SeriesMetadata", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "CoverArtistLocked", + table: "SeriesMetadata", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "EditorLocked", + table: "SeriesMetadata", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "GenresLocked", + table: "SeriesMetadata", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "InkerLocked", + table: "SeriesMetadata", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "LanguageLocked", + table: "SeriesMetadata", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "LettererLocked", + table: "SeriesMetadata", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "PencillerLocked", + table: "SeriesMetadata", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "PublicationStatusLocked", + table: "SeriesMetadata", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "PublisherLocked", + table: "SeriesMetadata", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "SummaryLocked", + table: "SeriesMetadata", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "TagsLocked", + table: "SeriesMetadata", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "TranslatorLocked", + table: "SeriesMetadata", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "WriterLocked", + table: "SeriesMetadata", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "LocalizedNameLocked", + table: "Series", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "NameLocked", + table: "Series", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "SortNameLocked", + table: "Series", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AgeRatingLocked", + table: "SeriesMetadata"); + + migrationBuilder.DropColumn( + name: "CharacterLocked", + table: "SeriesMetadata"); + + migrationBuilder.DropColumn( + name: "ColoristLocked", + table: "SeriesMetadata"); + + migrationBuilder.DropColumn( + name: "CoverArtistLocked", + table: "SeriesMetadata"); + + migrationBuilder.DropColumn( + name: "EditorLocked", + table: "SeriesMetadata"); + + migrationBuilder.DropColumn( + name: "GenresLocked", + table: "SeriesMetadata"); + + migrationBuilder.DropColumn( + name: "InkerLocked", + table: "SeriesMetadata"); + + migrationBuilder.DropColumn( + name: "LanguageLocked", + table: "SeriesMetadata"); + + migrationBuilder.DropColumn( + name: "LettererLocked", + table: "SeriesMetadata"); + + migrationBuilder.DropColumn( + name: "PencillerLocked", + table: "SeriesMetadata"); + + migrationBuilder.DropColumn( + name: "PublicationStatusLocked", + table: "SeriesMetadata"); + + migrationBuilder.DropColumn( + name: "PublisherLocked", + table: "SeriesMetadata"); + + migrationBuilder.DropColumn( + name: "SummaryLocked", + table: "SeriesMetadata"); + + migrationBuilder.DropColumn( + name: "TagsLocked", + table: "SeriesMetadata"); + + migrationBuilder.DropColumn( + name: "TranslatorLocked", + table: "SeriesMetadata"); + + migrationBuilder.DropColumn( + name: "WriterLocked", + table: "SeriesMetadata"); + + migrationBuilder.DropColumn( + name: "LocalizedNameLocked", + table: "Series"); + + migrationBuilder.DropColumn( + name: "NameLocked", + table: "Series"); + + migrationBuilder.DropColumn( + name: "SortNameLocked", + table: "Series"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index ff8d50df9..631d93a0b 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -15,7 +15,7 @@ namespace API.Data.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "6.0.1"); + modelBuilder.HasAnnotation("ProductVersion", "6.0.2"); modelBuilder.Entity("API.Entities.AppRole", b => { @@ -502,15 +502,51 @@ namespace API.Data.Migrations b.Property("AgeRating") .HasColumnType("INTEGER"); + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + b.Property("Count") .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("PencillerLocked") + .HasColumnType("INTEGER"); + b.Property("PublicationStatus") .HasColumnType("INTEGER"); + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + b.Property("ReleaseYear") .HasColumnType("INTEGER"); @@ -524,6 +560,18 @@ namespace API.Data.Migrations b.Property("Summary") .HasColumnType("TEXT"); + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + b.HasKey("Id"); b.HasIndex("SeriesId") @@ -647,9 +695,15 @@ namespace API.Data.Migrations b.Property("LocalizedName") .HasColumnType("TEXT"); + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + b.Property("Name") .HasColumnType("TEXT"); + b.Property("NameLocked") + .HasColumnType("INTEGER"); + b.Property("NormalizedName") .HasColumnType("TEXT"); @@ -662,6 +716,9 @@ namespace API.Data.Migrations b.Property("SortName") .HasColumnType("TEXT"); + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + b.HasKey("Id"); b.HasIndex("LibraryId"); diff --git a/API/Data/Repositories/AppUserProgressRepository.cs b/API/Data/Repositories/AppUserProgressRepository.cs index d9799aa22..d2acb3573 100644 --- a/API/Data/Repositories/AppUserProgressRepository.cs +++ b/API/Data/Repositories/AppUserProgressRepository.cs @@ -1,4 +1,5 @@ -using System.Linq; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using API.Entities; using API.Entities.Enums; @@ -13,6 +14,7 @@ public interface IAppUserProgressRepository Task UserHasProgress(LibraryType libraryType, int userId); Task GetUserProgressAsync(int chapterId, int userId); Task HasAnyProgressOnSeriesAsync(int seriesId, int userId); + Task> GetUserProgressForSeriesAsync(int seriesId, int userId); } public class AppUserProgressRepository : IAppUserProgressRepository @@ -83,6 +85,19 @@ public class AppUserProgressRepository : IAppUserProgressRepository .AnyAsync(aup => aup.PagesRead > 0 && aup.AppUserId == userId && aup.SeriesId == seriesId); } + /// + /// This will return any user progress. This filters out progress rows that have no pages read. + /// + /// + /// + /// + public async Task> GetUserProgressForSeriesAsync(int seriesId, int userId) + { + return await _context.AppUserProgresses + .Where(p => p.SeriesId == seriesId && p.AppUserId == userId && p.PagesRead > 0) + .ToListAsync(); + } + public async Task GetUserProgressAsync(int chapterId, int userId) { return await _context.AppUserProgresses diff --git a/API/Data/Repositories/ChapterRepository.cs b/API/Data/Repositories/ChapterRepository.cs index f89304f74..330aa4b5e 100644 --- a/API/Data/Repositories/ChapterRepository.cs +++ b/API/Data/Repositories/ChapterRepository.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Threading.Tasks; using API.DTOs; +using API.DTOs.Metadata; using API.DTOs.Reader; using API.Entities; using AutoMapper; @@ -18,6 +19,7 @@ public interface IChapterRepository Task GetChapterTotalPagesAsync(int chapterId); Task GetChapterAsync(int chapterId); Task GetChapterDtoAsync(int chapterId); + Task GetChapterMetadataDtoAsync(int chapterId); Task> GetFilesForChapterAsync(int chapterId); Task> GetChaptersAsync(int volumeId); Task> GetFilesForChaptersAsync(IReadOnlyList chapterIds); @@ -46,6 +48,7 @@ public class ChapterRepository : IChapterRepository return await _context.Chapter .Where(c => chapterIds.Contains(c.Id)) .Include(c => c.Volume) + .AsSplitQuery() .ToListAsync(); } @@ -113,6 +116,19 @@ public class ChapterRepository : IChapterRepository .Include(c => c.Files) .ProjectTo(_mapper.ConfigurationProvider) .AsNoTracking() + .AsSplitQuery() + .SingleOrDefaultAsync(c => c.Id == chapterId); + + return chapter; + } + + public async Task GetChapterMetadataDtoAsync(int chapterId) + { + var chapter = await _context.Chapter + .Include(c => c.Files) + .ProjectTo(_mapper.ConfigurationProvider) + .AsNoTracking() + .AsSplitQuery() .SingleOrDefaultAsync(c => c.Id == chapterId); return chapter; @@ -140,6 +156,7 @@ public class ChapterRepository : IChapterRepository { return await _context.Chapter .Include(c => c.Files) + .AsSplitQuery() .SingleOrDefaultAsync(c => c.Id == chapterId); } diff --git a/API/Entities/Metadata/ChapterMetadata.cs b/API/Entities/Metadata/ChapterMetadata.cs deleted file mode 100644 index ef4836c23..000000000 --- a/API/Entities/Metadata/ChapterMetadata.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.Collections.Generic; - -namespace API.Entities.Metadata -{ - /// - /// Has a 1-to-1 relationship with a Chapter. Represents metadata about a chapter. - /// - public class ChapterMetadata - { - public int Id { get; set; } - - /// - /// Chapter title - /// - /// This should not be confused with Chapter.Title which is used for special filenames. - public string Title { get; set; } = string.Empty; - public string Year { get; set; } // Only time I can think this will be more than 1 year is for a volume which will be a spread - public string StoryArc { get; set; } // This might be a list - - /// - /// All people attached at a Chapter level. Usually Comics will have different people per issue. - /// - public ICollection People { get; set; } = new List(); - - - - - - // Relationships - public Chapter Chapter { get; set; } - public int ChapterId { get; set; } - - } -} diff --git a/API/Entities/Metadata/SeriesMetadata.cs b/API/Entities/Metadata/SeriesMetadata.cs index 81fcba090..0ec7038fa 100644 --- a/API/Entities/Metadata/SeriesMetadata.cs +++ b/API/Entities/Metadata/SeriesMetadata.cs @@ -40,6 +40,31 @@ namespace API.Entities.Metadata public int Count { get; set; } = 0; public PublicationStatus PublicationStatus { get; set; } + // Locks + public bool LanguageLocked { get; set; } + public bool SummaryLocked { get; set; } + /// + /// Locked by user so metadata updates from scan loop will not override AgeRating + /// + public bool AgeRatingLocked { get; set; } + /// + /// Locked by user so metadata updates from scan loop will not override PublicationStatus + /// + public bool PublicationStatusLocked { get; set; } + public bool GenresLocked { get; set; } + public bool TagsLocked { get; set; } + public bool WriterLocked { get; set; } + public bool CharacterLocked { get; set; } + public bool ColoristLocked { get; set; } + public bool EditorLocked { get; set; } + public bool InkerLocked { get; set; } + public bool LettererLocked { get; set; } + public bool PencillerLocked { get; set; } + public bool PublisherLocked { get; set; } + public bool TranslatorLocked { get; set; } + public bool CoverArtistLocked { get; set; } + + // Relationship public Series Series { get; set; } public int SeriesId { get; set; } @@ -48,6 +73,7 @@ namespace API.Entities.Metadata [ConcurrencyCheck] public uint RowVersion { get; private set; } + /// public void OnSavingChanges() { diff --git a/API/Entities/Series.cs b/API/Entities/Series.cs index 77a011d53..12e169c07 100644 --- a/API/Entities/Series.cs +++ b/API/Entities/Series.cs @@ -58,6 +58,10 @@ namespace API.Entities /// public MangaFormat Format { get; set; } = MangaFormat.Unknown; + public bool NameLocked { get; set; } + public bool SortNameLocked { get; set; } + public bool LocalizedNameLocked { get; set; } + public SeriesMetadata Metadata { get; set; } public ICollection Ratings { get; set; } = new List(); public ICollection Progress { get; set; } = new List(); diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index 0b81f2d7f..ab0b86167 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -21,45 +21,16 @@ namespace API.Helpers public AutoMapperProfiles() { CreateMap(); - CreateMap(); - CreateMap(); - - CreateMap() - .ForMember(dest => dest.Writers, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Writer))) - .ForMember(dest => dest.CoverArtist, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.CoverArtist))) - .ForMember(dest => dest.Colorist, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Colorist))) - .ForMember(dest => dest.Inker, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Inker))) - .ForMember(dest => dest.Letterer, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Letterer))) - .ForMember(dest => dest.Penciller, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Penciller))) - .ForMember(dest => dest.Publisher, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Publisher))) - .ForMember(dest => dest.Editor, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Editor))) - .ForMember(dest => dest.Translators, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Translator))); - + CreateMap(); CreateMap(); CreateMap(); CreateMap(); CreateMap(); CreateMap(); + CreateMap(); + CreateMap(); CreateMap() .ForMember(dest => dest.Writers, @@ -93,29 +64,35 @@ namespace API.Helpers opt => opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Editor))); - CreateMap() + CreateMap() .ForMember(dest => dest.Writers, opt => opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Writer))) - .ForMember(dest => dest.CoverArtist, + .ForMember(dest => dest.CoverArtists, opt => opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.CoverArtist))) - .ForMember(dest => dest.Colorist, + .ForMember(dest => dest.Colorists, opt => opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Colorist))) - .ForMember(dest => dest.Inker, + .ForMember(dest => dest.Inkers, opt => opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Inker))) - .ForMember(dest => dest.Letterer, + .ForMember(dest => dest.Letterers, opt => opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Letterer))) - .ForMember(dest => dest.Penciller, + .ForMember(dest => dest.Pencillers, opt => opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Penciller))) - .ForMember(dest => dest.Publisher, + .ForMember(dest => dest.Publishers, opt => opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Publisher))) - .ForMember(dest => dest.Editor, + .ForMember(dest => dest.Translators, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Translator))) + .ForMember(dest => dest.Characters, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Character))) + .ForMember(dest => dest.Editors, opt => opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Editor))); diff --git a/API/Services/ReaderService.cs b/API/Services/ReaderService.cs index a4e196ae9..4e73b422e 100644 --- a/API/Services/ReaderService.cs +++ b/API/Services/ReaderService.cs @@ -337,29 +337,67 @@ public class ReaderService : IReaderService return -1; } + /// + /// Finds the chapter to continue reading from. If a chapter has progress and not complete, return that. If not, progress in the + /// ordering (Volumes -> Loose Chapters -> Special) to find next chapter. If all are read, return first in order for series. + /// + /// + /// + /// public async Task GetContinuePoint(int seriesId, int userId) { - // Loop through all chapters that are not in volume 0 + var progress = (await _unitOfWork.AppUserProgressRepository.GetUserProgressForSeriesAsync(seriesId, userId)).ToList(); var volumes = (await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId)).ToList(); - var nonSpecialChapters = volumes + if (progress.Count == 0) + { + // I think i need a way to sort volumes last + return volumes.OrderBy(v => double.Parse(v.Number + ""), _chapterSortComparer).First().Chapters + .OrderBy(c => float.Parse(c.Number)).First(); + } + + // Loop through all chapters that are not in volume 0 + var volumeChapters = volumes .Where(v => v.Number != 0) .SelectMany(v => v.Chapters) .OrderBy(c => float.Parse(c.Number)) .ToList(); - var currentlyReadingChapter = nonSpecialChapters.FirstOrDefault(chapter => chapter.PagesRead < chapter.Pages); - - + // If there are any volumes that have progress, return those. If not, move on. + var currentlyReadingChapter = volumeChapters.FirstOrDefault(chapter => chapter.PagesRead < chapter.Pages && chapter.PagesRead > 0); if (currentlyReadingChapter != null) return currentlyReadingChapter; - // Check if there are any specials + // Check loose leaf chapters (and specials). First check if there are any var volume = volumes.SingleOrDefault(v => v.Number == 0); - if (volume == null) return nonSpecialChapters.First(); + return FindNextReadingChapter(volume == null ? volumeChapters : volume.Chapters.OrderBy(c => float.Parse(c.Number)).ToList()); + } - var chapters = volume.Chapters.OrderBy(c => float.Parse(c.Number)).ToList(); + private static ChapterDto FindNextReadingChapter(IList volumeChapters) + { + var chaptersWithProgress = volumeChapters.Where(c => c.PagesRead > 0).ToList(); + if (chaptersWithProgress.Count > 0) + { + var last = chaptersWithProgress.FindLastIndex(c => c.PagesRead > 0); + if (last + 1 < chaptersWithProgress.Count) + { + return chaptersWithProgress.ElementAt(last + 1); + } - return chapters.FirstOrDefault(chapter => chapter.PagesRead < chapter.Pages) ?? chapters.First(); + var lastChapter = chaptersWithProgress.ElementAt(last); + if (lastChapter.PagesRead < lastChapter.Pages) + { + return chaptersWithProgress.ElementAt(last); + } + + // chaptersWithProgress are all read, then we need to get the next chapter that doesn't have progress + var lastIndexWithProgress = volumeChapters.IndexOf(lastChapter); + if (lastIndexWithProgress + 1 < volumeChapters.Count) + { + return volumeChapters.ElementAt(lastIndexWithProgress + 1); + } + } + + return volumeChapters.First(); } diff --git a/API/Services/SeriesService.cs b/API/Services/SeriesService.cs index 714639813..3218190e5 100644 --- a/API/Services/SeriesService.cs +++ b/API/Services/SeriesService.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Collections.Generic; using System.IO; using System.Linq; @@ -6,10 +7,14 @@ using System.Threading.Tasks; using API.Comparators; using API.Data; using API.DTOs; +using API.DTOs.CollectionTags; +using API.DTOs.Metadata; using API.Entities; using API.Entities.Enums; +using API.Helpers; using API.SignalR; using Microsoft.Extensions.Logging; +using Microsoft.VisualBasic; namespace API.Services; @@ -44,52 +49,100 @@ public class SeriesService : ISeriesService { var seriesId = updateSeriesMetadataDto.SeriesMetadata.SeriesId; var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); - var allTags = (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()).ToList(); + var allCollectionTags = (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()).ToList(); + var allGenres = (await _unitOfWork.GenreRepository.GetAllGenresAsync()).ToList(); + var allPeople = (await _unitOfWork.PersonRepository.GetAllPeople()).ToList(); + var allTags = (await _unitOfWork.TagRepository.GetAllTagsAsync()).ToList(); + if (series.Metadata == null) { - series.Metadata = DbFactory.SeriesMetadata(updateSeriesMetadataDto.Tags + series.Metadata = DbFactory.SeriesMetadata(updateSeriesMetadataDto.CollectionTags .Select(dto => DbFactory.CollectionTag(dto.Id, dto.Title, dto.Summary, dto.Promoted)).ToList()); } else { + if (series.Metadata.AgeRating != updateSeriesMetadataDto.SeriesMetadata.AgeRating) + { + series.Metadata.AgeRating = updateSeriesMetadataDto.SeriesMetadata.AgeRating; + series.Metadata.AgeRatingLocked = true; + } + + if (series.Metadata.PublicationStatus != updateSeriesMetadataDto.SeriesMetadata.PublicationStatus) + { + series.Metadata.PublicationStatus = updateSeriesMetadataDto.SeriesMetadata.PublicationStatus; + series.Metadata.PublicationStatusLocked = true; + } + + if (series.Metadata.Summary != updateSeriesMetadataDto.SeriesMetadata.Summary.Trim()) + { + series.Metadata.Summary = updateSeriesMetadataDto.SeriesMetadata?.Summary.Trim(); + series.Metadata.SummaryLocked = true; + } + series.Metadata.CollectionTags ??= new List(); - // TODO: Move this merging logic into a reusable code as it can be used for any Tag - var newTags = new List(); - - // I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different - var existingTags = series.Metadata.CollectionTags.ToList(); - foreach (var existing in existingTags) - { - if (updateSeriesMetadataDto.Tags.SingleOrDefault(t => t.Id == existing.Id) == null) - { - // Remove tag - series.Metadata.CollectionTags.Remove(existing); - } - } - - // At this point, all tags that aren't in dto have been removed. - foreach (var tag in updateSeriesMetadataDto.Tags) - { - var existingTag = allTags.SingleOrDefault(t => t.Title == tag.Title); - if (existingTag != null) - { - if (series.Metadata.CollectionTags.All(t => t.Title != tag.Title)) - { - newTags.Add(existingTag); - } - } - else - { - // Add new tag - newTags.Add(DbFactory.CollectionTag(tag.Id, tag.Title, tag.Summary, tag.Promoted)); - } - } - - foreach (var tag in newTags) + UpdateRelatedList(updateSeriesMetadataDto.CollectionTags, series, allCollectionTags, (tag) => { series.Metadata.CollectionTags.Add(tag); + }); + + series.Metadata.Genres ??= new List(); + UpdateGenreList(updateSeriesMetadataDto.SeriesMetadata.Genres, series, allGenres, (genre) => + { + series.Metadata.Genres.Add(genre); + }, () => series.Metadata.GenresLocked = true); + + series.Metadata.Tags ??= new List(); + UpdateTagList(updateSeriesMetadataDto.SeriesMetadata.Tags, series, allTags, (tag) => + { + series.Metadata.Tags.Add(tag); + }, () => series.Metadata.TagsLocked = true); + + void HandleAddPerson(Person person) + { + PersonHelper.AddPersonIfNotExists(series.Metadata.People, person); + allPeople.Add(person); } + + series.Metadata.People ??= new List(); + UpdatePeopleList(PersonRole.Writer, updateSeriesMetadataDto.SeriesMetadata.Writers, series, allPeople, + HandleAddPerson, () => series.Metadata.WriterLocked = true); + UpdatePeopleList(PersonRole.Character, updateSeriesMetadataDto.SeriesMetadata.Characters, series, allPeople, + HandleAddPerson, () => series.Metadata.CharacterLocked = true); + UpdatePeopleList(PersonRole.Colorist, updateSeriesMetadataDto.SeriesMetadata.Colorists, series, allPeople, + HandleAddPerson, () => series.Metadata.ColoristLocked = true); + UpdatePeopleList(PersonRole.Editor, updateSeriesMetadataDto.SeriesMetadata.Editors, series, allPeople, + HandleAddPerson, () => series.Metadata.EditorLocked = true); + UpdatePeopleList(PersonRole.Inker, updateSeriesMetadataDto.SeriesMetadata.Inkers, series, allPeople, + HandleAddPerson, () => series.Metadata.InkerLocked = true); + UpdatePeopleList(PersonRole.Letterer, updateSeriesMetadataDto.SeriesMetadata.Letterers, series, allPeople, + HandleAddPerson, () => series.Metadata.LettererLocked = true); + UpdatePeopleList(PersonRole.Penciller, updateSeriesMetadataDto.SeriesMetadata.Pencillers, series, allPeople, + HandleAddPerson, () => series.Metadata.PencillerLocked = true); + UpdatePeopleList(PersonRole.Publisher, updateSeriesMetadataDto.SeriesMetadata.Publishers, series, allPeople, + HandleAddPerson, () => series.Metadata.PublisherLocked = true); + UpdatePeopleList(PersonRole.Translator, updateSeriesMetadataDto.SeriesMetadata.Translators, series, allPeople, + HandleAddPerson, () => series.Metadata.TranslatorLocked = true); + UpdatePeopleList(PersonRole.CoverArtist, updateSeriesMetadataDto.SeriesMetadata.CoverArtists, series, allPeople, + HandleAddPerson, () => series.Metadata.CoverArtistLocked = true); + + if (!updateSeriesMetadataDto.SeriesMetadata.AgeRatingLocked) series.Metadata.AgeRatingLocked = false; + if (!updateSeriesMetadataDto.SeriesMetadata.PublicationStatusLocked) series.Metadata.PublicationStatusLocked = false; + if (!updateSeriesMetadataDto.SeriesMetadata.LanguageLocked) series.Metadata.LanguageLocked = false; + if (!updateSeriesMetadataDto.SeriesMetadata.GenresLocked) series.Metadata.GenresLocked = false; + if (!updateSeriesMetadataDto.SeriesMetadata.TagsLocked) series.Metadata.TagsLocked = false; + if (!updateSeriesMetadataDto.SeriesMetadata.CharacterLocked) series.Metadata.CharacterLocked = false; + if (!updateSeriesMetadataDto.SeriesMetadata.ColoristLocked) series.Metadata.ColoristLocked = false; + if (!updateSeriesMetadataDto.SeriesMetadata.EditorLocked) series.Metadata.EditorLocked = false; + if (!updateSeriesMetadataDto.SeriesMetadata.InkerLocked) series.Metadata.InkerLocked = false; + if (!updateSeriesMetadataDto.SeriesMetadata.LettererLocked) series.Metadata.LettererLocked = false; + if (!updateSeriesMetadataDto.SeriesMetadata.PencillerLocked) series.Metadata.PencillerLocked = false; + if (!updateSeriesMetadataDto.SeriesMetadata.PublisherLocked) series.Metadata.PublisherLocked = false; + if (!updateSeriesMetadataDto.SeriesMetadata.TranslatorLocked) series.Metadata.TranslatorLocked = false; + if (!updateSeriesMetadataDto.SeriesMetadata.CoverArtistLocked) series.Metadata.CoverArtistLocked = false; + if (!updateSeriesMetadataDto.SeriesMetadata.WriterLocked) series.Metadata.WriterLocked = false; + if (!updateSeriesMetadataDto.SeriesMetadata.SummaryLocked) series.Metadata.SummaryLocked = false; + } if (!_unitOfWork.HasChanges()) @@ -99,13 +152,16 @@ public class SeriesService : ISeriesService if (await _unitOfWork.CommitAsync()) { - foreach (var tag in updateSeriesMetadataDto.Tags) + foreach (var tag in updateSeriesMetadataDto.CollectionTags) { await _eventHub.SendMessageAsync(MessageFactory.SeriesAddedToCollection, MessageFactory.SeriesAddedToCollectionEvent(tag.Id, updateSeriesMetadataDto.SeriesMetadata.SeriesId), false); } + await _eventHub.SendMessageAsync(MessageFactory.ScanSeries, + MessageFactory.ScanSeriesEvent(series.Id, series.Name), false); + await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries(); return true; @@ -120,6 +176,165 @@ public class SeriesService : ISeriesService return false; } + // TODO: Move this to a helper so we can easily test + private static void UpdateRelatedList(ICollection tags, Series series, IReadOnlyCollection allTags, + Action handleAdd) + { + // I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different + var existingTags = series.Metadata.CollectionTags.ToList(); + foreach (var existing in existingTags) + { + if (tags.SingleOrDefault(t => t.Id == existing.Id) == null) + { + // Remove tag + series.Metadata.CollectionTags.Remove(existing); + } + } + + // At this point, all tags that aren't in dto have been removed. + foreach (var tag in tags) + { + var existingTag = allTags.SingleOrDefault(t => t.Title == tag.Title); + if (existingTag != null) + { + if (series.Metadata.CollectionTags.All(t => t.Title != tag.Title)) + { + handleAdd(existingTag); + } + } + else + { + // Add new tag + handleAdd(DbFactory.CollectionTag(tag.Id, tag.Title, tag.Summary, tag.Promoted)); + } + } + } + + private static void UpdateGenreList(ICollection tags, Series series, IReadOnlyCollection allTags, Action handleAdd, Action onModified) + { + var isModified = false; + // I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different + var existingTags = series.Metadata.Genres.ToList(); + foreach (var existing in existingTags) + { + if (tags.SingleOrDefault(t => t.Id == existing.Id) == null) + { + // Remove tag + series.Metadata.Genres.Remove(existing); + isModified = true; + } + } + + // At this point, all tags that aren't in dto have been removed. + foreach (var tagTitle in tags.Select(t => t.Title)) + { + var existingTag = allTags.SingleOrDefault(t => t.Title == tagTitle); + if (existingTag != null) + { + if (series.Metadata.Genres.All(t => t.Title != tagTitle)) + { + handleAdd(existingTag); + isModified = true; + } + } + else + { + // Add new tag + handleAdd(DbFactory.Genre(tagTitle, false)); + isModified = true; + } + } + + if (isModified) + { + onModified(); + } + } + + private static void UpdateTagList(ICollection tags, Series series, IReadOnlyCollection allTags, Action handleAdd, Action onModified) + { + var isModified = false; + // I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different + var existingTags = series.Metadata.Tags.ToList(); + foreach (var existing in existingTags) + { + if (tags.SingleOrDefault(t => t.Id == existing.Id) == null) + { + // Remove tag + series.Metadata.Tags.Remove(existing); + isModified = true; + } + } + + // At this point, all tags that aren't in dto have been removed. + foreach (var tagTitle in tags.Select(t => t.Title)) + { + var existingTag = allTags.SingleOrDefault(t => t.Title == tagTitle); + if (existingTag != null) + { + if (series.Metadata.Tags.All(t => t.Title != tagTitle)) + { + + handleAdd(existingTag); + isModified = true; + } + } + else + { + // Add new tag + handleAdd(DbFactory.Tag(tagTitle, false)); + isModified = true; + } + } + + if (isModified) + { + onModified(); + } + } + + private static void UpdatePeopleList(PersonRole role, ICollection tags, Series series, IReadOnlyCollection allTags, + Action handleAdd, Action onModified) + { + var isModified = false; + // I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different + var existingTags = series.Metadata.People.Where(p => p.Role == role).ToList(); + foreach (var existing in existingTags) + { + if (tags.SingleOrDefault(t => t.Id == existing.Id) == null) // This needs to check against role + { + // Remove tag + series.Metadata.People.Remove(existing); + isModified = true; + } + } + + // At this point, all tags that aren't in dto have been removed. + foreach (var tag in tags) + { + var existingTag = allTags.SingleOrDefault(t => t.Name == tag.Name && t.Role == tag.Role); + if (existingTag != null) + { + if (series.Metadata.People.All(t => t.Name != tag.Name && t.Role == tag.Role)) + { + handleAdd(existingTag); + isModified = true; + } + } + else + { + // Add new tag + handleAdd(DbFactory.Person(tag.Name, role)); + isModified = true; + } + } + + if (isModified) + { + onModified(); + } + } + /// /// /// diff --git a/API/Services/Tasks/Scanner/ParseScannedFiles.cs b/API/Services/Tasks/Scanner/ParseScannedFiles.cs index c78e137e7..d9128fbe5 100644 --- a/API/Services/Tasks/Scanner/ParseScannedFiles.cs +++ b/API/Services/Tasks/Scanner/ParseScannedFiles.cs @@ -115,6 +115,12 @@ namespace API.Services.Tasks.Scanner { info.Chapters = info.ComicInfo.Number; } + + // Patch is SeriesSort from ComicInfo + if (info.ComicInfo != null && !string.IsNullOrEmpty(info.ComicInfo.TitleSort)) + { + info.SeriesSort = info.ComicInfo.TitleSort; + } } TrackSeries(info); diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index 769dda73f..afe7ec303 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -328,7 +328,7 @@ public class ScannerService : IScannerService if (await _unitOfWork.CommitAsync()) { _logger.LogInformation( - "[ScannerService] Processed {TotalFiles} files and {ParsedSeriesCount} series in {ElapsedScanTime} milliseconds for {LibraryName}", + "[ScannerService] Finished scan of {TotalFiles} files and {ParsedSeriesCount} series in {ElapsedScanTime} milliseconds for {LibraryName}", totalFiles, series.Keys.Count, sw.ElapsedMilliseconds + scanElapsedTime, library.Name); } else @@ -351,8 +351,7 @@ public class ScannerService : IScannerService var parsedSeries = await scanner.ScanLibrariesForSeries(library.Type, dirs, library.Name); var totalFiles = parsedSeries.Keys.Sum(key => parsedSeries[key].Count); var scanElapsedTime = scanWatch.ElapsedMilliseconds; - _logger.LogInformation("Scanned {TotalFiles} files in {ElapsedScanTime} milliseconds", totalFiles, - scanElapsedTime); + return new Tuple>>(totalFiles, scanElapsedTime, parsedSeries); } @@ -426,24 +425,15 @@ public class ScannerService : IScannerService // Now, we only have to deal with series that exist on disk. Let's recalculate the volumes for each series var librarySeries = cleanedSeries.ToList(); - //var index = 0; foreach (var series in librarySeries) { await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.LibraryScanProgressEvent(library.Name, ProgressEventType.Started, series.Name)); await UpdateSeries(series, parsedSeries, allPeople, allTags, allGenres, library); - // await _eventHub.SendMessageAsync(SignalREvents.NotificationProgress, - // MessageFactory.ScanLibraryProgressEvent(library.Id, (1F * index) / librarySeries.Count)); - // index += 1; } try { await _unitOfWork.CommitAsync(); - - // Update the people, genres, and tags after committing as we might have inserted new ones. - allPeople = await _unitOfWork.PersonRepository.GetAllPeople(); - allGenres = await _unitOfWork.GenreRepository.GetAllGenresAsync(); - allTags = await _unitOfWork.TagRepository.GetAllTagsAsync(); } catch (Exception ex) { @@ -471,10 +461,6 @@ public class ScannerService : IScannerService // This is something more like, the series has finished updating in the backend. It may or may not have been modified. await _eventHub.SendMessageAsync(MessageFactory.ScanSeries, MessageFactory.ScanSeriesEvent(series.Id, series.Name)); } - - //var progress = Math.Max(0, Math.Min(1, ((chunk + 1F) * chunkInfo.ChunkSize) / chunkInfo.TotalSize)); - // await _eventHub.SendMessageAsync(SignalREvents.NotificationProgress, - // MessageFactory.ScanLibraryProgressEvent(library.Id, progress)); } @@ -484,6 +470,7 @@ public class ScannerService : IScannerService var allSeries = (await _unitOfWork.SeriesRepository.GetSeriesForLibraryIdAsync(library.Id)).ToList(); _logger.LogDebug("[ScannerService] Fetched {AllSeriesCount} series for comparing new series with. There should be {DeltaToParsedSeries} new series", allSeries.Count, parsedSeries.Count - allSeries.Count); + // TODO: Once a parsedSeries is processed, remove the key to free up some memory foreach (var (key, infos) in parsedSeries) { // Key is normalized already @@ -518,7 +505,6 @@ public class ScannerService : IScannerService } - var i = 0; foreach(var series in newSeries) { _logger.LogDebug("[ScannerService] Processing series {SeriesName}", series.OriginalName); @@ -539,11 +525,6 @@ public class ScannerService : IScannerService _logger.LogCritical(ex, "[ScannerService] There was a critical exception adding new series entry for {SeriesName} with a duplicate index key: {IndexKey} ", series.Name, $"{series.Name}_{series.NormalizedName}_{series.LocalizedName}_{series.LibraryId}_{series.Format}"); } - - //var progress = Math.Max(0F, Math.Min(1F, i * 1F / newSeries.Count)); - // await _eventHub.SendMessageAsync(SignalREvents.NotificationProgress, - // MessageFactory.ScanLibraryProgressEvent(library.Id, progress)); - i++; } await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.LibraryScanProgressEvent(library.Name, ProgressEventType.Ended)); @@ -559,8 +540,6 @@ public class ScannerService : IScannerService try { _logger.LogInformation("[ScannerService] Processing series {SeriesName}", series.OriginalName); - //await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.DbUpdateProgressEvent(series, ProgressEventType.Started)); - //await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.DbUpdateProgressEvent(series, ProgressEventType.Updated)); await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.LibraryScanProgressEvent(library.Name, ProgressEventType.Ended, series.Name)); // Get all associated ParsedInfos to the series. This includes infos that use a different filename that matches Series LocalizedName @@ -575,8 +554,8 @@ public class ScannerService : IScannerService series.Format = parsedInfos[0].Format; } series.OriginalName ??= parsedInfos[0].Series; - series.SortName ??= parsedInfos[0].SeriesSort; - //await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.DbUpdateProgressEvent(series, ProgressEventType.Updated)); + if (!series.SortNameLocked) series.SortName ??= parsedInfos[0].SeriesSort; + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.LibraryScanProgressEvent(library.Name, ProgressEventType.Ended, series.Name)); UpdateSeriesMetadata(series, allPeople, allGenres, allTags, library.Type); @@ -585,7 +564,7 @@ public class ScannerService : IScannerService { _logger.LogError(ex, "[ScannerService] There was an exception updating volumes for {SeriesName}", series.Name); } - //await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.DbUpdateProgressEvent(series, ProgressEventType.Ended)); + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.LibraryScanProgressEvent(library.Name, ProgressEventType.Ended, series.Name)); } @@ -624,65 +603,116 @@ public class ScannerService : IScannerService } // Set the AgeRating as highest in all the comicInfos - series.Metadata.AgeRating = chapters.Max(chapter => chapter.AgeRating); + if (!series.Metadata.AgeRatingLocked) series.Metadata.AgeRating = chapters.Max(chapter => chapter.AgeRating); series.Metadata.Count = chapters.Max(chapter => chapter.TotalCount); - series.Metadata.PublicationStatus = PublicationStatus.OnGoing; - if (chapters.Max(chapter => chapter.Count) >= series.Metadata.Count && series.Metadata.Count > 0) + if (!series.Metadata.PublicationStatusLocked) { - series.Metadata.PublicationStatus = PublicationStatus.Completed; + series.Metadata.PublicationStatus = PublicationStatus.OnGoing; + if (chapters.Max(chapter => chapter.Count) >= series.Metadata.Count && series.Metadata.Count > 0) + { + series.Metadata.PublicationStatus = PublicationStatus.Completed; + } } - if (!string.IsNullOrEmpty(firstChapter.Summary)) + if (!string.IsNullOrEmpty(firstChapter.Summary) && !series.Metadata.SummaryLocked) { series.Metadata.Summary = firstChapter.Summary; } - if (!string.IsNullOrEmpty(firstChapter.Language)) + if (!string.IsNullOrEmpty(firstChapter.Language) && !series.Metadata.LanguageLocked) { series.Metadata.Language = firstChapter.Language; } + void HandleAddPerson(Person person) + { + PersonHelper.AddPersonIfNotExists(series.Metadata.People, person); + allPeople.Add(person); + } + // Handle People foreach (var chapter in chapters) { - PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Writer).Select(p => p.Name), PersonRole.Writer, - person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person)); + if (!series.Metadata.WriterLocked) + { + PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Writer).Select(p => p.Name), PersonRole.Writer, + HandleAddPerson); + } - PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.CoverArtist).Select(p => p.Name), PersonRole.CoverArtist, - person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person)); + if (!series.Metadata.CoverArtistLocked) + { + PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.CoverArtist).Select(p => p.Name), PersonRole.CoverArtist, + HandleAddPerson); + } - PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Publisher).Select(p => p.Name), PersonRole.Publisher, - person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person)); + if (!series.Metadata.PublisherLocked) + { + PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Publisher).Select(p => p.Name), PersonRole.Publisher, + HandleAddPerson); + } - PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Character).Select(p => p.Name), PersonRole.Character, - person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person)); + if (!series.Metadata.CharacterLocked) + { + PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Character).Select(p => p.Name), PersonRole.Character, + HandleAddPerson); + } - PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Colorist).Select(p => p.Name), PersonRole.Colorist, - person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person)); + if (!series.Metadata.ColoristLocked) + { + PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Colorist).Select(p => p.Name), PersonRole.Colorist, + HandleAddPerson); + } - PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Editor).Select(p => p.Name), PersonRole.Editor, - person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person)); + if (!series.Metadata.EditorLocked) + { + PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Editor).Select(p => p.Name), PersonRole.Editor, + HandleAddPerson); + } - PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Inker).Select(p => p.Name), PersonRole.Inker, - person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person)); + if (!series.Metadata.InkerLocked) + { + PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Inker).Select(p => p.Name), PersonRole.Inker, + HandleAddPerson); + } - PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Letterer).Select(p => p.Name), PersonRole.Letterer, - person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person)); + if (!series.Metadata.LettererLocked) + { + PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Letterer).Select(p => p.Name), PersonRole.Letterer, + HandleAddPerson); + } - PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Penciller).Select(p => p.Name), PersonRole.Penciller, - person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person)); + if (!series.Metadata.PencillerLocked) + { + PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Penciller).Select(p => p.Name), PersonRole.Penciller, + HandleAddPerson); + } - PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Translator).Select(p => p.Name), PersonRole.Translator, - person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person)); + if (!series.Metadata.TranslatorLocked) + { + PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Translator).Select(p => p.Name), PersonRole.Translator, + HandleAddPerson); + } - TagHelper.UpdateTag(allTags, chapter.Tags.Select(t => t.Title), false, (tag, _) => - TagHelper.AddTagIfNotExists(series.Metadata.Tags, tag)); + if (!series.Metadata.TagsLocked) + { + TagHelper.UpdateTag(allTags, chapter.Tags.Select(t => t.Title), false, (tag, _) => + { + TagHelper.AddTagIfNotExists(series.Metadata.Tags, tag); + allTags.Add(tag); + }); + } - GenreHelper.UpdateGenre(allGenres, chapter.Genres.Select(t => t.Title), false, genre => - GenreHelper.AddGenreIfNotExists(series.Metadata.Genres, genre)); + if (!series.Metadata.GenresLocked) + { + GenreHelper.UpdateGenre(allGenres, chapter.Genres.Select(t => t.Title), false, genre => + { + GenreHelper.AddGenreIfNotExists(series.Metadata.Genres, genre); + allGenres.Add(genre); + }); + } } var people = chapters.SelectMany(c => c.People).ToList(); @@ -708,7 +738,6 @@ public class ScannerService : IScannerService _unitOfWork.VolumeRepository.Add(volume); } - // TODO: Here we can put a signalR update _logger.LogDebug("[ScannerService] Parsing {SeriesName} - Volume {VolumeNumber}", series.Name, volume.Name); var infos = parsedInfos.Where(p => p.Volumes == volumeNumber).ToArray(); UpdateChapters(volume, infos); diff --git a/API/SignalR/MessageFactory.cs b/API/SignalR/MessageFactory.cs index e15d991a5..76caeb189 100644 --- a/API/SignalR/MessageFactory.cs +++ b/API/SignalR/MessageFactory.cs @@ -14,7 +14,7 @@ namespace API.SignalR /// public const string UpdateAvailable = "UpdateAvailable"; /// - /// Used to tell when a scan series completes + /// Used to tell when a scan series completes. This also informs UI to update series metadata /// public const string ScanSeries = "ScanSeries"; /// diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index 86b37394f..2a511d87a 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -2405,9 +2405,9 @@ } }, "@ng-bootstrap/ng-bootstrap": { - "version": "12.0.0-beta.4", - "resolved": "https://registry.npmjs.org/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-12.0.0-beta.4.tgz", - "integrity": "sha512-iOXZT4FLouAGJDRw4ruogyR+lg648nywNWKUxW7l+mtMC9i4kdpfo4beQ/nqb4Uq2zMDs9zj4MbKVI391+kMnA==", + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-12.0.0.tgz", + "integrity": "sha512-XWf/CsP1gH0aev7Mtsldtj0DPPFdTrJpSiyjzLFS29gU1ZuDlJz6OKthgUDxZoua6uNPAzaGMc0A20T+reMfRw==", "requires": { "tslib": "^2.3.0" } diff --git a/UI/Web/package.json b/UI/Web/package.json index d43401c1a..07c7cc43e 100644 --- a/UI/Web/package.json +++ b/UI/Web/package.json @@ -28,7 +28,7 @@ "@angular/router": "~13.2.2", "@fortawesome/fontawesome-free": "^6.0.0", "@microsoft/signalr": "^6.0.2", - "@ng-bootstrap/ng-bootstrap": "^12.0.0-beta.4", + "@ng-bootstrap/ng-bootstrap": "^12.0.0", "@ngx-lite/nav-drawer": "^0.4.7", "@ngx-lite/util": "0.0.1", "@popperjs/core": "^2.11.2", diff --git a/UI/Web/src/app/_models/chapter-metadata.ts b/UI/Web/src/app/_models/chapter-metadata.ts index 56f400210..4f877cd5b 100644 --- a/UI/Web/src/app/_models/chapter-metadata.ts +++ b/UI/Web/src/app/_models/chapter-metadata.ts @@ -1,16 +1,37 @@ +import { Genre } from "./genre"; +import { AgeRating } from "./metadata/age-rating"; +import { PublicationStatus } from "./metadata/publication-status"; import { Person } from "./person"; +import { Tag } from "./tag"; export interface ChapterMetadata { id: number; chapterId: number; title: string; year: string; + + ageRating: AgeRating; + releaseDate: string; + language: string; + publicationStatus: PublicationStatus; + summary: string; + count: number; + totalCount: number; + + + genres: Array; + tags: Array; writers: Array; - penciller: Array; - inker: Array; - colorist: Array; - letterer: Array; - coverArtist: Array; - editor: Array; + coverArtists: Array; publishers: Array; + characters: Array; + pencillers: Array; + inkers: Array; + colorists: Array; + letterers: Array; + editors: Array; + translators: Array; + + + } \ No newline at end of file diff --git a/UI/Web/src/app/_models/chapter.ts b/UI/Web/src/app/_models/chapter.ts index 1a66e2471..96e4d894c 100644 --- a/UI/Web/src/app/_models/chapter.ts +++ b/UI/Web/src/app/_models/chapter.ts @@ -1,7 +1,8 @@ import { MangaFile } from './manga-file'; -import { Person } from './person'; -import { Tag } from './tag'; +/** + * Chapter table object. This does not have metadata on it, use ChapterMetadata which is the same Chapter but with those fields. + */ export interface Chapter { id: number; range: string; @@ -18,19 +19,8 @@ export interface Chapter { isSpecial: boolean; title: string; created: string; - - titleName: string; /** - * This is only Year and Month, Day is not supported from underlying sources + * Actual name of the Chapter if populated in underlying metadata */ - releaseDate: string; - writers: Array; - penciller: Array; - inker: Array; - colorist: Array; - letterer: Array; - coverArtist: Array; - editor: Array; - publisher: Array; - tags: Array; + titleName: string; } diff --git a/UI/Web/src/app/_models/series-metadata.ts b/UI/Web/src/app/_models/series-metadata.ts index e143a9639..9905cc1a6 100644 --- a/UI/Web/src/app/_models/series-metadata.ts +++ b/UI/Web/src/app/_models/series-metadata.ts @@ -6,11 +6,12 @@ import { Person } from "./person"; import { Tag } from "./tag"; export interface SeriesMetadata { - publisher: string; + seriesId: number; summary: string; + collectionTags: Array; + genres: Array; tags: Array; - collectionTags: Array; writers: Array; coverArtists: Array; publishers: Array; @@ -24,6 +25,23 @@ export interface SeriesMetadata { ageRating: AgeRating; releaseYear: number; language: string; - seriesId: number; publicationStatus: PublicationStatus; + + summaryLocked: boolean; + genresLocked: boolean; + tagsLocked: boolean; + writersLocked: boolean; + coverArtistsLocked: boolean; + publishersLocked: boolean; + charactersLocked: boolean; + pencillersLocked: boolean; + inkersLocked: boolean; + coloristsLocked: boolean; + letterersLocked: boolean; + editorsLocked: boolean; + translatorsLocked: boolean; + ageRatingLocked: boolean; + releaseYearLocked: boolean; + languageLocked: boolean; + publicationStatusLocked: boolean; } \ No newline at end of file diff --git a/UI/Web/src/app/_models/series.ts b/UI/Web/src/app/_models/series.ts index 2bed3bc08..0e3123ac3 100644 --- a/UI/Web/src/app/_models/series.ts +++ b/UI/Web/src/app/_models/series.ts @@ -11,6 +11,9 @@ export interface Series { localizedName: string; sortName: string; coverImageLocked: boolean; + sortNameLocked: boolean; + localizedNameLocked: boolean; + nameLocked: boolean; volumes: Volume[]; /** * Total pages in series diff --git a/UI/Web/src/app/_models/volume.ts b/UI/Web/src/app/_models/volume.ts index 933bab02e..675e9612e 100644 --- a/UI/Web/src/app/_models/volume.ts +++ b/UI/Web/src/app/_models/volume.ts @@ -8,5 +8,5 @@ export interface Volume { lastModified: string; pages: number; pagesRead: number; - chapters?: Array; + chapters: Array; // TODO: Validate any cases where this is undefined } diff --git a/UI/Web/src/app/_services/action-factory.service.ts b/UI/Web/src/app/_services/action-factory.service.ts index a0d1609ff..6f47bd178 100644 --- a/UI/Web/src/app/_services/action-factory.service.ts +++ b/UI/Web/src/app/_services/action-factory.service.ts @@ -121,7 +121,7 @@ export class ActionFactoryService { this.chapterActions.push({ action: Action.Edit, - title: 'Info', + title: 'Details', callback: this.dummyCallback, requiresAdmin: false }); @@ -247,7 +247,7 @@ export class ActionFactoryService { }, { action: Action.Edit, - title: 'Info', + title: 'Details', callback: this.dummyCallback, requiresAdmin: false } diff --git a/UI/Web/src/app/_services/metadata.service.ts b/UI/Web/src/app/_services/metadata.service.ts index 97d39624b..878dba34b 100644 --- a/UI/Web/src/app/_services/metadata.service.ts +++ b/UI/Web/src/app/_services/metadata.service.ts @@ -1,15 +1,17 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { of } from 'rxjs'; +import { Observable, of } from 'rxjs'; import { map } from 'rxjs/operators'; import { environment } from 'src/environments/environment'; +import { UtilityService } from '../shared/_services/utility.service'; +import { TypeaheadSettings } from '../typeahead/typeahead-settings'; import { ChapterMetadata } from '../_models/chapter-metadata'; import { Genre } from '../_models/genre'; import { AgeRating } from '../_models/metadata/age-rating'; import { AgeRatingDto } from '../_models/metadata/age-rating-dto'; import { Language } from '../_models/metadata/language'; import { PublicationStatusDto } from '../_models/metadata/publication-status-dto'; -import { Person } from '../_models/person'; +import { Person, PersonRole } from '../_models/person'; import { Tag } from '../_models/tag'; @Injectable({ @@ -21,7 +23,7 @@ export class MetadataService { private ageRatingTypes: {[key: number]: string} | undefined = undefined; - constructor(private httpClient: HttpClient) { } + constructor(private httpClient: HttpClient, private utilityService: UtilityService) { } getAgeRating(ageRating: AgeRating) { if (this.ageRatingTypes != undefined && this.ageRatingTypes.hasOwnProperty(ageRating)) { @@ -77,6 +79,13 @@ export class MetadataService { return this.httpClient.get>(this.baseUrl + method); } + /** + * All the potential language tags there can be + */ + getAllValidLanguages() { + return this.httpClient.get>(this.baseUrl + 'metadata/all-languages'); + } + getAllPeople(libraries?: Array) { let method = 'metadata/people' if (libraries != undefined && libraries.length > 0) { diff --git a/UI/Web/src/app/_services/series.service.ts b/UI/Web/src/app/_services/series.service.ts index 4530d8010..1b7c91405 100644 --- a/UI/Web/src/app/_services/series.service.ts +++ b/UI/Web/src/app/_services/series.service.ts @@ -4,6 +4,7 @@ import { of } from 'rxjs'; import { map } from 'rxjs/operators'; import { environment } from 'src/environments/environment'; import { Chapter } from '../_models/chapter'; +import { ChapterMetadata } from '../_models/chapter-metadata'; import { CollectionTag } from '../_models/collection-tag'; import { PaginatedResult } from '../_models/pagination'; import { RecentlyAddedItem } from '../_models/recently-added-item'; @@ -85,6 +86,10 @@ export class SeriesService { return this.httpClient.get(this.baseUrl + 'series/chapter?chapterId=' + chapterId); } + getChapterMetadata(chapterId: number) { + return this.httpClient.get(this.baseUrl + 'series/chapter-metadata?chapterId=' + chapterId); + } + getData(id: number) { return of(id); } @@ -161,10 +166,10 @@ export class SeriesService { })); } - updateMetadata(seriesMetadata: SeriesMetadata, tags: CollectionTag[]) { + updateMetadata(seriesMetadata: SeriesMetadata, collectionTags: CollectionTag[]) { const data = { seriesMetadata, - tags + collectionTags, }; return this.httpClient.post(this.baseUrl + 'series/metadata', data, {responseType: 'text' as 'json'}); } @@ -173,11 +178,6 @@ export class SeriesService { let params = new HttpParams(); params = this._addPaginationIfExists(params, pageNum, itemsPerPage); - - // NOTE: I'm not sure the paginated result is doing anything - // if (this.paginatedSeriesForTagsResults?.pagination !== undefined && this.paginatedSeriesForTagsResults?.pagination?.currentPage === pageNum) { - // return of(this.paginatedSeriesForTagsResults); - // } return this.httpClient.get>(this.baseUrl + 'series/series-by-collection?collectionId=' + collectionTagId, {observe: 'response', params}).pipe( map((response: any) => { diff --git a/UI/Web/src/app/admin/manage-settings/manage-settings.component.html b/UI/Web/src/app/admin/manage-settings/manage-settings.component.html index 7bda84b4b..f71101a89 100644 --- a/UI/Web/src/app/admin/manage-settings/manage-settings.component.html +++ b/UI/Web/src/app/admin/manage-settings/manage-settings.component.html @@ -58,7 +58,7 @@

Email Services (SMTP)

Kavita comes out of the box with an email service to power flows like invite user, forgot password, etc. Emails sent via our service are deleted immediately. You can use your own - email service. Set the url of the email service and use the Test button to ensure it works. At any time you can reset to ours. There is no way to disable emails althought confirmation links will always + email service. Set the url of the email service and use the Test button to ensure it works. At any time you can reset to ours. There is no way to disable emails although confirmation links will always be saved to logs.

diff --git a/UI/Web/src/app/app.module.ts b/UI/Web/src/app/app.module.ts index 8847e77d2..3de4ae9ed 100644 --- a/UI/Web/src/app/app.module.ts +++ b/UI/Web/src/app/app.module.ts @@ -29,13 +29,12 @@ import { CollectionsModule } from './collections/collections.module'; import { ReadingListModule } from './reading-list/reading-list.module'; import { SAVER, getSaver } from './shared/_providers/saver.provider'; import { NavEventsToggleComponent } from './nav-events-toggle/nav-events-toggle.component'; -import { PersonRolePipe } from './_pipes/person-role.pipe'; import { SeriesMetadataDetailComponent } from './series-metadata-detail/series-metadata-detail.component'; import { AllSeriesComponent } from './all-series/all-series.component'; import { RegistrationModule } from './registration/registration.module'; import { GroupedTypeaheadComponent } from './grouped-typeahead/grouped-typeahead.component'; -import { PublicationStatusPipe } from './_pipes/publication-status.pipe'; import { ThemeTestComponent } from './theme-test/theme-test.component'; +import { PipeModule } from './pipe/pipe.module'; @NgModule({ @@ -51,8 +50,6 @@ import { ThemeTestComponent } from './theme-test/theme-test.component'; OnDeckComponent, DashboardComponent, NavEventsToggleComponent, - PersonRolePipe, - PublicationStatusPipe, SeriesMetadataDetailComponent, AllSeriesComponent, GroupedTypeaheadComponent, @@ -83,6 +80,7 @@ import { ThemeTestComponent } from './theme-test/theme-test.component'; RegistrationModule, NgbAccordionModule, // ThemeTest Component only + PipeModule, ToastrModule.forRoot({ diff --git a/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.html b/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.html index a6f68943a..7d2dbddc7 100644 --- a/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.html +++ b/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.html @@ -9,76 +9,145 @@
- - - diff --git a/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.ts b/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.ts index a5a655f8f..d65215ef6 100644 --- a/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.ts +++ b/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.ts @@ -3,7 +3,7 @@ import { Router } from '@angular/router'; import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { ToastrService } from 'ngx-toastr'; import { take } from 'rxjs/operators'; -import { UtilityService } from 'src/app/shared/_services/utility.service'; +import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service'; import { Chapter } from 'src/app/_models/chapter'; import { MangaFile } from 'src/app/_models/manga-file'; import { MangaFormat } from 'src/app/_models/manga-format'; @@ -12,11 +12,16 @@ import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/acti import { ActionService } from 'src/app/_services/action.service'; import { ImageService } from 'src/app/_services/image.service'; import { UploadService } from 'src/app/_services/upload.service'; -import { ChangeCoverImageModalComponent } from '../change-cover-image/change-cover-image-modal.component'; import { LibraryType } from '../../../_models/library'; import { LibraryService } from '../../../_services/library.service'; import { SeriesService } from 'src/app/_services/series.service'; import { Series } from 'src/app/_models/series'; +import { PersonRole } from 'src/app/_models/person'; +import { Volume } from 'src/app/_models/volume'; +import { ChapterMetadata } from 'src/app/_models/chapter-metadata'; +import { PageBookmark } from 'src/app/_models/page-bookmark'; +import { ReaderService } from 'src/app/_services/reader.service'; +import { MetadataService } from 'src/app/_services/metadata.service'; @@ -30,38 +35,95 @@ export class CardDetailsModalComponent implements OnInit { @Input() parentName = ''; @Input() seriesId: number = 0; @Input() libraryId: number = 0; - @Input() data!: any; // Volume | Chapter + @Input() data!: Volume | Chapter; // Volume | Chapter + + /** + * If this is a volume, this will be first chapter for said volume. + */ + chapter!: Chapter; isChapter = false; chapters: Chapter[] = []; - seriesVolumes: any[] = []; - isLoadingVolumes = false; - formatKeys = Object.keys(MangaFormat); + + /** * If a cover image update occured. */ coverImageUpdate: boolean = false; - isAdmin: boolean = false; + coverImageIndex: number = 0; + /** + * Url of the selected cover + */ + selectedCover: string = ''; + coverImageLocked: boolean = false; + /** + * When the API is doing work + */ + coverImageSaveLoading: boolean = false; + imageUrls: Array = []; + + actions: ActionItem[] = []; chapterActions: ActionItem[] = []; libraryType: LibraryType = LibraryType.Manga; - series: Series | undefined = undefined; + + bookmarks: PageBookmark[] = []; + + tabs = [{title: 'General', disabled: false}, {title: 'Metadata', disabled: false}, {title: 'Cover', disabled: false}, {title: 'Bookmarks', disabled: false}, {title: 'Info', disabled: false}]; + active = this.tabs[0]; + + chapterMetadata!: ChapterMetadata; + ageRating!: string; + + + get Breakpoint(): typeof Breakpoint { + return Breakpoint; + } + + get PersonRole() { + return PersonRole; + } get LibraryType(): typeof LibraryType { return LibraryType; } - constructor(private modalService: NgbModal, public modal: NgbActiveModal, public utilityService: UtilityService, + constructor(public modal: NgbActiveModal, public utilityService: UtilityService, public imageService: ImageService, private uploadService: UploadService, private toastr: ToastrService, private accountService: AccountService, private actionFactoryService: ActionFactoryService, private actionService: ActionService, private router: Router, private libraryService: LibraryService, - private seriesService: SeriesService) { } + private seriesService: SeriesService, private readerService: ReaderService, public metadataService: MetadataService) { } ngOnInit(): void { this.isChapter = this.utilityService.isChapter(this.data); + console.log('isChapter: ', this.isChapter); + + this.chapter = this.utilityService.isChapter(this.data) ? (this.data as Chapter) : (this.data as Volume).chapters[0]; + + this.imageUrls.push(this.imageService.getChapterCoverImage(this.chapter.id)); + + let bookmarkApi; + if (this.isChapter) { + bookmarkApi = this.readerService.getBookmarks(this.chapter.id); + } else { + bookmarkApi = this.readerService.getBookmarksForVolume(this.data.id); + } + + bookmarkApi.pipe(take(1)).subscribe(bookmarks => { + this.bookmarks = bookmarks; + }); + + this.seriesService.getChapterMetadata(this.chapter.id).subscribe(metadata => { + this.chapterMetadata = metadata; + + this.metadataService.getAgeRating(this.chapterMetadata.ageRating).subscribe(ageRating => this.ageRating = ageRating); + }); + this.accountService.currentUser$.pipe(take(1)).subscribe(user => { if (user) { - this.isAdmin = this.accountService.hasAdminRole(user); + if (!this.accountService.hasAdminRole(user)) { + this.tabs.find(s => s.title === 'Cover')!.disabled = true; + } } }); @@ -72,10 +134,11 @@ export class CardDetailsModalComponent implements OnInit { this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this)).filter(item => item.action !== Action.Edit); if (this.isChapter) { - this.chapters.push(this.data); + this.chapters.push(this.data as Chapter); } else if (!this.isChapter) { - this.chapters.push(...this.data?.chapters); + this.chapters.push(...(this.data as Volume).chapters); } + // TODO: Move this into the backend this.chapters.sort(this.utilityService.sortChapters); this.chapters.forEach(c => c.coverImage = this.imageService.getChapterCoverImage(c.id)); // Try to show an approximation of the reading order for files @@ -83,10 +146,6 @@ export class CardDetailsModalComponent implements OnInit { this.chapters.forEach((c: Chapter) => { c.files.sort((a: MangaFile, b: MangaFile) => collator.compare(a.filePath, b.filePath)); }); - - this.seriesService.getSeries(this.seriesId).subscribe(series => { - this.series = series; - }) } close() { @@ -106,34 +165,36 @@ export class CardDetailsModalComponent implements OnInit { } } - updateCover() { - const modalRef = this.modalService.open(ChangeCoverImageModalComponent, { size: 'lg' }); // scrollable: true, size: 'lg', windowClass: 'scrollable-modal' (these don't work well on mobile) - if (this.utilityService.isChapter(this.data)) { - const chapter = this.utilityService.asChapter(this.data) - chapter.coverImage = this.imageService.getChapterCoverImage(chapter.id); - modalRef.componentInstance.chapter = chapter; - modalRef.componentInstance.title = 'Select ' + (chapter.isSpecial ? '' : this.utilityService.formatChapterName(this.libraryType, false, true)) + chapter.range + '\'s Cover'; - } else { - const volume = this.utilityService.asVolume(this.data); - const chapters = volume.chapters; - if (chapters && chapters.length > 0) { - modalRef.componentInstance.chapter = chapters[0]; - modalRef.componentInstance.title = 'Select Volume ' + volume.number + '\'s Cover'; - } - } - - modalRef.closed.subscribe((closeResult: {success: boolean, chapter: Chapter, coverImageUpdate: boolean}) => { - if (closeResult.success) { - this.coverImageUpdate = closeResult.coverImageUpdate; - if (!this.coverImageUpdate) { - this.uploadService.resetChapterCoverLock(closeResult.chapter.id).subscribe(() => { - this.toastr.info('Please refresh in a bit for the cover image to be reflected.'); - }); - } else { - closeResult.chapter.coverImage = this.imageService.randomize(this.imageService.getChapterCoverImage(closeResult.chapter.id)); + updateSelectedIndex(index: number) { + this.coverImageIndex = index; + } + + updateSelectedImage(url: string) { + this.selectedCover = url; + } + + handleReset() { + this.coverImageLocked = false; + } + + saveCoverImage() { + this.coverImageSaveLoading = true; + const selectedIndex = this.coverImageIndex || 0; + if (selectedIndex > 0) { + this.uploadService.updateChapterCoverImage(this.chapter.id, this.selectedCover).subscribe(() => { + if (this.coverImageIndex > 0) { + this.chapter.coverImageLocked = true; + this.coverImageUpdate = true; } - } - }); + this.coverImageSaveLoading = false; + }, err => this.coverImageSaveLoading = false); + } else if (this.coverImageLocked === false) { + this.uploadService.resetChapterCoverLock(this.chapter.id).subscribe(() => { + this.toastr.info('Cover image reset'); + this.coverImageSaveLoading = false; + this.coverImageUpdate = true; + }); + } } markChapterAsRead(chapter: Chapter) { @@ -180,4 +241,10 @@ export class CardDetailsModalComponent implements OnInit { this.router.navigate(['library', this.libraryId, 'series', this.seriesId, 'manga', chapter.id]); } } + + removeBookmark(bookmark: PageBookmark, index: number) { + this.readerService.unbookmark(bookmark.seriesId, bookmark.volumeId, bookmark.chapterId, bookmark.page).subscribe(() => { + this.bookmarks.splice(index, 1); + }); + } } diff --git a/UI/Web/src/app/cards/_modals/change-cover-image/change-cover-image-modal.component.html b/UI/Web/src/app/cards/_modals/change-cover-image/change-cover-image-modal.component.html deleted file mode 100644 index 0c04ec53a..000000000 --- a/UI/Web/src/app/cards/_modals/change-cover-image/change-cover-image-modal.component.html +++ /dev/null @@ -1,11 +0,0 @@ - - - \ No newline at end of file diff --git a/UI/Web/src/app/cards/_modals/change-cover-image/change-cover-image-modal.component.scss b/UI/Web/src/app/cards/_modals/change-cover-image/change-cover-image-modal.component.scss deleted file mode 100644 index 09b1e38fe..000000000 --- a/UI/Web/src/app/cards/_modals/change-cover-image/change-cover-image-modal.component.scss +++ /dev/null @@ -1,3 +0,0 @@ -.scrollable-modal { - -} \ No newline at end of file diff --git a/UI/Web/src/app/cards/_modals/change-cover-image/change-cover-image-modal.component.ts b/UI/Web/src/app/cards/_modals/change-cover-image/change-cover-image-modal.component.ts deleted file mode 100644 index aba42e595..000000000 --- a/UI/Web/src/app/cards/_modals/change-cover-image/change-cover-image-modal.component.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Component, Input, OnInit } from '@angular/core'; -import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; -import { Chapter } from 'src/app/_models/chapter'; -import { ImageService } from 'src/app/_services/image.service'; -import { UploadService } from 'src/app/_services/upload.service'; - -@Component({ - selector: 'app-change-cover-image-modal', - templateUrl: './change-cover-image-modal.component.html', - styleUrls: ['./change-cover-image-modal.component.scss'] -}) -export class ChangeCoverImageModalComponent implements OnInit { - - @Input() chapter!: Chapter; - @Input() title: string = ''; - - selectedCover: string = ''; - imageUrls: Array = []; - coverImageIndex: number = 0; - coverImageLocked: boolean = false; - loading: boolean = false; - - constructor(private imageService: ImageService, private uploadService: UploadService, public modal: NgbActiveModal) { } - - ngOnInit(): void { - // Randomization isn't needed as this is only the chooser - this.imageUrls.push(this.imageService.getChapterCoverImage(this.chapter.id)); - } - - cancel() { - this.modal.close({success: false, coverImageUpdate: false}) - } - save() { - this.loading = true; - if (this.coverImageIndex > 0) { - this.chapter.coverImageLocked = true; - this.uploadService.updateChapterCoverImage(this.chapter.id, this.selectedCover).subscribe(() => { - if (this.coverImageIndex > 0) { - this.chapter.coverImageLocked = true; - } - this.modal.close({success: true, chapter: this.chapter, coverImageUpdate: this.chapter.coverImageLocked}); - this.loading = false; - }, err => this.loading = false); - } else { - this.modal.close({success: true, chapter: this.chapter, coverImageUpdate: this.chapter.coverImageLocked}); - } - - - } - - updateSelectedIndex(index: number) { - this.coverImageIndex = index; - } - - updateSelectedImage(url: string) { - this.selectedCover = url; - } - - handleReset() { - this.coverImageLocked = false; - this.chapter.coverImageLocked = false; - this.modal.close({success: true, chapter: this.chapter, coverImageUpdate: this.chapter.coverImageLocked}); - } - -} 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 d6aee5d42..292c0c9d5 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 @@ -7,82 +7,321 @@ + + + + Field is locked + + + diff --git a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.scss b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.scss index 5bc8698c7..62a1c2f69 100644 --- a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.scss +++ b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.scss @@ -2,3 +2,10 @@ max-height: 90vh; // 600px overflow: auto; } + +.lock-active { + > .input-group-text { + background-color: var(--primary-color); + color: white; + } +} \ No newline at end of file diff --git a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts index c6b1e7452..4e0a8e787 100644 --- a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts +++ b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts @@ -1,17 +1,24 @@ import { Component, Input, OnDestroy, OnInit } from '@angular/core'; import { FormBuilder, FormControl, FormGroup } from '@angular/forms'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; -import { forkJoin, Subject } from 'rxjs'; +import { forkJoin, Observable, of, Subject } from 'rxjs'; import { map, takeUntil } from 'rxjs/operators'; import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service'; import { TypeaheadSettings } from 'src/app/typeahead/typeahead-settings'; import { Chapter } from 'src/app/_models/chapter'; import { CollectionTag } from 'src/app/_models/collection-tag'; +import { Genre } from 'src/app/_models/genre'; +import { AgeRatingDto } from 'src/app/_models/metadata/age-rating-dto'; +import { Language } from 'src/app/_models/metadata/language'; +import { PublicationStatusDto } from 'src/app/_models/metadata/publication-status-dto'; +import { Person, PersonRole } from 'src/app/_models/person'; import { Series } from 'src/app/_models/series'; import { SeriesMetadata } from 'src/app/_models/series-metadata'; +import { Tag } from 'src/app/_models/tag'; import { CollectionTagService } from 'src/app/_services/collection-tag.service'; import { ImageService } from 'src/app/_services/image.service'; import { LibraryService } from 'src/app/_services/library.service'; +import { MetadataService } from 'src/app/_services/metadata.service'; import { SeriesService } from 'src/app/_services/series.service'; import { UploadService } from 'src/app/_services/upload.service'; @@ -28,14 +35,27 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { isCollapsed = true; volumeCollapsed: any = {}; - tabs = ['General', 'Cover Image', 'Info']; + tabs = ['General', 'Metadata', 'People', 'Cover Image', 'Info']; active = this.tabs[0]; editSeriesForm!: FormGroup; libraryName: string | undefined = undefined; private readonly onDestroy = new Subject(); - settings: TypeaheadSettings = new TypeaheadSettings(); - tags: CollectionTag[] = []; + + // Typeaheads + ageRatingSettings: TypeaheadSettings = new TypeaheadSettings(); + publicationStatusSettings: TypeaheadSettings = new TypeaheadSettings(); + tagsSettings: TypeaheadSettings = new TypeaheadSettings(); + languageSettings: TypeaheadSettings = new TypeaheadSettings(); + peopleSettings: {[PersonRole: string]: TypeaheadSettings} = {}; + collectionTagSettings: TypeaheadSettings = new TypeaheadSettings(); + genreSettings: TypeaheadSettings = new TypeaheadSettings(); + + + collectionTags: CollectionTag[] = []; + tags: Tag[] = []; + genres: Genre[] = []; + metadata!: SeriesMetadata; imageUrls: Array = []; /** @@ -43,10 +63,22 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { */ selectedCover: string = ''; + ageRatings: Array = []; + publicationStatuses: Array = []; + validLanguages: Array = []; + get Breakpoint(): typeof Breakpoint { return Breakpoint; } + get PersonRole() { + return PersonRole; + } + + getPersonsSettings(role: PersonRole) { + return this.peopleSettings[role]; + } + constructor(public modal: NgbActiveModal, private seriesService: SeriesService, public utilityService: UtilityService, @@ -54,7 +86,8 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { public imageService: ImageService, private libraryService: LibraryService, private collectionService: CollectionTagService, - private uploadService: UploadService) { } + private uploadService: UploadService, + private metadataService: MetadataService) { } ngOnInit(): void { this.imageUrls.push(this.imageService.getSeriesCoverImage(this.series.id)); @@ -63,9 +96,6 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { this.libraryName = names[this.series.libraryId]; }); - this.setupTypeaheadSettings(); - - this.editSeriesForm = this.fb.group({ id: new FormControl(this.series.id, []), @@ -75,20 +105,70 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { sortName: new FormControl(this.series.sortName, []), rating: new FormControl(this.series.userRating, []), - genres: new FormControl('', []), - author: new FormControl('', []), - artist: new FormControl('', []), - coverImageIndex: new FormControl(0, []), - coverImageLocked: new FormControl(this.series.coverImageLocked, []) + coverImageLocked: new FormControl(this.series.coverImageLocked, []), + + ageRating: new FormControl('', []), + publicationStatus: new FormControl('', []), + language: new FormControl('', []), }); + + this.metadataService.getAllAgeRatings().subscribe(ratings => { + this.ageRatings = ratings; + }); + + this.metadataService.getAllPublicationStatus().subscribe(statuses => { + this.publicationStatuses = statuses; + }); + + this.metadataService.getAllValidLanguages().subscribe(validLanguages => { + this.validLanguages = validLanguages; + }) + this.seriesService.getMetadata(this.series.id).subscribe(metadata => { if (metadata) { this.metadata = metadata; - this.settings.savedData = metadata.collectionTags; - this.tags = metadata.collectionTags; + + this.setupTypeaheads(); this.editSeriesForm.get('summary')?.setValue(this.metadata.summary); + this.editSeriesForm.get('ageRating')?.setValue(this.metadata.ageRating); + this.editSeriesForm.get('publicationStatus')?.setValue(this.metadata.publicationStatus); + this.editSeriesForm.get('language')?.setValue(this.metadata.language); + + this.editSeriesForm.get('name')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => { + if (!this.editSeriesForm.get('name')?.touched) return; + this.series.nameLocked = true; + }); + + this.editSeriesForm.get('sortName')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => { + if (!this.editSeriesForm.get('sortName')?.touched) return; + this.series.sortNameLocked = true; + }); + + this.editSeriesForm.get('localizedName')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => { + if (!this.editSeriesForm.get('localizedName')?.touched) return; + this.series.localizedNameLocked = true; + }); + + this.editSeriesForm.get('summary')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => { + if (!this.editSeriesForm.get('summary')?.touched) return; + this.metadata.summaryLocked = true; + this.metadata.summary = val; + }); + + + this.editSeriesForm.get('ageRating')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => { + this.metadata.ageRating = parseInt(val + '', 10); + if (!this.editSeriesForm.get('ageRating')?.touched) return; + this.metadata.ageRatingLocked = true; + }); + + this.editSeriesForm.get('publicationStatus')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => { + this.metadata.publicationStatus = parseInt(val + '', 10); + if (!this.editSeriesForm.get('publicationStatus')?.touched) return; + this.metadata.publicationStatusLocked = true; + }); } }); @@ -114,22 +194,192 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { this.onDestroy.complete(); } - setupTypeaheadSettings() { - this.settings.minCharacters = 0; - this.settings.multiple = true; - this.settings.id = 'collections'; - this.settings.unique = true; - this.settings.addIfNonExisting = true; - this.settings.fetchFn = (filter: string) => this.fetchCollectionTags(filter).pipe(map(items => this.settings.compareFn(items, filter))); - this.settings.addTransformFn = ((title: string) => { + setupTypeaheads() { + forkJoin([ + this.setupCollectionTagsSettings(), + this.setupTagSettings(), + this.setupGenreTypeahead(), + this.setupPersonTypeahead(), + this.setupLanguageTypeahead() + ]).subscribe(results => { + this.collectionTags = this.metadata.collectionTags; + this.editSeriesForm.get('summary')?.setValue(this.metadata.summary); + }); + } + + setupCollectionTagsSettings() { + this.collectionTagSettings.minCharacters = 0; + this.collectionTagSettings.multiple = true; + this.collectionTagSettings.id = 'collections'; + this.collectionTagSettings.unique = true; + this.collectionTagSettings.addIfNonExisting = true; + this.collectionTagSettings.fetchFn = (filter: string) => this.fetchCollectionTags(filter).pipe(map(items => this.collectionTagSettings.compareFn(items, filter))); + this.collectionTagSettings.addTransformFn = ((title: string) => { return {id: 0, title: title, promoted: false, coverImage: '', summary: '', coverImageLocked: false }; }); - this.settings.compareFn = (options: CollectionTag[], filter: string) => { + this.collectionTagSettings.compareFn = (options: CollectionTag[], filter: string) => { return options.filter(m => this.utilityService.filter(m.title, filter)); } - this.settings.singleCompareFn = (a: CollectionTag, b: CollectionTag) => { + this.collectionTagSettings.singleCompareFn = (a: CollectionTag, b: CollectionTag) => { return a.id == b.id; } + + if (this.metadata.collectionTags) { + this.collectionTagSettings.savedData = this.metadata.collectionTags; + } + + return of(true); + } + + setupTagSettings() { + this.tagsSettings.minCharacters = 0; + this.tagsSettings.multiple = true; + this.tagsSettings.id = 'tags'; + this.tagsSettings.unique = true; + this.tagsSettings.showLocked = true; + this.tagsSettings.addIfNonExisting = true; + + + this.tagsSettings.compareFn = (options: Tag[], filter: string) => { + return options.filter(m => this.utilityService.filter(m.title, filter)); + } + this.tagsSettings.fetchFn = (filter: string) => this.metadataService.getAllTags() + .pipe(map(items => this.tagsSettings.compareFn(items, filter))); + + this.tagsSettings.addTransformFn = ((title: string) => { + return {id: 0, title: title }; + }); + this.tagsSettings.singleCompareFn = (a: Tag, b: Tag) => { + return a.id == b.id; + } + + if (this.metadata.tags) { + this.tagsSettings.savedData = this.metadata.tags; + } + return of(true); + } + + setupGenreTypeahead() { + this.genreSettings.minCharacters = 0; + this.genreSettings.multiple = true; + this.genreSettings.id = 'genres'; + this.genreSettings.unique = true; + this.genreSettings.showLocked = true; + this.genreSettings.addIfNonExisting = true; + this.genreSettings.fetchFn = (filter: string) => { + return this.metadataService.getAllGenres() + .pipe(map(items => this.genreSettings.compareFn(items, filter))); + }; + this.genreSettings.compareFn = (options: Genre[], filter: string) => { + return options.filter(m => this.utilityService.filter(m.title, filter)); + } + this.genreSettings.singleCompareFn = (a: Genre, b: Genre) => { + return a.title == b.title; + } + + this.genreSettings.addTransformFn = ((title: string) => { + return {id: 0, title: title }; + }); + + if (this.metadata.genres) { + this.genreSettings.savedData = this.metadata.genres; + } + return of(true); + } + + updateFromPreset(id: string, presetField: Array | undefined, role: PersonRole) { + const personSettings = this.createBlankPersonSettings(id, role) + if (presetField && presetField.length > 0) { + const fetch = personSettings.fetchFn as ((filter: string) => Observable); + return fetch('').pipe(map(people => { + const persetIds = presetField.map(p => p.id); + personSettings.savedData = people.filter(person => persetIds.includes(person.id)); + this.peopleSettings[role] = personSettings; + this.updatePerson(personSettings.savedData as Person[], role); + return true; + })); + } else { + this.peopleSettings[role] = personSettings; + return of(true); + } + } + + setupLanguageTypeahead() { + this.languageSettings.minCharacters = 0; + this.languageSettings.multiple = false; + this.languageSettings.id = 'language'; + this.languageSettings.unique = true; + this.languageSettings.showLocked = true; + this.languageSettings.addIfNonExisting = false; + this.languageSettings.compareFn = (options: Language[], filter: string) => { + return options.filter(m => this.utilityService.filter(m.title, filter)); + } + this.languageSettings.fetchFn = (filter: string) => of(this.validLanguages) + .pipe(map(items => this.languageSettings.compareFn(items, filter))); + + this.languageSettings.singleCompareFn = (a: Language, b: Language) => { + return a.isoCode == b.isoCode; + } + + if (this.metadata.language) { + const l = this.validLanguages.find(l => l.isoCode === this.metadata.language); + if (l !== undefined) { + this.languageSettings.savedData = l; + } + } + return of(true); + } + + setupPersonTypeahead() { + this.peopleSettings = {}; + + return forkJoin([ + this.updateFromPreset('writer', this.metadata.writers, PersonRole.Writer), + this.updateFromPreset('character', this.metadata.characters, PersonRole.Character), + this.updateFromPreset('colorist', this.metadata.colorists, PersonRole.Colorist), + this.updateFromPreset('cover-artist', this.metadata.coverArtists, PersonRole.CoverArtist), + this.updateFromPreset('editor', this.metadata.editors, PersonRole.Editor), + this.updateFromPreset('inker', this.metadata.inkers, PersonRole.Inker), + this.updateFromPreset('letterer', this.metadata.letterers, PersonRole.Letterer), + this.updateFromPreset('penciller', this.metadata.pencillers, PersonRole.Penciller), + this.updateFromPreset('publisher', this.metadata.publishers, PersonRole.Publisher), + this.updateFromPreset('translator', this.metadata.translators, PersonRole.Translator) + ]).pipe(map(results => { + //this.resetTypeaheads.next(true); + return of(true); + })); + } + + fetchPeople(role: PersonRole, filter: string) { + return this.metadataService.getAllPeople().pipe(map(people => { + return people.filter(p => p.role == role && this.utilityService.filter(p.name, filter)); + })); + } + + createBlankPersonSettings(id: string, role: PersonRole) { + var personSettings = new TypeaheadSettings(); + personSettings.minCharacters = 0; + personSettings.multiple = true; + personSettings.showLocked = true; + personSettings.unique = true; + personSettings.addIfNonExisting = true; + personSettings.id = id; + personSettings.compareFn = (options: Person[], filter: string) => { + return options.filter(m => this.utilityService.filter(m.name, filter)); + } + + personSettings.singleCompareFn = (a: Person, b: Person) => { + return a.name == b.name && a.role == b.role; + } + personSettings.fetchFn = (filter: string) => { + return this.fetchPeople(role, filter).pipe(map(items => personSettings.compareFn(items, filter))); + }; + + personSettings.addTransformFn = ((title: string) => { + return {id: 0, name: title, role: role }; + }); + + return personSettings; } close() { @@ -150,11 +400,17 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { save() { const model = this.editSeriesForm.value; const selectedIndex = this.editSeriesForm.get('coverImageIndex')?.value || 0; + const apis = [ - this.seriesService.updateSeries(model), - this.seriesService.updateMetadata(this.metadata, this.tags) + this.seriesService.updateMetadata(this.metadata, this.collectionTags) ]; + // We only need to call updateSeries if we changed name, sort name, or localized name + if (this.editSeriesForm.get('name')?.dirty || this.editSeriesForm.get('sortName')?.dirty || this.editSeriesForm.get('localizedName')?.dirty) { + apis.push(this.seriesService.updateSeries(model)); + } + + if (selectedIndex > 0) { apis.push(this.uploadService.updateSeriesCoverImage(model.id, this.selectedCover)); @@ -165,8 +421,65 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { }); } + handleUnlock(field: string) { + console.log('todo: unlock ', field); + } + + hello(val: boolean) { + console.log('hello: ', val); + } + updateCollections(tags: CollectionTag[]) { + this.collectionTags = tags; + } + + updateTags(tags: Tag[]) { this.tags = tags; + this.metadata.tags = tags; + } + + updateGenres(genres: Genre[]) { + this.genres = genres; + this.metadata.genres = genres; + } + + updateLanguage(language: Language) { + this.metadata.language = language.isoCode; + } + + updatePerson(persons: Person[], role: PersonRole) { + switch (role) { + case PersonRole.CoverArtist: + this.metadata.coverArtists = persons; + break; + case PersonRole.Character: + this.metadata.characters = persons; + break; + case PersonRole.Colorist: + this.metadata.colorists = persons; + break; + case PersonRole.Editor: + this.metadata.editors = persons; + break; + case PersonRole.Inker: + this.metadata.inkers = persons; + break; + case PersonRole.Letterer: + this.metadata.letterers = persons; + break; + case PersonRole.Penciller: + this.metadata.pencillers = persons; + break; + case PersonRole.Publisher: + this.metadata.publishers = persons; + break; + case PersonRole.Writer: + this.metadata.writers = persons; + break; + case PersonRole.Translator: + this.metadata.translators = persons; + + } } updateSelectedIndex(index: number) { @@ -185,4 +498,10 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { }); } + unlock(b: any, field: string) { + if (b) { + b[field] = !b[field]; + } + } + } diff --git a/UI/Web/src/app/cards/cards.module.ts b/UI/Web/src/app/cards/cards.module.ts index a64f3a056..8f44a076b 100644 --- a/UI/Web/src/app/cards/cards.module.ts +++ b/UI/Web/src/app/cards/cards.module.ts @@ -5,7 +5,6 @@ import { LibraryCardComponent } from './library-card/library-card.component'; import { CoverImageChooserComponent } from './cover-image-chooser/cover-image-chooser.component'; import { EditSeriesModalComponent } from './_modals/edit-series-modal/edit-series-modal.component'; import { EditCollectionTagsComponent } from './_modals/edit-collection-tags/edit-collection-tags.component'; -import { ChangeCoverImageModalComponent } from './_modals/change-cover-image/change-cover-image-modal.component'; import { BookmarksModalComponent } from './_modals/bookmarks-modal/bookmarks-modal.component'; import { NgbTooltipModule, NgbCollapseModule, NgbPaginationModule, NgbDropdownModule, NgbProgressbarModule, NgbNavModule, NgbRatingModule } from '@ng-bootstrap/ng-bootstrap'; import { CardActionablesComponent } from './card-item/card-actionables/card-actionables.component'; @@ -34,7 +33,6 @@ import { BookmarkComponent } from './bookmark/bookmark.component'; CoverImageChooserComponent, EditSeriesModalComponent, EditCollectionTagsComponent, - ChangeCoverImageModalComponent, BookmarksModalComponent, CardActionablesComponent, CardDetailLayoutComponent, @@ -75,7 +73,6 @@ import { BookmarkComponent } from './bookmark/bookmark.component'; CoverImageChooserComponent, EditSeriesModalComponent, EditCollectionTagsComponent, - ChangeCoverImageModalComponent, BookmarksModalComponent, CardActionablesComponent, CardDetailLayoutComponent, diff --git a/UI/Web/src/app/cards/chapter-metadata-detail/chapter-metadata-detail.component.html b/UI/Web/src/app/cards/chapter-metadata-detail/chapter-metadata-detail.component.html index 716ebd02f..349f2e746 100644 --- a/UI/Web/src/app/cards/chapter-metadata-detail/chapter-metadata-detail.component.html +++ b/UI/Web/src/app/cards/chapter-metadata-detail/chapter-metadata-detail.component.html @@ -1,118 +1,102 @@ -
- - - - + + + No metadata available +
-
- Id: {{chapter.id}} +
+
Writers
+ + + + +
-
- -
-
- Title: {{chapter.titleName || '-'}} -
-
- Pages: {{chapter.pages}} -
-
- -
-
- Added: {{(chapter.created | date: 'short') || '-'}} -
-
- Release Date: {{(chapter.releaseDate | date: 'shortDate') || '-'}} -
-
-
-
    -
  • - - - -
    -
    - - - - {{chapter.pagesRead}} / {{chapter.pages}} - UNREAD - READ - - - Files -
    -
      - -
    - - - -
    -
    -
    Writers
    -
    -
    - -
    -
    - -
    -
    -
    Artists
    -
    -
    - -
    -
    - -
    -
    -
    Publishers
    -
    -
    - -
    -
    -
    +
    +
    Cover Artists
    + + + + + +
    + +
    +
    Pencillers
    + + + + + +
    + +
    +
    Inkers
    + + + + + +
    + +
    +
    Colorists
    + + + + +
    -
  • -
-
- \ No newline at end of file + + \ No newline at end of file diff --git a/UI/Web/src/app/cards/chapter-metadata-detail/chapter-metadata-detail.component.ts b/UI/Web/src/app/cards/chapter-metadata-detail/chapter-metadata-detail.component.ts index 2c203aed0..1a3db1cc2 100644 --- a/UI/Web/src/app/cards/chapter-metadata-detail/chapter-metadata-detail.component.ts +++ b/UI/Web/src/app/cards/chapter-metadata-detail/chapter-metadata-detail.component.ts @@ -5,6 +5,7 @@ import { ChapterMetadata } from 'src/app/_models/chapter-metadata'; import { UtilityService } from 'src/app/shared/_services/utility.service'; import { LibraryType } from 'src/app/_models/library'; import { ActionItem } from 'src/app/_services/action-factory.service'; +import { PersonRole } from 'src/app/_models/person'; @Component({ selector: 'app-chapter-metadata-detail', @@ -13,9 +14,10 @@ import { ActionItem } from 'src/app/_services/action-factory.service'; }) export class ChapterMetadataDetailComponent implements OnInit { - @Input() chapter!: Chapter; + @Input() chapter!: ChapterMetadata; @Input() libraryType: LibraryType = LibraryType.Manga; - //metadata!: ChapterMetadata; + + roles: string[] = []; get LibraryType(): typeof LibraryType { return LibraryType; @@ -24,10 +26,14 @@ export class ChapterMetadataDetailComponent implements OnInit { constructor(private metadataService: MetadataService, public utilityService: UtilityService) { } ngOnInit(): void { - // this.metadataService.getChapterMetadata(this.chapter.id).subscribe(metadata => { - // console.log('Chapter ', this.chapter.number, ' metadata: ', metadata); - // this.metadata = metadata; - // }) + this.roles = Object.keys(PersonRole).filter(role => /[0-9]/.test(role) === false); + } + + getPeople(role: string) { + if (this.chapter) { + return (this.chapter as any)[role.toLowerCase()]; + } + return []; } performAction(action: ActionItem, chapter: Chapter) { diff --git a/UI/Web/src/app/manga-reader/manga-reader.component.html b/UI/Web/src/app/manga-reader/manga-reader.component.html index d12e2d09e..864dc22d1 100644 --- a/UI/Web/src/app/manga-reader/manga-reader.component.html +++ b/UI/Web/src/app/manga-reader/manga-reader.component.html @@ -24,12 +24,13 @@
- + -
- + +
+
{ * Id of the input element, for linking label elements (accessibility) */ id: string = ''; + /** + * Show a locked icon next to input and provide functionality around locking/unlocking a field + */ + showLocked: boolean = false; /** * Data to preload the typeahead with on first load */ diff --git a/UI/Web/src/app/typeahead/typeahead.component.html b/UI/Web/src/app/typeahead/typeahead.component.html index 38f3e5826..bacb08e08 100644 --- a/UI/Web/src/app/typeahead/typeahead.component.html +++ b/UI/Web/src/app/typeahead/typeahead.component.html @@ -1,36 +1,40 @@
- -
-
- - - - +
+ + + Field is locked + + +
+ + + + - -
- Loading... -
- + +
+ Loading...
-
- + + + +
+
- - - \ No newline at end of file + + \ No newline at end of file diff --git a/UI/Web/src/app/typeahead/typeahead.component.scss b/UI/Web/src/app/typeahead/typeahead.component.scss index 9ee84055d..e9a1ce65d 100644 --- a/UI/Web/src/app/typeahead/typeahead.component.scss +++ b/UI/Web/src/app/typeahead/typeahead.component.scss @@ -2,6 +2,10 @@ form { position: relative; } +.input-group { + flex-wrap: inherit; +} + input { width: 15px; opacity: 1px; @@ -10,6 +14,18 @@ input { border: none; } +.lock-active { + > .input-group-text { + background-color: var(--primary-color); + color: white; + } +} + +.close-offset { + right: 29px !important; + top: 29% !important; +} + .typeahead-input { padding: 0px 6px; display: inline-block; @@ -44,14 +60,22 @@ input { } } +.open .input-group-text { + border-bottom-left-radius: 0px; +} + +.open .typeahead-input { + border-bottom-left-radius: 0px; + border-bottom-right-radius: 0px; +} + .dropdown { width: 100%; min-width: 10rem; background: var(--input-bg-color); - z-index:1000; - margin: 2px 0 0; + z-index: 1000; border-radius: 4px; - margin-top: -7px; + margin-top: -1px; border-top-left-radius: 0px; border-top-right-radius: 0px; position: absolute; @@ -59,6 +83,11 @@ input { overflow-y: auto; overflow-x: hidden; + .list-group { + border-top-left-radius: 0px; + border-top-right-radius: 0px; + } + .list-group-item { padding: 5px 10px; width: 100%; diff --git a/UI/Web/src/app/typeahead/typeahead.component.ts b/UI/Web/src/app/typeahead/typeahead.component.ts index a02ccdd08..321958d9e 100644 --- a/UI/Web/src/app/typeahead/typeahead.component.ts +++ b/UI/Web/src/app/typeahead/typeahead.component.ts @@ -137,30 +137,36 @@ export class SelectionModel { styleUrls: ['./typeahead.component.scss'] }) export class TypeaheadComponent implements OnInit, OnDestroy { - - filteredOptions!: Observable; - isLoadingOptions: boolean = false; - typeaheadControl!: FormControl; - typeaheadForm!: FormGroup; - - + /** + * Settings for the typeahead + */ @Input() settings!: TypeaheadSettings; /** * When true, component will re-init and set back to false. */ @Input() reset: Subject = new ReplaySubject(1); + /** + * When a field is locked, we render custom css to indicate to the user. Does not affect functionality. + */ + @Input() locked: boolean = false; @Output() selectedData = new EventEmitter(); @Output() newItemAdded = new EventEmitter(); + @Output() onUnlock = new EventEmitter(); + @Output() lockedChange = new EventEmitter(); + + @ViewChild('input') inputElem!: ElementRef; + @ContentChild('optionItem') optionTemplate!: TemplateRef; + @ContentChild('badgeItem') badgeTemplate!: TemplateRef; optionSelection!: SelectionModel; hasFocus = false; // Whether input has active focus focusedIndex: number = 0; showAddItem: boolean = false; - - @ViewChild('input') inputElem!: ElementRef; - @ContentChild('optionItem') optionTemplate!: TemplateRef; - @ContentChild('badgeItem') badgeTemplate!: TemplateRef; + filteredOptions!: Observable; + isLoadingOptions: boolean = false; + typeaheadControl!: FormControl; + typeaheadForm!: FormGroup; private readonly onDestroy = new Subject(); @@ -245,10 +251,17 @@ export class TypeaheadComponent implements OnInit, OnDestroy { if (this.settings.multiple) { this.optionSelection = new SelectionModel(true, this.settings.savedData); } - // else { - // this.optionSelection = new SelectionModel(true, this.settings.savedData[0]); - // this.typeaheadControl.setValue(this.settings.displayFn(this.settings.savedData)) - // } + else { + const isArray = this.settings.savedData.hasOwnProperty('length'); + if (isArray) { + this.optionSelection = new SelectionModel(true, this.settings.savedData); + } else { + this.optionSelection = new SelectionModel(true, [this.settings.savedData]); + } + + + //this.typeaheadControl.setValue(this.settings.displayFn(this.settings.savedData)) + } } else { this.optionSelection = new SelectionModel(); } @@ -336,7 +349,17 @@ export class TypeaheadComponent implements OnInit, OnDestroy { this.resetField(); } + clearSelections(event: any) { + this.optionSelection.selected().forEach(item => this.optionSelection.toggle(item, false)); + this.selectedData.emit(this.optionSelection.selected()); + this.resetField(); + } + handleOptionClick(opt: any) { + if (!this.settings.multiple && this.optionSelection.selected().length > 0) { + return; + } + this.toggleSelection(opt); this.resetField(); @@ -375,12 +398,17 @@ export class TypeaheadComponent implements OnInit, OnDestroy { event.preventDefault(); } + if (!this.settings.multiple && this.optionSelection.selected().length > 0) { + return; + } + if (this.inputElem) { // hack: To prevent multiple typeaheads from being open at once, click document then trigger the focus document.querySelector('body')?.click(); this.inputElem.nativeElement.focus(); this.hasFocus = true; } + this.openDropdown(); } @@ -415,4 +443,10 @@ export class TypeaheadComponent implements OnInit, OnDestroy { } + unlock(event: any) { + this.locked = !this.locked; + this.onUnlock.emit(); + this.lockedChange.emit(this.locked); + } + } diff --git a/UI/Web/src/theme/components/_dropdown.scss b/UI/Web/src/theme/components/_dropdown.scss index 0826259f0..424267ae0 100644 --- a/UI/Web/src/theme/components/_dropdown.scss +++ b/UI/Web/src/theme/components/_dropdown.scss @@ -13,4 +13,8 @@ text-decoration: none; } } +} + +.dropdown { + z-index: 1055 !important; // ngb v12 bug: https://github.com/ng-bootstrap/ng-bootstrap/issues/2686 } \ No newline at end of file