diff --git a/API.Tests/Helpers/TagHelperTests.cs b/API.Tests/Helpers/TagHelperTests.cs new file mode 100644 index 000000000..5370d9971 --- /dev/null +++ b/API.Tests/Helpers/TagHelperTests.cs @@ -0,0 +1,119 @@ +using System.Collections.Generic; +using API.Data; +using API.Entities; +using API.Helpers; +using Xunit; + +namespace API.Tests.Helpers; + +public class TagHelperTests +{ + [Fact] + public void UpdateTag_ShouldAddNewTag() + { + var allTags = new List + { + DbFactory.Tag("Action", false), + DbFactory.Tag("action", false), + DbFactory.Tag("Sci-fi", false), + }; + var tagAdded = new List(); + + TagHelper.UpdateTag(allTags, new[] {"Action", "Adventure"}, false, (tag, added) => + { + if (added) + { + tagAdded.Add(tag); + } + + }); + + Assert.Equal(1, tagAdded.Count); + Assert.Equal(4, allTags.Count); + } + + [Fact] + public void UpdateTag_ShouldNotAddDuplicateTag() + { + var allTags = new List + { + DbFactory.Tag("Action", false), + DbFactory.Tag("action", false), + DbFactory.Tag("Sci-fi", false), + + }; + var tagAdded = new List(); + + TagHelper.UpdateTag(allTags, new[] {"Action", "Scifi"}, false, (tag, added) => + { + if (added) + { + tagAdded.Add(tag); + } + TagHelper.AddTagIfNotExists(allTags, tag); + }); + + Assert.Equal(3, allTags.Count); + Assert.Empty(tagAdded); + } + + [Fact] + public void AddTag_ShouldAddOnlyNonExistingTag() + { + var existingTags = new List + { + DbFactory.Tag("Action", false), + DbFactory.Tag("action", false), + DbFactory.Tag("Sci-fi", false), + }; + + + TagHelper.AddTagIfNotExists(existingTags, DbFactory.Tag("Action", false)); + Assert.Equal(3, existingTags.Count); + + TagHelper.AddTagIfNotExists(existingTags, DbFactory.Tag("action", false)); + Assert.Equal(3, existingTags.Count); + + TagHelper.AddTagIfNotExists(existingTags, DbFactory.Tag("Shonen", false)); + Assert.Equal(4, existingTags.Count); + } + + [Fact] + public void AddTag_ShouldNotAddSameNameAndExternal() + { + var existingTags = new List + { + DbFactory.Tag("Action", false), + DbFactory.Tag("action", false), + DbFactory.Tag("Sci-fi", false), + }; + + + TagHelper.AddTagIfNotExists(existingTags, DbFactory.Tag("Action", true)); + Assert.Equal(3, existingTags.Count); + } + + [Fact] + public void KeepOnlySamePeopleBetweenLists() + { + var existingTags = new List + { + DbFactory.Tag("Action", false), + DbFactory.Tag("Sci-fi", false), + }; + + var peopleFromChapters = new List + { + DbFactory.Tag("Action", false), + }; + + var tagRemoved = new List(); + TagHelper.KeepOnlySameTagBetweenLists(existingTags, + peopleFromChapters, tag => + { + tagRemoved.Add(tag); + }); + + Assert.Equal(1, tagRemoved.Count); + } +} diff --git a/API/Controllers/MetadataController.cs b/API/Controllers/MetadataController.cs index b0fcd9930..4c36b7f46 100644 --- a/API/Controllers/MetadataController.cs +++ b/API/Controllers/MetadataController.cs @@ -1,8 +1,14 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; using System.Threading.Tasks; using API.Data; using API.DTOs; +using API.DTOs.Filtering; using API.DTOs.Metadata; +using API.Entities.Enums; +using Kavita.Common.Extensions; using Microsoft.AspNetCore.Mvc; namespace API.Controllers; @@ -17,15 +23,97 @@ public class MetadataController : BaseApiController _unitOfWork = unitOfWork; } + /// + /// Fetches genres from the instance + /// + /// String separated libraryIds or null for all genres + /// [HttpGet("genres")] - public async Task>> GetAllGenres() + public async Task>> GetAllGenres(string? libraryIds) { + var ids = libraryIds?.Split(",").Select(int.Parse).ToList(); + if (ids != null && ids.Count > 0) + { + return Ok(await _unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(ids)); + } + return Ok(await _unitOfWork.GenreRepository.GetAllGenreDtosAsync()); } + /// + /// Fetches people from the instance + /// + /// String separated libraryIds or null for all people + /// [HttpGet("people")] - public async Task>> GetAllPeople() + public async Task>> GetAllPeople(string? libraryIds) { + var ids = libraryIds?.Split(",").Select(int.Parse).ToList(); + if (ids != null && ids.Count > 0) + { + return Ok(await _unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(ids)); + } return Ok(await _unitOfWork.PersonRepository.GetAllPeople()); } + + /// + /// Fetches all tags from the instance + /// + /// String separated libraryIds or null for all tags + /// + [HttpGet("tags")] + public async Task>> GetAllTags(string? libraryIds) + { + var ids = libraryIds?.Split(",").Select(int.Parse).ToList(); + if (ids != null && ids.Count > 0) + { + return Ok(await _unitOfWork.TagRepository.GetAllTagDtosForLibrariesAsync(ids)); + } + return Ok(await _unitOfWork.TagRepository.GetAllTagDtosAsync()); + } + + /// + /// Fetches all age ratings from the instance + /// + /// String separated libraryIds or null for all ratings + /// + [HttpGet("age-ratings")] + public async Task>> GetAllAgeRatings(string? libraryIds) + { + var ids = libraryIds?.Split(",").Select(int.Parse).ToList(); + if (ids != null && ids.Count > 0) + { + return Ok(await _unitOfWork.SeriesRepository.GetAllAgeRatingsDtosForLibrariesAsync(ids)); + } + + return Ok(Enum.GetValues().Select(t => new AgeRatingDto() + { + Title = t.ToDescription(), + Value = t + })); + } + + /// + /// Fetches all age ratings from the instance + /// + /// String separated libraryIds or null for all ratings + /// + [HttpGet("languages")] + public async Task>> GetAllLanguages(string? libraryIds) + { + var ids = libraryIds?.Split(",").Select(int.Parse).ToList(); + if (ids != null && ids.Count > 0) + { + return Ok(await _unitOfWork.SeriesRepository.GetAllLanguagesForLibrariesAsync(ids)); + } + + return Ok(new List() + { + new () + { + Title = CultureInfo.GetCultureInfo("en").DisplayName, + IsoCode = "en" + } + }); + } } diff --git a/API/DTOs/ChapterDto.cs b/API/DTOs/ChapterDto.cs index 0637d4fb9..97bdd4310 100644 --- a/API/DTOs/ChapterDto.cs +++ b/API/DTOs/ChapterDto.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using API.DTOs.Metadata; +using API.Entities; namespace API.DTOs { @@ -68,5 +70,7 @@ namespace API.DTOs 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/Filtering/FilterDto.cs b/API/DTOs/Filtering/FilterDto.cs index a745b9f0e..011b94e5b 100644 --- a/API/DTOs/Filtering/FilterDto.cs +++ b/API/DTOs/Filtering/FilterDto.cs @@ -1,6 +1,4 @@ -using System.Collections; -using System.Collections.Generic; -using API.Data.Migrations; +using System.Collections.Generic; using API.Entities; using API.Entities.Enums; @@ -63,14 +61,34 @@ namespace API.DTOs.Filtering /// public IList Character { get; init; } = new List(); /// + /// A list of Translator ids to restrict search to. Defaults to all genres by passing an empty list + /// + public IList Translators { get; init; } = new List(); + /// /// A list of Collection Tag ids to restrict search to. Defaults to all genres by passing an empty list /// public IList CollectionTags { get; init; } = new List(); /// + /// A list of Tag ids to restrict search to. Defaults to all genres by passing an empty list + /// + public IList Tags { get; init; } = new List(); + /// /// Will return back everything with the rating and above /// /// public int Rating { get; init; } + /// + /// Sorting Options for a query. Defaults to null, which uses the queries natural sorting order + /// + public SortOptions SortOptions { get; init; } = null; + /// + /// Age Ratings. Empty list will return everything back + /// + public IList AgeRating { get; init; } = new List(); + /// + /// Languages (ISO 639-1 code) to filter by. Empty list will return everything back + /// + public IList Languages { get; init; } = new List(); } } diff --git a/API/DTOs/Filtering/LanguageDto.cs b/API/DTOs/Filtering/LanguageDto.cs new file mode 100644 index 000000000..b09aed5d1 --- /dev/null +++ b/API/DTOs/Filtering/LanguageDto.cs @@ -0,0 +1,7 @@ +namespace API.DTOs.Filtering; + +public class LanguageDto +{ + public string IsoCode { get; set; } + public string Title { get; set; } +} diff --git a/API/DTOs/Filtering/SortField.cs b/API/DTOs/Filtering/SortField.cs new file mode 100644 index 000000000..0e465f6aa --- /dev/null +++ b/API/DTOs/Filtering/SortField.cs @@ -0,0 +1,8 @@ +namespace API.DTOs.Filtering; + +public enum SortField +{ + SortName = 1, + CreatedDate = 2, + LastModifiedDate = 3, +} diff --git a/API/DTOs/Filtering/SortOptions.cs b/API/DTOs/Filtering/SortOptions.cs new file mode 100644 index 000000000..00bf91675 --- /dev/null +++ b/API/DTOs/Filtering/SortOptions.cs @@ -0,0 +1,10 @@ +namespace API.DTOs.Filtering; + +/// +/// Sorting Options for a query +/// +public class SortOptions +{ + public SortField SortField { get; set; } + public bool IsAscending { get; set; } = true; +} diff --git a/API/DTOs/Metadata/AgeRatingDto.cs b/API/DTOs/Metadata/AgeRatingDto.cs new file mode 100644 index 000000000..cbeb44e33 --- /dev/null +++ b/API/DTOs/Metadata/AgeRatingDto.cs @@ -0,0 +1,9 @@ +using API.Entities.Enums; + +namespace API.DTOs.Metadata; + +public class AgeRatingDto +{ + public AgeRating Value { get; set; } + public string Title { get; set; } +} diff --git a/API/DTOs/Metadata/GenreTagDto.cs b/API/DTOs/Metadata/GenreTagDto.cs index 5a4315559..e6ea03130 100644 --- a/API/DTOs/Metadata/GenreTagDto.cs +++ b/API/DTOs/Metadata/GenreTagDto.cs @@ -4,6 +4,5 @@ { public int Id { get; set; } public string Title { get; set; } - } } diff --git a/API/DTOs/Metadata/TagDto.cs b/API/DTOs/Metadata/TagDto.cs new file mode 100644 index 000000000..6e9b2f71e --- /dev/null +++ b/API/DTOs/Metadata/TagDto.cs @@ -0,0 +1,7 @@ +namespace API.DTOs.Metadata; + +public class TagDto +{ + public int Id { get; set; } + public string Title { get; set; } +} diff --git a/API/DTOs/SeriesMetadataDto.cs b/API/DTOs/SeriesMetadataDto.cs index 1c36aa762..1ac361ed8 100644 --- a/API/DTOs/SeriesMetadataDto.cs +++ b/API/DTOs/SeriesMetadataDto.cs @@ -9,8 +9,18 @@ namespace API.DTOs { public int Id { get; set; } public string Summary { get; set; } - public ICollection Tags { get; set; } + /// + /// Collections the Series belongs to + /// + public ICollection CollectionTags { get; set; } + /// + /// Genres for the Series + /// public ICollection Genres { get; set; } + /// + /// Collection of all Tags from underlying chapters for a Series + /// + public ICollection Tags { get; set; } public ICollection Writers { get; set; } = new List(); public ICollection Artists { get; set; } = new List(); public ICollection Publishers { get; set; } = new List(); @@ -20,6 +30,7 @@ namespace API.DTOs 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(); /// /// Highest Age Rating from all Chapters /// @@ -28,6 +39,10 @@ namespace API.DTOs /// Earliest Year from all chapters /// public int ReleaseYear { get; set; } + /// + /// Language of the content (ISO 639-1 code) + /// + public string Language { get; set; } = string.Empty; public int SeriesId { get; set; } } diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index c264792a6..c1e100d48 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -39,6 +39,7 @@ namespace API.Data public DbSet ReadingListItem { get; set; } public DbSet Person { get; set; } public DbSet Genre { get; set; } + public DbSet Tag { get; set; } protected override void OnModelCreating(ModelBuilder builder) diff --git a/API/Data/DbFactory.cs b/API/Data/DbFactory.cs index 5638bae00..e32be0450 100644 --- a/API/Data/DbFactory.cs +++ b/API/Data/DbFactory.cs @@ -91,6 +91,16 @@ namespace API.Data }; } + public static Tag Tag(string name, bool external) + { + return new Tag() + { + Title = name.Trim().SentenceCase(), + NormalizedTitle = Parser.Parser.Normalize(name), + ExternalTag = external + }; + } + public static Person Person(string name, PersonRole role) { return new Person() diff --git a/API/Data/Metadata/ComicInfo.cs b/API/Data/Metadata/ComicInfo.cs index bab5f2bc4..c34a305fd 100644 --- a/API/Data/Metadata/ComicInfo.cs +++ b/API/Data/Metadata/ComicInfo.cs @@ -20,6 +20,9 @@ namespace API.Data.Metadata public string Genre { get; set; } = string.Empty; public int PageCount { get; set; } // ReSharper disable once InconsistentNaming + /// + /// ISO 639-1 Code to represent the language of the content + /// public string LanguageISO { get; set; } = string.Empty; /// /// This is the link to where the data was scraped from @@ -51,8 +54,16 @@ namespace API.Data.Metadata /// public string TitleSort { get; set; } = string.Empty; - - + /// + /// The translator, can be comma separated. This is part of ComicInfo.xml draft v2.1 + /// + /// See https://github.com/anansi-project/comicinfo/issues/2 for information about this tag + public string Translator { get; set; } = string.Empty; + /// + /// Misc tags. This is part of ComicInfo.xml draft v2.1 + /// + /// See https://github.com/anansi-project/comicinfo/issues/1 for information about this tag + public string Tags { get; set; } = string.Empty; /// /// This is the Author. For Books, we map creator tag in OPF to this field. Comma separated if multiple. diff --git a/API/Data/Migrations/20211216150752_seriesAndChapterTags.Designer.cs b/API/Data/Migrations/20211216150752_seriesAndChapterTags.Designer.cs new file mode 100644 index 000000000..845aa8e7b --- /dev/null +++ b/API/Data/Migrations/20211216150752_seriesAndChapterTags.Designer.cs @@ -0,0 +1,1311 @@ +// +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("20211216150752_seriesAndChapterTags")] + partial class seriesAndChapterTags + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.1"); + + 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("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("SiteDarkMode") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + 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("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("GenreId") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + 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("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GenreId"); + + 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("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + 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("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + 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.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("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.Navigation("AppUser"); + }); + + 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.Genre", null) + .WithMany("Chapters") + .HasForeignKey("GenreId"); + + 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("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.Genre", b => + { + b.Navigation("Chapters"); + }); + + 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/20211216150752_seriesAndChapterTags.cs b/API/Data/Migrations/20211216150752_seriesAndChapterTags.cs new file mode 100644 index 000000000..4203068bd --- /dev/null +++ b/API/Data/Migrations/20211216150752_seriesAndChapterTags.cs @@ -0,0 +1,103 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class seriesAndChapterTags : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Tag", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Title = table.Column(type: "TEXT", nullable: true), + NormalizedTitle = table.Column(type: "TEXT", nullable: true), + ExternalTag = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Tag", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "ChapterTag", + columns: table => new + { + ChaptersId = table.Column(type: "INTEGER", nullable: false), + TagsId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ChapterTag", x => new { x.ChaptersId, x.TagsId }); + table.ForeignKey( + name: "FK_ChapterTag_Chapter_ChaptersId", + column: x => x.ChaptersId, + principalTable: "Chapter", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ChapterTag_Tag_TagsId", + column: x => x.TagsId, + principalTable: "Tag", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "SeriesMetadataTag", + columns: table => new + { + SeriesMetadatasId = table.Column(type: "INTEGER", nullable: false), + TagsId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SeriesMetadataTag", x => new { x.SeriesMetadatasId, x.TagsId }); + table.ForeignKey( + name: "FK_SeriesMetadataTag_SeriesMetadata_SeriesMetadatasId", + column: x => x.SeriesMetadatasId, + principalTable: "SeriesMetadata", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_SeriesMetadataTag_Tag_TagsId", + column: x => x.TagsId, + principalTable: "Tag", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ChapterTag_TagsId", + table: "ChapterTag", + column: "TagsId"); + + migrationBuilder.CreateIndex( + name: "IX_SeriesMetadataTag_TagsId", + table: "SeriesMetadataTag", + column: "TagsId"); + + migrationBuilder.CreateIndex( + name: "IX_Tag_NormalizedTitle_ExternalTag", + table: "Tag", + columns: new[] { "NormalizedTitle", "ExternalTag" }, + unique: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ChapterTag"); + + migrationBuilder.DropTable( + name: "SeriesMetadataTag"); + + migrationBuilder.DropTable( + name: "Tag"); + } + } +} diff --git a/API/Data/Migrations/20211216191436_seriesLanguage.Designer.cs b/API/Data/Migrations/20211216191436_seriesLanguage.Designer.cs new file mode 100644 index 000000000..5f53e0c6d --- /dev/null +++ b/API/Data/Migrations/20211216191436_seriesLanguage.Designer.cs @@ -0,0 +1,1314 @@ +// +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("20211216191436_seriesLanguage")] + partial class seriesLanguage + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.1"); + + 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("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("SiteDarkMode") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + 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("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("GenreId") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + 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("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GenreId"); + + 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("Language") + .HasColumnType("TEXT"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + 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("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + 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.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("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.Navigation("AppUser"); + }); + + 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.Genre", null) + .WithMany("Chapters") + .HasForeignKey("GenreId"); + + 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("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.Genre", b => + { + b.Navigation("Chapters"); + }); + + 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/20211216191436_seriesLanguage.cs b/API/Data/Migrations/20211216191436_seriesLanguage.cs new file mode 100644 index 000000000..8b48546cf --- /dev/null +++ b/API/Data/Migrations/20211216191436_seriesLanguage.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class seriesLanguage : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Language", + table: "SeriesMetadata", + type: "TEXT", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Language", + table: "SeriesMetadata"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index b20f13f6d..9b08b4a74 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.0"); + modelBuilder.HasAnnotation("ProductVersion", "6.0.1"); modelBuilder.Entity("API.Entities.AppRole", b => { @@ -490,6 +490,9 @@ namespace API.Data.Migrations b.Property("AgeRating") .HasColumnType("INTEGER"); + b.Property("Language") + .HasColumnType("TEXT"); + b.Property("ReleaseYear") .HasColumnType("INTEGER"); @@ -668,6 +671,29 @@ namespace API.Data.Migrations b.ToTable("ServerSetting"); }); + 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") @@ -732,6 +758,21 @@ namespace API.Data.Migrations 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") @@ -861,6 +902,21 @@ namespace API.Data.Migrations 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") @@ -1082,6 +1138,21 @@ namespace API.Data.Migrations .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) @@ -1163,6 +1234,21 @@ namespace API.Data.Migrations .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"); diff --git a/API/Data/Repositories/GenreRepository.cs b/API/Data/Repositories/GenreRepository.cs index e1d53934c..05f2052f4 100644 --- a/API/Data/Repositories/GenreRepository.cs +++ b/API/Data/Repositories/GenreRepository.cs @@ -17,6 +17,7 @@ public interface IGenreRepository Task> GetAllGenresAsync(); Task> GetAllGenreDtosAsync(); Task RemoveAllGenreNoLongerAssociated(bool removeExternal = false); + Task> GetAllGenreDtosForLibrariesAsync(IList libraryIds); } public class GenreRepository : IGenreRepository @@ -60,6 +61,16 @@ public class GenreRepository : IGenreRepository await _context.SaveChangesAsync(); } + public async Task> GetAllGenreDtosForLibrariesAsync(IList libraryIds) + { + return await _context.Series + .Where(s => libraryIds.Contains(s.LibraryId)) + .SelectMany(s => s.Metadata.Genres) + .Distinct() + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } + public async Task> GetAllGenresAsync() { return await _context.Genre.ToListAsync(); @@ -68,6 +79,7 @@ public class GenreRepository : IGenreRepository public async Task> GetAllGenreDtosAsync() { return await _context.Genre + .AsNoTracking() .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); } diff --git a/API/Data/Repositories/PersonRepository.cs b/API/Data/Repositories/PersonRepository.cs index 34ae22d59..71ec69639 100644 --- a/API/Data/Repositories/PersonRepository.cs +++ b/API/Data/Repositories/PersonRepository.cs @@ -1,8 +1,10 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using API.DTOs; using API.Entities; using AutoMapper; +using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; @@ -13,6 +15,7 @@ public interface IPersonRepository void Remove(Person person); Task> GetAllPeople(); Task RemoveAllPeopleNoLongerAssociated(bool removeExternal = false); + Task> GetAllPeopleDtosForLibrariesAsync(List libraryIds); } public class PersonRepository : IPersonRepository @@ -57,6 +60,16 @@ public class PersonRepository : IPersonRepository await _context.SaveChangesAsync(); } + public async Task> GetAllPeopleDtosForLibrariesAsync(List libraryIds) + { + return await _context.Series + .Where(s => libraryIds.Contains(s.LibraryId)) + .SelectMany(s => s.Metadata.People) + .Distinct() + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } + public async Task> GetAllPeople() { diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index 0c55a7b49..e124f4709 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -1,11 +1,13 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Threading.Tasks; using API.Data.Scanner; using API.DTOs; using API.DTOs.CollectionTags; using API.DTOs.Filtering; +using API.DTOs.Metadata; using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; @@ -14,6 +16,7 @@ using API.Helpers; using API.Services.Tasks; using AutoMapper; using AutoMapper.QueryableExtensions; +using Kavita.Common.Extensions; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; @@ -67,6 +70,8 @@ public interface ISeriesRepository Task GetFullSeriesForSeriesIdAsync(int seriesId); Task GetChunkInfo(int libraryId = 0); Task> GetSeriesMetadataForIdsAsync(IEnumerable seriesIds); + Task> GetAllAgeRatingsDtosForLibrariesAsync(List libraryIds); + Task> GetAllLanguagesForLibrariesAsync(List libraryIds); } public class SeriesRepository : ISeriesRepository @@ -135,13 +140,23 @@ public class SeriesRepository : ISeriesRepository { var query = _context.Series .Where(s => s.LibraryId == libraryId) + .Include(s => s.Metadata) .ThenInclude(m => m.People) + .Include(s => s.Metadata) .ThenInclude(m => m.Genres) + + .Include(s => s.Metadata) + .ThenInclude(m => m.Tags) + .Include(s => s.Volumes) .ThenInclude(v => v.Chapters) .ThenInclude(cm => cm.People) + + .Include(s => s.Volumes) + .ThenInclude(v => v.Chapters) + .Include(s => s.Volumes) .ThenInclude(v => v.Chapters) .ThenInclude(c => c.Files) @@ -168,6 +183,14 @@ public class SeriesRepository : ISeriesRepository .Include(s => s.Volumes) .ThenInclude(v => v.Chapters) .ThenInclude(cm => cm.People) + + .Include(s => s.Volumes) + .ThenInclude(v => v.Chapters) + .ThenInclude(cm => cm.Tags) + + .Include(s => s.Metadata) + .ThenInclude(m => m.Tags) + .Include(s => s.Volumes) .ThenInclude(v => v.Chapters) .ThenInclude(c => c.Files) @@ -179,8 +202,12 @@ public class SeriesRepository : ISeriesRepository { var query = await CreateFilteredSearchQueryable(userId, libraryId, filter); + if (filter.SortOptions == null) + { + query = query.OrderBy(s => s.SortName); + } + var retSeries = query - .OrderByDescending(s => s.SortName) .ProjectTo(_mapper.ConfigurationProvider) .AsSplitQuery() .AsNoTracking(); @@ -387,7 +414,8 @@ public class SeriesRepository : ISeriesRepository private IList ExtractFilters(int libraryId, int userId, FilterDto filter, ref List userLibraries, out List allPeopleIds, out bool hasPeopleFilter, out bool hasGenresFilter, out bool hasCollectionTagFilter, - out bool hasRatingFilter, out bool hasProgressFilter, out IList seriesIds) + out bool hasRatingFilter, out bool hasProgressFilter, out IList seriesIds, out bool hasAgeRating, out bool hasTagsFilter, + out bool hasLanguageFilter) { var formats = filter.GetSqlFilter(); @@ -406,12 +434,16 @@ public class SeriesRepository : ISeriesRepository allPeopleIds.AddRange(filter.Penciller); allPeopleIds.AddRange(filter.Publisher); allPeopleIds.AddRange(filter.CoverArtist); + allPeopleIds.AddRange(filter.Translators); hasPeopleFilter = allPeopleIds.Count > 0; hasGenresFilter = filter.Genres.Count > 0; hasCollectionTagFilter = filter.CollectionTags.Count > 0; hasRatingFilter = filter.Rating > 0; hasProgressFilter = !filter.ReadStatus.Read || !filter.ReadStatus.InProgress || !filter.ReadStatus.NotRead; + hasAgeRating = filter.AgeRating.Count > 0; + hasTagsFilter = filter.Tags.Count > 0; + hasLanguageFilter = filter.Languages.Count > 0; bool ProgressComparison(int pagesRead, int totalPages) @@ -499,7 +531,7 @@ public class SeriesRepository : ISeriesRepository var formats = ExtractFilters(libraryId, userId, filter, ref userLibraries, out var allPeopleIds, out var hasPeopleFilter, out var hasGenresFilter, out var hasCollectionTagFilter, out var hasRatingFilter, out var hasProgressFilter, - out var seriesIds); + out var seriesIds, out var hasAgeRating, out var hasTagsFilter, out var hasLanguageFilter); var query = _context.Series .Where(s => userLibraries.Contains(s.LibraryId) @@ -510,42 +542,41 @@ public class SeriesRepository : ISeriesRepository s.Metadata.CollectionTags.Any(t => filter.CollectionTags.Contains(t.Id))) && (!hasRatingFilter || s.Ratings.Any(r => r.Rating >= filter.Rating)) && (!hasProgressFilter || seriesIds.Contains(s.Id)) + && (!hasAgeRating || filter.AgeRating.Contains(s.Metadata.AgeRating)) + && (!hasTagsFilter || s.Metadata.Tags.Any(t => filter.Tags.Contains(t.Id))) + && (!hasLanguageFilter || filter.Languages.Contains(s.Metadata.Language)) ) .AsNoTracking(); - // IQueryable newFilter = null; - // if (hasProgressFilter) - // { - // newFilter = query - // .Join(_context.AppUserProgresses, s => s.Id, progress => progress.SeriesId, (s, progress) => - // new - // { - // Series = s, - // PagesRead = _context.AppUserProgresses.Where(s1 => s1.SeriesId == s.Id && s1.AppUserId == userId) - // .Sum(s1 => s1.PagesRead), - // progress.AppUserId, - // LastModified = _context.AppUserProgresses.Where(p => p.Id == progress.Id && p.AppUserId == userId) - // .Max(p => p.LastModified) - // }) - // .Select(d => new FilterableQuery() - // { - // Series = d.Series, - // AppUserId = d.AppUserId, - // LastModified = d.LastModified, - // PagesRead = d.PagesRead - // }) - // .Where(d => seriesIds.Contains(d.Series.Id)); - // } - // else - // { - // newFilter = query.Select(s => new FilterableQuery() - // { - // Series = s, - // LastModified = DateTime.Now, // TODO: Figure this out - // AppUserId = userId, - // PagesRead = 0 - // }); - // } + if (filter.SortOptions != null) + { + if (filter.SortOptions.IsAscending) + { + if (filter.SortOptions.SortField == SortField.SortName) + { + query = query.OrderBy(s => s.SortName); + } else if (filter.SortOptions.SortField == SortField.CreatedDate) + { + query = query.OrderBy(s => s.Created); + } else if (filter.SortOptions.SortField == SortField.LastModifiedDate) + { + query = query.OrderBy(s => s.LastModified); + } + } + else + { + if (filter.SortOptions.SortField == SortField.SortName) + { + query = query.OrderByDescending(s => s.SortName); + } else if (filter.SortOptions.SortField == SortField.CreatedDate) + { + query = query.OrderByDescending(s => s.Created); + } else if (filter.SortOptions.SortField == SortField.LastModifiedDate) + { + query = query.OrderByDescending(s => s.LastModified); + } + } + } return query; } @@ -555,13 +586,15 @@ public class SeriesRepository : ISeriesRepository var metadataDto = await _context.SeriesMetadata .Where(metadata => metadata.SeriesId == seriesId) .Include(m => m.Genres) + .Include(m => m.Tags) + .Include(m => m.People) .AsNoTracking() .ProjectTo(_mapper.ConfigurationProvider) .SingleOrDefaultAsync(); if (metadataDto != null) { - metadataDto.Tags = await _context.CollectionTag + metadataDto.CollectionTags = await _context.CollectionTag .Include(t => t.SeriesMetadatas) .Where(t => t.SeriesMetadatas.Select(s => s.SeriesId).Contains(seriesId)) .ProjectTo(_mapper.ConfigurationProvider) @@ -694,4 +727,35 @@ public class SeriesRepository : ISeriesRepository .Include(sm => sm.CollectionTags) .ToListAsync(); } + + public async Task> GetAllAgeRatingsDtosForLibrariesAsync(List libraryIds) + { + return await _context.Series + .Where(s => libraryIds.Contains(s.LibraryId)) + .Select(s => s.Metadata.AgeRating) + .Distinct() + .Select(s => new AgeRatingDto() + { + Value = s, + Title = s.ToDescription() + }) + .ToListAsync(); + } + + public async Task> GetAllLanguagesForLibrariesAsync(List libraryIds) + { + var ret = await _context.Series + .Where(s => libraryIds.Contains(s.LibraryId)) + .Select(s => s.Metadata.Language) + .Distinct() + .ToListAsync(); + + return ret + .Where(s => !string.IsNullOrEmpty(s)) + .Select(s => new LanguageDto() + { + Title = CultureInfo.GetCultureInfo(s).DisplayName, + IsoCode = s + }).ToList(); + } } diff --git a/API/Data/Repositories/TagRepository.cs b/API/Data/Repositories/TagRepository.cs new file mode 100644 index 000000000..772957aa9 --- /dev/null +++ b/API/Data/Repositories/TagRepository.cs @@ -0,0 +1,86 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs.Metadata; +using API.Entities; +using AutoMapper; +using AutoMapper.QueryableExtensions; +using Microsoft.EntityFrameworkCore; + +namespace API.Data.Repositories; + +public interface ITagRepository +{ + void Attach(Tag tag); + void Remove(Tag tag); + Task FindByNameAsync(string tagName); + Task> GetAllTagsAsync(); + Task> GetAllTagDtosAsync(); + Task RemoveAllTagNoLongerAssociated(bool removeExternal = false); + Task> GetAllTagDtosForLibrariesAsync(IList libraryIds); +} + +public class TagRepository : ITagRepository +{ + private readonly DataContext _context; + private readonly IMapper _mapper; + + public TagRepository(DataContext context, IMapper mapper) + { + _context = context; + _mapper = mapper; + } + + public void Attach(Tag tag) + { + _context.Tag.Attach(tag); + } + + public void Remove(Tag tag) + { + _context.Tag.Remove(tag); + } + + public async Task FindByNameAsync(string tagName) + { + var normalizedName = Parser.Parser.Normalize(tagName); + return await _context.Tag + .FirstOrDefaultAsync(g => g.NormalizedTitle.Equals(normalizedName)); + } + + public async Task RemoveAllTagNoLongerAssociated(bool removeExternal = false) + { + var TagsWithNoConnections = await _context.Tag + .Include(p => p.SeriesMetadatas) + .Include(p => p.Chapters) + .Where(p => p.SeriesMetadatas.Count == 0 && p.Chapters.Count == 0 && p.ExternalTag == removeExternal) + .ToListAsync(); + + _context.Tag.RemoveRange(TagsWithNoConnections); + + await _context.SaveChangesAsync(); + } + + public async Task> GetAllTagDtosForLibrariesAsync(IList libraryIds) + { + return await _context.Series + .Where(s => libraryIds.Contains(s.LibraryId)) + .SelectMany(s => s.Metadata.Tags) + .Distinct() + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } + + public async Task> GetAllTagsAsync() + { + return await _context.Tag.ToListAsync(); + } + + public async Task> GetAllTagDtosAsync() + { + return await _context.Tag + .AsNoTracking() + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } +} diff --git a/API/Data/Repositories/VolumeRepository.cs b/API/Data/Repositories/VolumeRepository.cs index 7d2d76d2d..0c46bd49c 100644 --- a/API/Data/Repositories/VolumeRepository.cs +++ b/API/Data/Repositories/VolumeRepository.cs @@ -173,6 +173,8 @@ public class VolumeRepository : IVolumeRepository .Where(vol => vol.SeriesId == seriesId) .Include(vol => vol.Chapters) .ThenInclude(c => c.People) + .Include(vol => vol.Chapters) + .ThenInclude(c => c.Tags) .OrderBy(volume => volume.Number) .ProjectTo(_mapper.ConfigurationProvider) .AsNoTracking() diff --git a/API/Data/UnitOfWork.cs b/API/Data/UnitOfWork.cs index 42ef365ea..82046ca2a 100644 --- a/API/Data/UnitOfWork.cs +++ b/API/Data/UnitOfWork.cs @@ -20,6 +20,7 @@ public interface IUnitOfWork ISeriesMetadataRepository SeriesMetadataRepository { get; } IPersonRepository PersonRepository { get; } IGenreRepository GenreRepository { get; } + ITagRepository TagRepository { get; } bool Commit(); Task CommitAsync(); bool HasChanges(); @@ -54,6 +55,7 @@ public class UnitOfWork : IUnitOfWork public ISeriesMetadataRepository SeriesMetadataRepository => new SeriesMetadataRepository(_context); public IPersonRepository PersonRepository => new PersonRepository(_context, _mapper); public IGenreRepository GenreRepository => new GenreRepository(_context, _mapper); + public ITagRepository TagRepository => new TagRepository(_context, _mapper); /// /// Commits changes to the DB. Completes the open transaction. diff --git a/API/Entities/Chapter.cs b/API/Entities/Chapter.cs index 65bbb3491..5061954af 100644 --- a/API/Entities/Chapter.cs +++ b/API/Entities/Chapter.cs @@ -62,6 +62,7 @@ namespace API.Entities /// All people attached at a Chapter level. Usually Comics will have different people per issue. /// public ICollection People { get; set; } = new List(); + public ICollection Tags { get; set; } = new List(); diff --git a/API/Entities/Enums/PersonRole.cs b/API/Entities/Enums/PersonRole.cs index cb8cedf95..714e1d534 100644 --- a/API/Entities/Enums/PersonRole.cs +++ b/API/Entities/Enums/PersonRole.cs @@ -24,7 +24,11 @@ /// /// Represents a character/person within the story /// - Character = 11 + Character = 11, + /// + /// The Translator + /// + Translator = 12 } diff --git a/API/Entities/Metadata/SeriesMetadata.cs b/API/Entities/Metadata/SeriesMetadata.cs index 1d90dd3fd..1393ec86f 100644 --- a/API/Entities/Metadata/SeriesMetadata.cs +++ b/API/Entities/Metadata/SeriesMetadata.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using API.Entities.Enums; using API.Entities.Interfaces; @@ -17,6 +18,7 @@ namespace API.Entities.Metadata public ICollection CollectionTags { get; set; } public ICollection Genres { get; set; } = new List(); + public ICollection Tags { get; set; } = new List(); /// /// All people attached at a Series level. /// @@ -30,6 +32,10 @@ namespace API.Entities.Metadata /// Earliest Year from all chapters /// public int ReleaseYear { get; set; } + /// + /// Language of the content (ISO 639-1 code) + /// + public string Language { get; set; } = string.Empty; // Relationship public Series Series { get; set; } diff --git a/API/Entities/Tag.cs b/API/Entities/Tag.cs new file mode 100644 index 000000000..5d1631760 --- /dev/null +++ b/API/Entities/Tag.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using API.Entities.Metadata; +using Microsoft.EntityFrameworkCore; + +namespace API.Entities; + +[Index(nameof(NormalizedTitle), nameof(ExternalTag), IsUnique = true)] +public class Tag +{ + public int Id { get; set; } + public string Title { get; set; } + public string NormalizedTitle { get; set; } + public bool ExternalTag { get; set; } + + public ICollection SeriesMetadatas { get; set; } + public ICollection Chapters { get; set; } +} diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index c67aca2f6..5bbdb7af1 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -48,15 +48,16 @@ namespace API.Helpers 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))); + 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() .ForMember(dest => dest.Writers, @@ -83,6 +84,9 @@ namespace API.Helpers .ForMember(dest => dest.Pencillers, opt => opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Penciller))) + .ForMember(dest => dest.Translators, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Translator))) .ForMember(dest => dest.Editors, opt => opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Editor))); diff --git a/API/Helpers/GenreHelper.cs b/API/Helpers/GenreHelper.cs index a9dc41782..aa465f58e 100644 --- a/API/Helpers/GenreHelper.cs +++ b/API/Helpers/GenreHelper.cs @@ -11,23 +11,23 @@ public static class GenreHelper /// /// /// - /// + /// /// /// /// - public static void UpdateGenre(ICollection allPeople, IEnumerable names, bool isExternal, Action action) + public static void UpdateGenre(ICollection allGenres, IEnumerable names, bool isExternal, Action action) { foreach (var name in names) { if (string.IsNullOrEmpty(name.Trim())) continue; var normalizedName = Parser.Parser.Normalize(name); - var genre = allPeople.FirstOrDefault(p => + var genre = allGenres.FirstOrDefault(p => p.NormalizedTitle.Equals(normalizedName) && p.ExternalTag == isExternal); if (genre == null) { genre = DbFactory.Genre(name, false); - allPeople.Add(genre); + allGenres.Add(genre); } action(genre); diff --git a/API/Helpers/TagHelper.cs b/API/Helpers/TagHelper.cs new file mode 100644 index 000000000..4060b00c8 --- /dev/null +++ b/API/Helpers/TagHelper.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using API.Data; +using API.Entities; + +namespace API.Helpers; + +public static class TagHelper +{ + /// + /// + /// + /// + /// + /// + /// Callback for every item. Will give said item back and a bool if item was added + public static void UpdateTag(ICollection allTags, IEnumerable names, bool isExternal, Action action) + { + foreach (var name in names) + { + if (string.IsNullOrEmpty(name.Trim())) continue; + + var added = false; + var normalizedName = Parser.Parser.Normalize(name); + + // var tag = DbFactory.Tag(name, isExternal); + // TagHelper.AddTagIfNotExists(allTags, tag); + + var genre = allTags.FirstOrDefault(p => + p.NormalizedTitle.Equals(normalizedName) && p.ExternalTag == isExternal); + if (genre == null) + { + added = true; + genre = DbFactory.Tag(name, false); + allTags.Add(genre); + } + + action(genre, added); + } + } + + public static void KeepOnlySameTagBetweenLists(ICollection existingTags, ICollection removeAllExcept, Action action = null) + { + var existing = existingTags.ToList(); + foreach (var genre in existing) + { + var existingPerson = removeAllExcept.FirstOrDefault(g => g.ExternalTag == genre.ExternalTag && genre.NormalizedTitle.Equals(g.NormalizedTitle)); + if (existingPerson != null) continue; + existingTags.Remove(genre); + action?.Invoke(genre); + } + + } + + /// + /// Adds the tag to the list if it's not already in there. This will ignore the ExternalTag. + /// + /// + /// + public static void AddTagIfNotExists(ICollection metadataTags, Tag tag) + { + var existingGenre = metadataTags.FirstOrDefault(p => + p.NormalizedTitle == Parser.Parser.Normalize(tag.Title)); + if (existingGenre == null) + { + metadataTags.Add(tag); + } + } + + /// + /// Remove tags on a list + /// + /// Used to remove before we update/add new tags + /// Existing tags on Entity + /// Tags from metadata + /// Remove external tags? + /// Callback which will be executed for each tag removed + public static void RemoveTags(ICollection existingTags, IEnumerable tags, bool isExternal, Action action = null) + { + var normalizedTags = tags.Select(Parser.Parser.Normalize).ToList(); + foreach (var person in normalizedTags) + { + var existingTag = existingTags.FirstOrDefault(p => p.ExternalTag == isExternal && person.Equals(p.NormalizedTitle)); + if (existingTag == null) continue; + + existingTags.Remove(existingTag); + action?.Invoke(existingTag); + } + + } +} + diff --git a/API/Services/MetadataService.cs b/API/Services/MetadataService.cs index 87e90bd36..6fe469c50 100644 --- a/API/Services/MetadataService.cs +++ b/API/Services/MetadataService.cs @@ -76,16 +76,16 @@ public class MetadataService : IMetadataService return true; } - private void UpdateChapterMetadata(Chapter chapter, ICollection allPeople, bool forceUpdate) + private void UpdateChapterMetadata(Chapter chapter, ICollection allPeople, ICollection allTags, bool forceUpdate) { var firstFile = chapter.Files.OrderBy(x => x.Chapter).FirstOrDefault(); if (firstFile == null || _cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, forceUpdate, firstFile)) return; - UpdateChapterFromComicInfo(chapter, allPeople, firstFile); + UpdateChapterFromComicInfo(chapter, allPeople, allTags, firstFile); firstFile.UpdateLastModified(); } - private void UpdateChapterFromComicInfo(Chapter chapter, ICollection allPeople, MangaFile firstFile) + private void UpdateChapterFromComicInfo(Chapter chapter, ICollection allPeople, ICollection allTags, MangaFile firstFile) { // TODO: Think about letting the higher level loop have access for series to avoid duplicate IO operations var comicInfo = _readingItemService.GetComicInfo(firstFile.FilePath, firstFile.Format); @@ -98,7 +98,7 @@ public class MetadataService : IMetadataService chapter.TitleName = comicInfo.Title.Trim(); } - if (comicInfo.Year > 0 && comicInfo.Month > 0) + if (comicInfo.Year > 0) { var day = Math.Max(comicInfo.Day, 1); var month = Math.Max(comicInfo.Month, 1); @@ -113,6 +113,26 @@ public class MetadataService : IMetadataService person => PersonHelper.AddPersonIfNotExists(chapter.People, person)); } + if (!string.IsNullOrEmpty(comicInfo.Translator)) + { + var people = comicInfo.Translator.Split(","); + PersonHelper.RemovePeople(chapter.People, people, PersonRole.Translator); + PersonHelper.UpdatePeople(allPeople, people, PersonRole.Translator, + person => PersonHelper.AddPersonIfNotExists(chapter.People, person)); + } + + if (!string.IsNullOrEmpty(comicInfo.Tags)) + { + var tags = comicInfo.Tags.Split(",").Select(s => s.Trim()).ToList(); + // Remove all tags that aren't matching between chapter tags and metadata + TagHelper.KeepOnlySameTagBetweenLists(chapter.Tags, tags.Select(t => DbFactory.Tag(t, false)).ToList()); + TagHelper.UpdateTag(allTags, tags, false, + (tag, added) => + { + chapter.Tags.Add(tag); + }); + } + if (!string.IsNullOrEmpty(comicInfo.Writer)) { var people = comicInfo.Writer.Split(","); @@ -198,7 +218,6 @@ public class MetadataService : IMetadataService { if (series == null) return; - // NOTE: This will fail if we replace the cover of the first volume on a first scan. Because the series will already have a cover image if (!_cacheHelper.ShouldUpdateCoverImage(_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, series.CoverImage), null, series.Created, forceUpdate, series.CoverImageLocked)) return; @@ -223,7 +242,7 @@ public class MetadataService : IMetadataService series.CoverImage = firstCover?.CoverImage ?? coverImage; } - private void UpdateSeriesMetadata(Series series, ICollection allPeople, ICollection allGenres, bool forceUpdate) + private void UpdateSeriesMetadata(Series series, ICollection allPeople, ICollection allGenres, ICollection allTags, bool forceUpdate) { var isBook = series.Library.Type == LibraryType.Book; var firstVolume = series.Volumes.OrderBy(c => c.Number, new ChapterSortComparer()).FirstWithChapters(isBook); @@ -233,10 +252,6 @@ public class MetadataService : IMetadataService if (firstFile == null || _cacheHelper.HasFileNotChangedSinceCreationOrLastScan(firstChapter, forceUpdate, firstFile)) return; if (Parser.Parser.IsPdf(firstFile.FilePath)) return; - var comicInfo = _readingItemService.GetComicInfo(firstFile.FilePath, firstFile.Format); - if (comicInfo == null) return; - - foreach (var chapter in series.Volumes.SelectMany(volume => volume.Chapters)) { PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Writer).Select(p => p.Name), PersonRole.Writer, @@ -265,29 +280,40 @@ public class MetadataService : IMetadataService PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Penciller).Select(p => p.Name), PersonRole.Penciller, person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person)); + + PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Translator).Select(p => p.Name), PersonRole.Translator, + person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person)); + + TagHelper.UpdateTag(allTags, chapter.Tags.Select(t => t.Title), false, (tag, added) => + TagHelper.AddTagIfNotExists(series.Metadata.Tags, tag)); } var comicInfos = series.Volumes .SelectMany(volume => volume.Chapters) + .OrderBy(c => double.Parse(c.Number), new ChapterSortComparer()) .SelectMany(c => c.Files) .Select(file => _readingItemService.GetComicInfo(file.FilePath, file.Format)) .Where(ci => ci != null) .ToList(); - //var firstComicInfo = comicInfos.First(i => i.) - // Summary Info - if (!string.IsNullOrEmpty(comicInfo.Summary)) + var comicInfo = comicInfos.FirstOrDefault(); + if (!string.IsNullOrEmpty(comicInfo?.Summary)) { - // PERF: I can move this to the bottom as I have a comicInfo selection, save me an extra read series.Metadata.Summary = comicInfo.Summary; } + if (!string.IsNullOrEmpty(comicInfo?.LanguageISO)) + { + series.Metadata.Language = comicInfo.LanguageISO; + } + // Set the AgeRating as highest in all the comicInfos - series.Metadata.AgeRating = comicInfos.Max(i => ComicInfo.ConvertAgeRatingToEnum(comicInfo.AgeRating)); + series.Metadata.AgeRating = comicInfos.Max(i => ComicInfo.ConvertAgeRatingToEnum(comicInfo?.AgeRating)); series.Metadata.ReleaseYear = series.Volumes .SelectMany(volume => volume.Chapters).Min(c => c.ReleaseDate.Year); var genres = comicInfos.SelectMany(i => i?.Genre.Split(",")).Distinct().ToList(); + var tags = comicInfos.SelectMany(i => i?.Tags.Split(",")).Distinct().ToList(); var people = series.Volumes.SelectMany(volume => volume.Chapters).SelectMany(c => c.People).ToList(); @@ -304,7 +330,7 @@ public class MetadataService : IMetadataService /// /// /// - private void ProcessSeriesMetadataUpdate(Series series, ICollection allPeople, ICollection allGenres, bool forceUpdate) + private void ProcessSeriesMetadataUpdate(Series series, ICollection allPeople, ICollection allGenres, ICollection allTags, bool forceUpdate) { _logger.LogDebug("[MetadataService] Processing series {SeriesName}", series.OriginalName); try @@ -316,14 +342,14 @@ public class MetadataService : IMetadataService foreach (var chapter in volume.Chapters) { chapterUpdated = UpdateChapterCoverImage(chapter, forceUpdate); - UpdateChapterMetadata(chapter, allPeople, forceUpdate || chapterUpdated); + UpdateChapterMetadata(chapter, allPeople, allTags, forceUpdate || chapterUpdated); } volumeUpdated = UpdateVolumeCoverImage(volume, chapterUpdated || forceUpdate); } UpdateSeriesCoverImage(series, volumeUpdated || forceUpdate); - UpdateSeriesMetadata(series, allPeople, allGenres, forceUpdate); + UpdateSeriesMetadata(series, allPeople, allGenres, allTags, forceUpdate); } catch (Exception ex) { @@ -370,6 +396,7 @@ public class MetadataService : IMetadataService var allPeople = await _unitOfWork.PersonRepository.GetAllPeople(); var allGenres = await _unitOfWork.GenreRepository.GetAllGenresAsync(); + var allTags = await _unitOfWork.TagRepository.GetAllTagsAsync(); var seriesIndex = 0; @@ -377,7 +404,7 @@ public class MetadataService : IMetadataService { try { - ProcessSeriesMetadataUpdate(series, allPeople, allGenres, forceUpdate); + ProcessSeriesMetadataUpdate(series, allPeople, allGenres, allTags, forceUpdate); } catch (Exception ex) { @@ -404,14 +431,19 @@ public class MetadataService : IMetadataService await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress, MessageFactory.RefreshMetadataProgressEvent(library.Id, 1F)); - // TODO: Remove any leftover People from DB - await _unitOfWork.PersonRepository.RemoveAllPeopleNoLongerAssociated(); - await _unitOfWork.GenreRepository.RemoveAllGenreNoLongerAssociated(); + await RemoveAbandonedMetadataKeys(); _logger.LogInformation("[MetadataService] Updated metadata for {SeriesNumber} series in library {LibraryName} in {ElapsedMilliseconds} milliseconds total", chunkInfo.TotalSize, library.Name, totalTime); } + private async Task RemoveAbandonedMetadataKeys() + { + await _unitOfWork.TagRepository.RemoveAllTagNoLongerAssociated(); + await _unitOfWork.PersonRepository.RemoveAllPeopleNoLongerAssociated(); + await _unitOfWork.GenreRepository.RemoveAllGenreNoLongerAssociated(); + } + // TODO: I can probably refactor RefreshMetadata and RefreshMetadataForSeries to be the same by utilizing chunk size of 1, so most of the code can be the same. private async Task PerformScan(Library library, bool forceUpdate, Action action) { @@ -490,8 +522,9 @@ public class MetadataService : IMetadataService var allPeople = await _unitOfWork.PersonRepository.GetAllPeople(); var allGenres = await _unitOfWork.GenreRepository.GetAllGenresAsync(); + var allTags = await _unitOfWork.TagRepository.GetAllTagsAsync(); - ProcessSeriesMetadataUpdate(series, allPeople, allGenres, forceUpdate); + ProcessSeriesMetadataUpdate(series, allPeople, allGenres, allTags, forceUpdate); await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress, MessageFactory.RefreshMetadataProgressEvent(libraryId, 1F)); @@ -502,6 +535,8 @@ public class MetadataService : IMetadataService await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadata, MessageFactory.RefreshMetadataEvent(series.LibraryId, series.Id)); } + await RemoveAbandonedMetadataKeys(); + _logger.LogInformation("[MetadataService] Updated metadata for {SeriesName} in {ElapsedMilliseconds} milliseconds", series.Name, sw.ElapsedMilliseconds); } } diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index ec355eeed..6bf10a5da 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -87,7 +87,7 @@ public class TaskScheduler : ITaskScheduler } RecurringJob.AddOrUpdate("cleanup", () => _cleanupService.Cleanup(), Cron.Daily, TimeZoneInfo.Local); - + RecurringJob.AddOrUpdate("cleanup-db", () => _cleanupService.CleanupDbEntries(), Cron.Daily, TimeZoneInfo.Local); } #region StatsTasks diff --git a/API/Services/Tasks/CleanupService.cs b/API/Services/Tasks/CleanupService.cs index ba4b94c1e..6d8b705a9 100644 --- a/API/Services/Tasks/CleanupService.cs +++ b/API/Services/Tasks/CleanupService.cs @@ -13,6 +13,7 @@ namespace API.Services.Tasks public interface ICleanupService { Task Cleanup(); + Task CleanupDbEntries(); void CleanupCacheDirectory(); Task DeleteSeriesCoverImages(); Task DeleteChapterCoverImages(); @@ -66,6 +67,17 @@ namespace API.Services.Tasks _logger.LogInformation("Cleanup finished"); } + /// + /// Cleans up abandon rows in the DB + /// + public async Task CleanupDbEntries() + { + await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters(); + await _unitOfWork.PersonRepository.RemoveAllPeopleNoLongerAssociated(); + await _unitOfWork.GenreRepository.RemoveAllGenreNoLongerAssociated(); + await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries(); + } + private async Task SendProgress(float progress) { await _messageHub.Clients.All.SendAsync(SignalREvents.CleanupProgress, diff --git a/UI/Web/src/app/_models/chapter.ts b/UI/Web/src/app/_models/chapter.ts index 7638ff70c..1a66e2471 100644 --- a/UI/Web/src/app/_models/chapter.ts +++ b/UI/Web/src/app/_models/chapter.ts @@ -1,5 +1,6 @@ import { MangaFile } from './manga-file'; import { Person } from './person'; +import { Tag } from './tag'; export interface Chapter { id: number; @@ -31,4 +32,5 @@ export interface Chapter { coverArtist: Array; editor: Array; publisher: Array; + tags: Array; } diff --git a/UI/Web/src/app/_models/metadata/age-rating-dto.ts b/UI/Web/src/app/_models/metadata/age-rating-dto.ts new file mode 100644 index 000000000..ebf0728b9 --- /dev/null +++ b/UI/Web/src/app/_models/metadata/age-rating-dto.ts @@ -0,0 +1,6 @@ +import { AgeRating } from "./age-rating"; + +export interface AgeRatingDto { + value: AgeRating; + title: string; +} \ No newline at end of file diff --git a/UI/Web/src/app/_models/person.ts b/UI/Web/src/app/_models/person.ts index 88b0848fa..e23925cef 100644 --- a/UI/Web/src/app/_models/person.ts +++ b/UI/Web/src/app/_models/person.ts @@ -9,7 +9,8 @@ export enum PersonRole { CoverArtist = 8, Editor = 9, Publisher = 10, - Character = 11 + Character = 11, + Translator = 12 } export interface Person { diff --git a/UI/Web/src/app/_models/series-filter.ts b/UI/Web/src/app/_models/series-filter.ts index 39cd48ab0..51aa9394f 100644 --- a/UI/Web/src/app/_models/series-filter.ts +++ b/UI/Web/src/app/_models/series-filter.ts @@ -20,8 +20,24 @@ export interface SeriesFilter { editor: Array; publisher: Array; character: Array; + translators: Array; collectionTags: Array; rating: number; + ageRating: Array; + sortOptions: SortOptions | null; + tags: Array; + languages: Array; +} + +export interface SortOptions { + sortField: SortField; + isAscending: boolean; +} + +export enum SortField { + SortName = 1, + Created = 2, + LastModified = 3 } export interface ReadStatus { diff --git a/UI/Web/src/app/_models/series-metadata.ts b/UI/Web/src/app/_models/series-metadata.ts index 2eaaada6f..742f755da 100644 --- a/UI/Web/src/app/_models/series-metadata.ts +++ b/UI/Web/src/app/_models/series-metadata.ts @@ -2,12 +2,14 @@ import { CollectionTag } from "./collection-tag"; import { Genre } from "./genre"; import { AgeRating } from "./metadata/age-rating"; import { Person } from "./person"; +import { Tag } from "./tag"; export interface SeriesMetadata { publisher: string; summary: string; genres: Array; - tags: Array; + tags: Array; + collectionTags: Array; writers: Array; artists: Array; publishers: Array; @@ -17,7 +19,9 @@ export interface SeriesMetadata { colorists: Array; letterers: Array; editors: Array; + translators: Array; ageRating: AgeRating; releaseYear: number; + language: string; seriesId: number; } \ 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 f8141eaa9..332407105 100644 --- a/UI/Web/src/app/_models/series.ts +++ b/UI/Web/src/app/_models/series.ts @@ -7,7 +7,6 @@ export interface Series { originalName: string; // This is not shown to user localizedName: string; sortName: string; - //summary: string; coverImageLocked: boolean; volumes: Volume[]; pages: number; // Total pages in series diff --git a/UI/Web/src/app/_models/tag.ts b/UI/Web/src/app/_models/tag.ts new file mode 100644 index 000000000..c75d48be6 --- /dev/null +++ b/UI/Web/src/app/_models/tag.ts @@ -0,0 +1,4 @@ +export interface Tag { + id: number, + title: string; +} \ No newline at end of file diff --git a/UI/Web/src/app/_services/metadata.service.ts b/UI/Web/src/app/_services/metadata.service.ts index 28d1e3d18..d1f08c41b 100644 --- a/UI/Web/src/app/_services/metadata.service.ts +++ b/UI/Web/src/app/_services/metadata.service.ts @@ -6,7 +6,10 @@ import { environment } from 'src/environments/environment'; 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 { Person } from '../_models/person'; +import { Tag } from '../_models/tag'; @Injectable({ providedIn: 'root' @@ -19,29 +22,57 @@ export class MetadataService { constructor(private httpClient: HttpClient) { } - // getChapterMetadata(chapterId: number) { - // return this.httpClient.get(this.baseUrl + 'series/chapter-metadata?chapterId=' + chapterId); - // } - getAgeRating(ageRating: AgeRating) { if (this.ageRatingTypes != undefined && this.ageRatingTypes.hasOwnProperty(ageRating)) { return of(this.ageRatingTypes[ageRating]); } - return this.httpClient.get(this.baseUrl + 'series/age-rating?ageRating=' + ageRating, {responseType: 'text' as 'json'}).pipe(map(l => { + return this.httpClient.get(this.baseUrl + 'series/age-rating?ageRating=' + ageRating, {responseType: 'text' as 'json'}).pipe(map(ratingString => { if (this.ageRatingTypes === undefined) { this.ageRatingTypes = {}; } - this.ageRatingTypes[ageRating] = l; + this.ageRatingTypes[ageRating] = ratingString; return this.ageRatingTypes[ageRating]; })); } - getAllGenres() { - return this.httpClient.get(this.baseUrl + 'metadata/genres'); + getAllAgeRatings(libraries?: Array) { + let method = 'metadata/age-ratings' + if (libraries != undefined && libraries.length > 0) { + method += '?libraryIds=' + libraries.join(','); + } + return this.httpClient.get>(this.baseUrl + method);; } - getAllPeople() { - return this.httpClient.get(this.baseUrl + 'metadata/people'); + getAllTags(libraries?: Array) { + let method = 'metadata/tags' + if (libraries != undefined && libraries.length > 0) { + method += '?libraryIds=' + libraries.join(','); + } + return this.httpClient.get>(this.baseUrl + method);; + } + + getAllGenres(libraries?: Array) { + let method = 'metadata/genres' + if (libraries != undefined && libraries.length > 0) { + method += '?libraryIds=' + libraries.join(','); + } + return this.httpClient.get(this.baseUrl + method); + } + + getAllLanguages(libraries?: Array) { + let method = 'metadata/languages' + if (libraries != undefined && libraries.length > 0) { + method += '?libraryIds=' + libraries.join(','); + } + return this.httpClient.get(this.baseUrl + method); + } + + getAllPeople(libraries?: Array) { + let method = 'metadata/people' + if (libraries != undefined && libraries.length > 0) { + method += '?libraryIds=' + libraries.join(','); + } + return this.httpClient.get(this.baseUrl + method); } } diff --git a/UI/Web/src/app/_services/series.service.ts b/UI/Web/src/app/_services/series.service.ts index b0b779478..f63fc4a4f 100644 --- a/UI/Web/src/app/_services/series.service.ts +++ b/UI/Web/src/app/_services/series.service.ts @@ -39,6 +39,18 @@ export class SeriesService { return paginatedVariable; } + getAllSeries(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) { + let params = new HttpParams(); + params = this._addPaginationIfExists(params, pageNum, itemsPerPage); + const data = this.createSeriesFilter(filter); + + return this.httpClient.post>(this.baseUrl + 'series/all', data, {observe: 'response', params}).pipe( + map((response: any) => { + return this._cachePaginatedResults(response, this.paginatedResults); + }) + ); + } + getSeriesForLibrary(libraryId: number, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) { let params = new HttpParams(); params = this._addPaginationIfExists(params, pageNum, itemsPerPage); @@ -137,7 +149,7 @@ export class SeriesService { getMetadata(seriesId: number) { return this.httpClient.get(this.baseUrl + 'series/metadata?seriesId=' + seriesId).pipe(map(items => { - items?.tags.forEach(tag => tag.coverImage = this.imageService.getCollectionCoverImage(tag.id)); + items?.collectionTags.forEach(tag => tag.coverImage = this.imageService.getCollectionCoverImage(tag.id)); return items; })); } @@ -189,13 +201,18 @@ export class SeriesService { editor: [], publisher: [], character: [], + translators: [], collectionTags: [], rating: 0, readStatus: { read: true, inProgress: true, notRead: true - } + }, + sortOptions: null, + ageRating: [], + tags: [], + languages: [] }; if (filter === undefined) return data; 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 728876284..727d1069f 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 @@ -1,6 +1,6 @@
-

Port, Base Url, and Logging Level require a manual restart of Kavita to take effect.

+

Port and Logging Level require a manual restart of Kavita to take effect.

  Where the server place temporary files when reading. This will be cleaned up on a regular basis. diff --git a/UI/Web/src/app/all-series/all-series.component.html b/UI/Web/src/app/all-series/all-series.component.html new file mode 100644 index 000000000..dc589e566 --- /dev/null +++ b/UI/Web/src/app/all-series/all-series.component.html @@ -0,0 +1,14 @@ + + + + + + diff --git a/UI/Web/src/app/all-series/all-series.component.scss b/UI/Web/src/app/all-series/all-series.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/UI/Web/src/app/all-series/all-series.component.ts b/UI/Web/src/app/all-series/all-series.component.ts new file mode 100644 index 000000000..51a37fc59 --- /dev/null +++ b/UI/Web/src/app/all-series/all-series.component.ts @@ -0,0 +1,145 @@ +import { Component, HostListener, OnDestroy, OnInit } from '@angular/core'; +import { Title } from '@angular/platform-browser'; +import { Router } from '@angular/router'; +import { Subject } from 'rxjs'; +import { take, debounceTime, takeUntil } from 'rxjs/operators'; +import { BulkSelectionService } from '../cards/bulk-selection.service'; +import { FilterSettings } from '../cards/card-detail-layout/card-detail-layout.component'; +import { KEY_CODES } from '../shared/_services/utility.service'; +import { SeriesAddedEvent } from '../_models/events/series-added-event'; +import { Library } from '../_models/library'; +import { Pagination } from '../_models/pagination'; +import { Series } from '../_models/series'; +import { SeriesFilter } from '../_models/series-filter'; +import { ActionItem, Action } from '../_services/action-factory.service'; +import { ActionService } from '../_services/action.service'; +import { MessageHubService } from '../_services/message-hub.service'; +import { SeriesService } from '../_services/series.service'; + +@Component({ + selector: 'app-all-series', + templateUrl: './all-series.component.html', + styleUrls: ['./all-series.component.scss'] +}) +export class AllSeriesComponent implements OnInit, OnDestroy { + + series: Series[] = []; + loadingSeries = false; + pagination!: Pagination; + actions: ActionItem[] = []; + filter: SeriesFilter | undefined = undefined; + onDestroy: Subject = new Subject(); + filterSettings: FilterSettings = new FilterSettings(); + + bulkActionCallback = (action: Action, data: any) => { + const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('series'); + const selectedSeries = this.series.filter((series, index: number) => selectedSeriesIndexies.includes(index + '')); + + switch (action) { + case Action.AddToReadingList: + this.actionService.addMultipleSeriesToReadingList(selectedSeries, () => { + this.bulkSelectionService.deselectAll(); + }); + break; + case Action.AddToCollection: + this.actionService.addMultipleSeriesToCollectionTag(selectedSeries, () => { + this.bulkSelectionService.deselectAll(); + }); + break; + case Action.MarkAsRead: + this.actionService.markMultipleSeriesAsRead(selectedSeries, () => { + this.loadPage(); + this.bulkSelectionService.deselectAll(); + }); + + break; + case Action.MarkAsUnread: + this.actionService.markMultipleSeriesAsUnread(selectedSeries, () => { + this.loadPage(); + this.bulkSelectionService.deselectAll(); + }); + break; + case Action.Delete: + this.actionService.deleteMultipleSeries(selectedSeries, () => { + this.loadPage(); + this.bulkSelectionService.deselectAll(); + }); + break; + } + } + + constructor(private router: Router, private seriesService: SeriesService, + private titleService: Title, private actionService: ActionService, + public bulkSelectionService: BulkSelectionService, private hubService: MessageHubService) { + + this.router.routeReuseStrategy.shouldReuseRoute = () => false; + + this.titleService.setTitle('Kavita - All Series'); + this.pagination = {currentPage: 0, itemsPerPage: 30, totalItems: 0, totalPages: 1}; + + this.loadPage(); + } + + ngOnInit(): void { + this.hubService.seriesAdded.pipe(debounceTime(6000), takeUntil(this.onDestroy)).subscribe((event: SeriesAddedEvent) => { + this.loadPage(); + }); + } + + ngOnDestroy() { + this.onDestroy.next(); + this.onDestroy.complete(); + } + + @HostListener('document:keydown.shift', ['$event']) + handleKeypress(event: KeyboardEvent) { + if (event.key === KEY_CODES.SHIFT) { + this.bulkSelectionService.isShiftDown = true; + } + } + + @HostListener('document:keyup.shift', ['$event']) + handleKeyUp(event: KeyboardEvent) { + if (event.key === KEY_CODES.SHIFT) { + this.bulkSelectionService.isShiftDown = false; + } + } + + updateFilter(data: SeriesFilter) { + this.filter = data; + if (this.pagination !== undefined && this.pagination !== null) { + this.pagination.currentPage = 1; + this.onPageChange(this.pagination); + } else { + this.loadPage(); + } + } + + loadPage() { + const page = this.getPage(); + if (page != null) { + this.pagination.currentPage = parseInt(page, 10); + } + this.loadingSeries = true; + + this.seriesService.getAllSeries(this.pagination?.currentPage, this.pagination?.itemsPerPage, this.filter).pipe(take(1)).subscribe(series => { + this.series = series.result; + this.pagination = series.pagination; + this.loadingSeries = false; + window.scrollTo(0, 0); + }); + } + + onPageChange(pagination: Pagination) { + window.history.replaceState(window.location.href, '', window.location.href.split('?')[0] + '?page=' + this.pagination.currentPage); + this.loadPage(); + } + + trackByIdentity = (index: number, item: Series) => `${item.name}_${item.originalName}_${item.localizedName}_${item.pagesRead}`; + + getPage() { + const urlParams = new URLSearchParams(window.location.search); + return urlParams.get('page'); + } + +} diff --git a/UI/Web/src/app/app-routing.module.ts b/UI/Web/src/app/app-routing.module.ts index 39800d8cd..636f0052b 100644 --- a/UI/Web/src/app/app-routing.module.ts +++ b/UI/Web/src/app/app-routing.module.ts @@ -9,6 +9,7 @@ import { AuthGuard } from './_guards/auth.guard'; import { LibraryAccessGuard } from './_guards/library-access.guard'; import { OnDeckComponent } from './on-deck/on-deck.component'; import { DashboardComponent } from './dashboard/dashboard.component'; +import { AllSeriesComponent } from './all-series/all-series.component'; // TODO: Once we modularize the components, use this and measure performance impact: https://angular.io/guide/lazy-loading-ngmodules#preloading-modules @@ -55,6 +56,8 @@ const routes: Routes = [ {path: 'library', component: DashboardComponent}, {path: 'recently-added', component: RecentlyAddedComponent}, {path: 'on-deck', component: OnDeckComponent}, + {path: 'all-series', component: AllSeriesComponent}, + ] }, {path: 'login', component: UserLoginComponent}, diff --git a/UI/Web/src/app/app.module.ts b/UI/Web/src/app/app.module.ts index 7984fd121..9d30a62c8 100644 --- a/UI/Web/src/app/app.module.ts +++ b/UI/Web/src/app/app.module.ts @@ -34,6 +34,7 @@ import { ConfigData } from './_models/config-data'; import { NavEventsToggleComponent } from './nav-events-toggle/nav-events-toggle.component'; import { PersonRolePipe } from './person-role.pipe'; import { SeriesMetadataDetailComponent } from './series-metadata-detail/series-metadata-detail.component'; +import { AllSeriesComponent } from './all-series/all-series.component'; @NgModule({ @@ -52,6 +53,7 @@ import { SeriesMetadataDetailComponent } from './series-metadata-detail/series-m NavEventsToggleComponent, PersonRolePipe, SeriesMetadataDetailComponent, + AllSeriesComponent, ], imports: [ HttpClientModule, 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 50819f3d6..ff1123797 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 @@ -86,8 +86,8 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { this.seriesService.getMetadata(this.series.id).subscribe(metadata => { if (metadata) { this.metadata = metadata; - this.settings.savedData = metadata.tags; - this.tags = metadata.tags; + this.settings.savedData = metadata.collectionTags; + this.tags = metadata.collectionTags; this.editSeriesForm.get('summary')?.setValue(this.metadata.summary); } }); diff --git a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html index 0fa06722c..ed6d3b691 100644 --- a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html +++ b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html @@ -40,7 +40,7 @@
-
+
@@ -54,7 +54,7 @@
-
+
@@ -68,7 +68,7 @@
-
+
@@ -82,7 +82,7 @@
-
+
@@ -95,10 +95,24 @@
+ +
+
+ + + + {{item.title}} + + + {{item.title}} + + +
+
-
+
@@ -112,7 +126,7 @@
-
+
@@ -126,7 +140,7 @@
-
+
@@ -140,9 +154,9 @@
-
+
- + {{item.title}} @@ -154,7 +168,7 @@
-
+
@@ -168,7 +182,7 @@
-
+
@@ -182,7 +196,7 @@
-
+
@@ -196,7 +210,7 @@
-
+
@@ -210,7 +224,7 @@
-
+
@@ -223,11 +237,23 @@
+ +
+
+ + + + {{item.title}} + + + {{item.title}} + + +
+
- -
- +
@@ -245,7 +271,7 @@
-
+
@@ -255,15 +281,59 @@
- -
- - + +
+ + + + {{item.title}} + + + {{item.title}} + +
+ +
+ + + + {{item.title}} + + + {{item.title}} + + +
+ +
+
+
+ + + +
+
+
+ +
- +
+ + + +
diff --git a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts index ad2274997..d3cb24235 100644 --- a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts +++ b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts @@ -1,16 +1,20 @@ import { Component, ContentChild, EventEmitter, Input, OnDestroy, OnInit, Output, TemplateRef } from '@angular/core'; import { FormControl, FormGroup } from '@angular/forms'; -import { of, ReplaySubject, Subject } from 'rxjs'; -import { takeUntil } from 'rxjs/operators'; +import { Observable, of, ReplaySubject, Subject } from 'rxjs'; +import { map, takeUntil } from 'rxjs/operators'; import { UtilityService } from 'src/app/shared/_services/utility.service'; import { TypeaheadSettings } from 'src/app/typeahead/typeahead-settings'; import { CollectionTag } from 'src/app/_models/collection-tag'; import { Genre } from 'src/app/_models/genre'; import { Library } from 'src/app/_models/library'; import { MangaFormat } from 'src/app/_models/manga-format'; +import { AgeRating } from 'src/app/_models/metadata/age-rating'; +import { AgeRatingDto } from 'src/app/_models/metadata/age-rating-dto'; +import { Language } from 'src/app/_models/metadata/language'; import { Pagination } from 'src/app/_models/pagination'; import { Person, PersonRole } from 'src/app/_models/person'; -import { FilterItem, mangaFormatFilters, SeriesFilter } from 'src/app/_models/series-filter'; +import { FilterItem, mangaFormatFilters, SeriesFilter, SortField } from 'src/app/_models/series-filter'; +import { Tag } from 'src/app/_models/tag'; import { ActionItem } from 'src/app/_services/action-factory.service'; import { CollectionTagService } from 'src/app/_services/collection-tag.service'; import { LibraryService } from 'src/app/_services/library.service'; @@ -29,6 +33,12 @@ export class FilterSettings { peopleDisabled = false; readProgressDisabled = false; ratingDisabled = false; + presetLibraryId = 0; + presetCollectionId = 0; + sortDisabled = false; + ageRatingDisabled = false; + tagsDisabled = false; + languageDisabled = false; } @Component({ @@ -59,6 +69,9 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy { librarySettings: TypeaheadSettings> = new TypeaheadSettings(); genreSettings: TypeaheadSettings> = new TypeaheadSettings(); collectionSettings: TypeaheadSettings> = new TypeaheadSettings(); + ageRatingSettings: TypeaheadSettings> = new TypeaheadSettings(); + tagsSettings: TypeaheadSettings> = new TypeaheadSettings(); + languageSettings: TypeaheadSettings> = new TypeaheadSettings(); peopleSettings: {[PersonRole: string]: TypeaheadSettings>} = {}; resetTypeaheads: Subject = new ReplaySubject(1); @@ -74,6 +87,8 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy { collectionTags: Array> = []; readProgressGroup!: FormGroup; + sortGroup!: FormGroup; + isAscendingSort: boolean = true; updateApplied: number = 0; @@ -83,6 +98,10 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy { return PersonRole; } + get SortField(): typeof SortField { + return SortField; + } + constructor(private libraryService: LibraryService, private metadataService: MetadataService, private seriesService: SeriesService, private utilityService: UtilityService, private collectionTagService: CollectionTagService) { this.filter = this.seriesService.createSeriesFilter(); @@ -92,10 +111,39 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy { inProgress: new FormControl(this.filter.readStatus.inProgress, []), }); + this.sortGroup = new FormGroup({ + sortField: new FormControl(this.filter.sortOptions?.sortField || SortField.SortName, []), + }); + this.readProgressGroup.valueChanges.pipe(takeUntil(this.onDestory)).subscribe(changes => { this.filter.readStatus.read = this.readProgressGroup.get('read')?.value; this.filter.readStatus.inProgress = this.readProgressGroup.get('inProgress')?.value; this.filter.readStatus.notRead = this.readProgressGroup.get('notRead')?.value; + + let sum = 0; + sum += (this.filter.readStatus.read ? 1 : 0); + sum += (this.filter.readStatus.inProgress ? 1 : 0); + sum += (this.filter.readStatus.notRead ? 1 : 0); + + if (sum === 1) { + if (this.filter.readStatus.read) this.readProgressGroup.get('read')?.disable({ emitEvent: false }); + if (this.filter.readStatus.notRead) this.readProgressGroup.get('notRead')?.disable({ emitEvent: false }); + if (this.filter.readStatus.inProgress) this.readProgressGroup.get('inProgress')?.disable({ emitEvent: false }); + } else { + this.readProgressGroup.get('read')?.enable({ emitEvent: false }); + this.readProgressGroup.get('notRead')?.enable({ emitEvent: false }); + this.readProgressGroup.get('inProgress')?.enable({ emitEvent: false }); + } + }); + + this.sortGroup.valueChanges.pipe(takeUntil(this.onDestory)).subscribe(changes => { + if (this.filter.sortOptions == null) { + this.filter.sortOptions = { + isAscending: this.isAscendingSort, + sortField: parseInt(this.sortGroup.get('sortField')?.value, 10) + }; + } + this.filter.sortOptions.sortField = parseInt(this.sortGroup.get('sortField')?.value, 10); }); } @@ -107,18 +155,7 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy { this.filterSettings = new FilterSettings(); } - - this.metadataService.getAllGenres().subscribe(genres => { - this.genres = genres.map(genre => { - return { - title: genre.title, - value: genre, - selected: false, - } - }); - this.setupGenreTypeahead(); - - }); + this.setupGenreTypeahead(); this.libraryService.getLibrariesForMember().subscribe(libs => { this.libraries = libs.map(lib => { @@ -131,27 +168,11 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy { this.setupLibraryTypeahead(); }); - this.metadataService.getAllPeople().subscribe(res => { - this.persons = res.map(lib => { - return { - title: lib.name, - value: lib, - selected: true, - } - }); - this.setupPersonTypeahead(); - }); - - this.collectionTagService.allTags().subscribe(tags => { - this.collectionTags = tags.map(lib => { - return { - title: lib.title, - value: lib, - selected: false, - } - }); - this.setupCollectionTagTypeahead(); - }); + this.setupCollectionTagTypeahead(); + this.setupPersonTypeahead(); + this.setupAgeRatingSettings(); + this.setupTagSettings(); + this.setupLanguageSettings(); } ngOnDestroy() { @@ -171,7 +192,6 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy { const f = filter.toLowerCase(); return options.filter(m => m.title.toLowerCase() === f); } - this.formatSettings.savedData = mangaFormatFilters; } setupLibraryTypeahead() { @@ -187,6 +207,12 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy { const f = filter.toLowerCase(); return options.filter(m => m.title.toLowerCase() === f); } + + if (this.filterSettings.presetLibraryId > 0) { + this.librarySettings.savedData = this.libraries.filter(item => item.value.id === this.filterSettings.presetLibraryId); + this.filter.libraries = this.librarySettings.savedData.map(item => item.value.id); + this.resetTypeaheads.next(true); // For some reason library just doesn't update properly with savedData + } } setupGenreTypeahead() { @@ -196,7 +222,15 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy { this.genreSettings.unique = true; this.genreSettings.addIfNonExisting = false; this.genreSettings.fetchFn = (filter: string) => { - return of (this.genres) + return this.metadataService.getAllGenres(this.filter.libraries).pipe(map(genres => { + return genres.map(genre => { + return { + title: genre.title, + value: genre, + selected: false, + } + }) + })); }; this.genreSettings.compareFn = (options: FilterItem[], filter: string) => { const f = filter.toLowerCase(); @@ -204,6 +238,75 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy { } } + setupAgeRatingSettings() { + this.ageRatingSettings.minCharacters = 0; + this.ageRatingSettings.multiple = true; + this.ageRatingSettings.id = 'age-rating'; + this.ageRatingSettings.unique = true; + this.ageRatingSettings.addIfNonExisting = false; + this.ageRatingSettings.fetchFn = (filter: string) => { + return this.metadataService.getAllAgeRatings(this.filter.libraries).pipe(map(ratings => { + return ratings.map(rating => { + return { + title: rating.title, + value: rating, + selected: false, + } + }) + })); + }; + this.ageRatingSettings.compareFn = (options: FilterItem[], filter: string) => { + const f = filter.toLowerCase(); + return options.filter(m => m.title.toLowerCase() === f && this.utilityService.filter(m.title, filter)); + } + } + + setupTagSettings() { + this.tagsSettings.minCharacters = 0; + this.tagsSettings.multiple = true; + this.tagsSettings.id = 'tags'; + this.tagsSettings.unique = true; + this.tagsSettings.addIfNonExisting = false; + this.tagsSettings.fetchFn = (filter: string) => { + return this.metadataService.getAllTags(this.filter.libraries).pipe(map(tags => { + return tags.map(tag => { + return { + title: tag.title, + value: tag, + selected: false, + } + }) + })); + }; + this.tagsSettings.compareFn = (options: FilterItem[], filter: string) => { + const f = filter.toLowerCase(); + return options.filter(m => m.title.toLowerCase() === f && this.utilityService.filter(m.title, filter)); + } + } + + setupLanguageSettings() { + this.languageSettings.minCharacters = 0; + this.languageSettings.multiple = true; + this.languageSettings.id = 'languages'; + this.languageSettings.unique = true; + this.languageSettings.addIfNonExisting = false; + this.languageSettings.fetchFn = (filter: string) => { + return this.metadataService.getAllLanguages(this.filter.libraries).pipe(map(tags => { + return tags.map(tag => { + return { + title: tag.title, + value: tag, + selected: false, + } + }) + })); + }; + this.languageSettings.compareFn = (options: FilterItem[], filter: string) => { + const f = filter.toLowerCase(); + return options.filter(m => m.title.toLowerCase() === f && this.utilityService.filter(m.title, filter)); + } + } + setupCollectionTagTypeahead() { this.collectionSettings.minCharacters = 0; this.collectionSettings.multiple = true; @@ -211,12 +314,25 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy { this.collectionSettings.unique = true; this.collectionSettings.addIfNonExisting = false; this.collectionSettings.fetchFn = (filter: string) => { - return of (this.collectionTags) + return this.collectionTagService.allTags().pipe(map(tags => { + return tags.map(lib => { + return { + title: lib.title, + value: lib, + selected: false, + } + }); + })); }; this.collectionSettings.compareFn = (options: FilterItem[], filter: string) => { const f = filter.toLowerCase(); return options.filter(m => m.title.toLowerCase() === f); } + if (this.filterSettings.presetCollectionId > 0) { + this.collectionSettings.savedData = this.collectionTags.filter(item => item.value.id === this.filterSettings.presetCollectionId); + this.filter.collectionTags = this.collectionSettings.savedData.map(item => item.value.id); + this.resetTypeaheads.next(true); + } } setupPersonTypeahead() { @@ -224,58 +340,75 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy { var personSettings = this.createBlankPersonSettings('writers'); personSettings.fetchFn = (filter: string) => { - return of (this.persons.filter(p => p.value.role == PersonRole.Writer && this.utilityService.filter(p.value.name, filter))); + return this.fetchPeople(PersonRole.Writer, filter); }; this.peopleSettings[PersonRole.Writer] = personSettings; personSettings = this.createBlankPersonSettings('character'); personSettings.fetchFn = (filter: string) => { - - return of (this.persons.filter(p => p.value.role == PersonRole.Character && this.utilityService.filter(p.title, filter))) + return this.fetchPeople(PersonRole.Character, filter); }; this.peopleSettings[PersonRole.Character] = personSettings; personSettings = this.createBlankPersonSettings('colorist'); personSettings.fetchFn = (filter: string) => { - return of (this.persons.filter(p => p.value.role == PersonRole.Colorist && this.utilityService.filter(p.title, filter))) + return this.fetchPeople(PersonRole.Colorist, filter); }; this.peopleSettings[PersonRole.Colorist] = personSettings; personSettings = this.createBlankPersonSettings('cover-artist'); personSettings.fetchFn = (filter: string) => { - return of (this.persons.filter(p => p.value.role == PersonRole.CoverArtist && this.utilityService.filter(p.title, filter))) + return this.fetchPeople(PersonRole.CoverArtist, filter); }; this.peopleSettings[PersonRole.CoverArtist] = personSettings; personSettings = this.createBlankPersonSettings('editor'); personSettings.fetchFn = (filter: string) => { - return of (this.persons.filter(p => p.value.role == PersonRole.Editor && this.utilityService.filter(p.title, filter))) + return this.fetchPeople(PersonRole.Editor, filter); }; this.peopleSettings[PersonRole.Editor] = personSettings; personSettings = this.createBlankPersonSettings('inker'); personSettings.fetchFn = (filter: string) => { - return of (this.persons.filter(p => p.value.role == PersonRole.Inker && this.utilityService.filter(p.title, filter))) + return this.fetchPeople(PersonRole.Inker, filter); }; this.peopleSettings[PersonRole.Inker] = personSettings; personSettings = this.createBlankPersonSettings('letterer'); personSettings.fetchFn = (filter: string) => { - return of (this.persons.filter(p => p.value.role == PersonRole.Letterer && this.utilityService.filter(p.title, filter))) + return this.fetchPeople(PersonRole.Letterer, filter); }; this.peopleSettings[PersonRole.Letterer] = personSettings; personSettings = this.createBlankPersonSettings('penciller'); personSettings.fetchFn = (filter: string) => { - return of (this.persons.filter(p => p.value.role == PersonRole.Penciller && this.utilityService.filter(p.title, filter))) + return this.fetchPeople(PersonRole.Penciller, filter); }; this.peopleSettings[PersonRole.Penciller] = personSettings; personSettings = this.createBlankPersonSettings('publisher'); personSettings.fetchFn = (filter: string) => { - return of (this.persons.filter(p => p.value.role == PersonRole.Publisher && this.utilityService.filter(p.title, filter))) + return this.fetchPeople(PersonRole.Publisher, filter); }; this.peopleSettings[PersonRole.Publisher] = personSettings; + + personSettings = this.createBlankPersonSettings('translators'); + personSettings.fetchFn = (filter: string) => { + return this.fetchPeople(PersonRole.Translator, filter); + }; + this.peopleSettings[PersonRole.Translator] = personSettings; + } + + fetchPeople(role: PersonRole, filter: string): Observable[]> { + return this.metadataService.getAllPeople(this.filter.libraries).pipe(map(people => { + return people.filter(p => p.role == role && this.utilityService.filter(p.name, filter)).map((p: Person) => { + return { + title: p.name, + value: p, + selected: false, + } + }); + })); } createBlankPersonSettings(id: string) { @@ -325,6 +458,10 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy { this.filter.genres = genres.map(item => item.value.id) || []; } + updateTagFilters(tags: FilterItem[]) { + this.filter.tags = tags.map(item => item.value.id) || []; + } + updatePersonFilters(persons: FilterItem[], role: PersonRole) { switch (role) { case PersonRole.CoverArtist: @@ -357,6 +494,8 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy { case PersonRole.Writer: this.filter.writers = persons.map(p => p.value.id); break; + case PersonRole.Translator: + this.filter.translators = persons.map(p => p.value.id); } } @@ -369,6 +508,14 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy { this.filter.rating = rating; } + updateAgeRating(ratingDtos: FilterItem[]) { + this.filter.ageRating = ratingDtos.map(item => item.value.value) || []; + } + + updateLanguageRating(languages: FilterItem[]) { + this.filter.languages = languages.map(item => item.value.isoCode) || []; + } + updateReadStatus(status: string) { console.log('readstatus: ', this.filter.readStatus); if (status === 'read') { @@ -380,13 +527,26 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy { } } + updateSortOrder() { + this.isAscendingSort = !this.isAscendingSort; + if (this.filter.sortOptions !== null) { + this.filter.sortOptions.isAscending = this.isAscendingSort; + } + } + getPersonsSettings(role: PersonRole) { return this.peopleSettings[role]; } clear() { this.filter = this.seriesService.createSeriesFilter(); + this.readProgressGroup.get('read')?.setValue(true); + this.readProgressGroup.get('notRead')?.setValue(true); + this.readProgressGroup.get('inProgress')?.setValue(true); + this.sortGroup.get('sortField')?.setValue(SortField.SortName); + this.isAscendingSort = true; this.resetTypeaheads.next(true); + this.applyFilter.emit(this.filter); this.updateApplied++; } diff --git a/UI/Web/src/app/cards/card-item/card-item.component.scss b/UI/Web/src/app/cards/card-item/card-item.component.scss index aa24f0358..d1c81c155 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.scss +++ b/UI/Web/src/app/cards/card-item/card-item.component.scss @@ -1,6 +1,6 @@ @use '../../../theme/colors'; -$triangle-size: 40px; +$triangle-size: 30px; $image-height: 230px; $image-width: 160px; diff --git a/UI/Web/src/app/collections/collection-detail/collection-detail.component.html b/UI/Web/src/app/collections/collection-detail/collection-detail.component.html index 4957b8c62..0e8516cf8 100644 --- a/UI/Web/src/app/collections/collection-detail/collection-detail.component.html +++ b/UI/Web/src/app/collections/collection-detail/collection-detail.component.html @@ -32,6 +32,7 @@ [isLoading]="isLoading" [items]="series" [pagination]="seriesPagination" + [filterSettings]="filterSettings" (pageChange)="onPageChange($event)" (applyFilter)="updateFilter($event)" > diff --git a/UI/Web/src/app/collections/collection-detail/collection-detail.component.ts b/UI/Web/src/app/collections/collection-detail/collection-detail.component.ts index 6a1831ee2..bc96ff8f5 100644 --- a/UI/Web/src/app/collections/collection-detail/collection-detail.component.ts +++ b/UI/Web/src/app/collections/collection-detail/collection-detail.component.ts @@ -6,6 +6,7 @@ import { ToastrService } from 'ngx-toastr'; import { Subject } from 'rxjs'; import { debounceTime, take, takeUntil, takeWhile } from 'rxjs/operators'; import { BulkSelectionService } from 'src/app/cards/bulk-selection.service'; +import { FilterSettings } from 'src/app/cards/card-detail-layout/card-detail-layout.component'; import { EditCollectionTagsComponent } from 'src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component'; import { KEY_CODES } from 'src/app/shared/_services/utility.service'; import { CollectionTag } from 'src/app/_models/collection-tag'; @@ -38,6 +39,7 @@ export class CollectionDetailComponent implements OnInit, OnDestroy { collectionTagActions: ActionItem[] = []; isAdmin: boolean = false; filter: SeriesFilter | undefined = undefined; + filterSettings: FilterSettings = new FilterSettings(); private onDestory: Subject = new Subject(); @@ -95,6 +97,9 @@ export class CollectionDetailComponent implements OnInit, OnDestroy { return; } const tagId = parseInt(routeId, 10); + + this.filterSettings.presetCollectionId = tagId; + this.updateTag(tagId); } diff --git a/UI/Web/src/app/library-detail/library-detail.component.html b/UI/Web/src/app/library-detail/library-detail.component.html index 65cc555dc..003475563 100644 --- a/UI/Web/src/app/library-detail/library-detail.component.html +++ b/UI/Web/src/app/library-detail/library-detail.component.html @@ -4,6 +4,7 @@ [items]="series" [actions]="actions" [pagination]="pagination" + [filterSettings]="filterSettings" (applyFilter)="updateFilter($event)" (pageChange)="onPageChange($event)" > diff --git a/UI/Web/src/app/library-detail/library-detail.component.ts b/UI/Web/src/app/library-detail/library-detail.component.ts index cb356cfa1..e3257bb7c 100644 --- a/UI/Web/src/app/library-detail/library-detail.component.ts +++ b/UI/Web/src/app/library-detail/library-detail.component.ts @@ -4,6 +4,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import { Subject } from 'rxjs'; import { debounceTime, take, takeUntil, takeWhile } from 'rxjs/operators'; import { BulkSelectionService } from '../cards/bulk-selection.service'; +import { FilterSettings } from '../cards/card-detail-layout/card-detail-layout.component'; import { KEY_CODES } from '../shared/_services/utility.service'; import { SeriesAddedEvent } from '../_models/events/series-added-event'; import { Library } from '../_models/library'; @@ -31,6 +32,7 @@ export class LibraryDetailComponent implements OnInit, OnDestroy { actions: ActionItem[] = []; filter: SeriesFilter | undefined = undefined; onDestroy: Subject = new Subject(); + filterSettings: FilterSettings = new FilterSettings(); bulkActionCallback = (action: Action, data: any) => { const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('series'); @@ -85,6 +87,8 @@ export class LibraryDetailComponent implements OnInit, OnDestroy { }); this.actions = this.actionFactoryService.getLibraryActions(this.handleAction.bind(this)); this.pagination = {currentPage: 0, itemsPerPage: 30, totalItems: 0, totalPages: 1}; + this.filterSettings.presetLibraryId = this.libraryId; + this.loadPage(); } @@ -147,7 +151,12 @@ export class LibraryDetailComponent implements OnInit, OnDestroy { } this.loadingSeries = true; - this.seriesService.getSeriesForLibrary(this.libraryId, this.pagination?.currentPage, this.pagination?.itemsPerPage, this.filter).pipe(take(1)).subscribe(series => { + if (this.filter == undefined) { + this.filter = this.seriesService.createSeriesFilter(); + this.filter.libraries.push(this.libraryId); + } + + this.seriesService.getSeriesForLibrary(0, this.pagination?.currentPage, this.pagination?.itemsPerPage, this.filter).pipe(take(1)).subscribe(series => { this.series = series.result; this.pagination = series.pagination; this.loadingSeries = false; diff --git a/UI/Web/src/app/library/library.component.html b/UI/Web/src/app/library/library.component.html index 3157a7d80..52c642743 100644 --- a/UI/Web/src/app/library/library.component.html +++ b/UI/Web/src/app/library/library.component.html @@ -17,7 +17,7 @@ - + diff --git a/UI/Web/src/app/library/library.component.ts b/UI/Web/src/app/library/library.component.ts index dc5ff3228..83ff53e80 100644 --- a/UI/Web/src/app/library/library.component.ts +++ b/UI/Web/src/app/library/library.component.ts @@ -110,6 +110,8 @@ export class LibraryComponent implements OnInit, OnDestroy { this.router.navigate(['recently-added']); } else if (sectionTitle.toLowerCase() === 'on deck') { this.router.navigate(['on-deck']); + } else if (sectionTitle.toLowerCase() === 'libraries') { + this.router.navigate(['all-series']); } } diff --git a/UI/Web/src/app/on-deck/on-deck.component.ts b/UI/Web/src/app/on-deck/on-deck.component.ts index 7c661461b..0d8d23ee5 100644 --- a/UI/Web/src/app/on-deck/on-deck.component.ts +++ b/UI/Web/src/app/on-deck/on-deck.component.ts @@ -34,6 +34,7 @@ export class OnDeckComponent implements OnInit { this.pagination = {currentPage: 0, itemsPerPage: 30, totalItems: 0, totalPages: 1}; } this.filterSettings.readProgressDisabled = true; + this.filterSettings.sortDisabled = true; this.loadPage(); } diff --git a/UI/Web/src/app/recently-added/recently-added.component.html b/UI/Web/src/app/recently-added/recently-added.component.html index 27ef260d2..71db9dbfd 100644 --- a/UI/Web/src/app/recently-added/recently-added.component.html +++ b/UI/Web/src/app/recently-added/recently-added.component.html @@ -3,6 +3,7 @@ [isLoading]="isLoading" [items]="series" [pagination]="pagination" +[filterSettings]="filterSettings" (applyFilter)="applyFilter($event)" (pageChange)="onPageChange($event)" > diff --git a/UI/Web/src/app/recently-added/recently-added.component.ts b/UI/Web/src/app/recently-added/recently-added.component.ts index b0e8fccd1..f7b6f146a 100644 --- a/UI/Web/src/app/recently-added/recently-added.component.ts +++ b/UI/Web/src/app/recently-added/recently-added.component.ts @@ -4,6 +4,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import { Subject } from 'rxjs'; import { debounceTime, take, takeUntil, takeWhile } from 'rxjs/operators'; import { BulkSelectionService } from '../cards/bulk-selection.service'; +import { FilterSettings } from '../cards/card-detail-layout/card-detail-layout.component'; import { KEY_CODES } from '../shared/_services/utility.service'; import { SeriesAddedEvent } from '../_models/events/series-added-event'; import { Pagination } from '../_models/pagination'; @@ -30,6 +31,7 @@ export class RecentlyAddedComponent implements OnInit, OnDestroy { libraryId!: number; filter: SeriesFilter | undefined = undefined; + filterSettings: FilterSettings = new FilterSettings(); onDestroy: Subject = new Subject(); @@ -40,6 +42,8 @@ export class RecentlyAddedComponent implements OnInit, OnDestroy { if (this.pagination === undefined || this.pagination === null) { this.pagination = {currentPage: 0, itemsPerPage: 30, totalItems: 0, totalPages: 1}; } + this.filterSettings.sortDisabled = true; + this.loadPage(); } diff --git a/UI/Web/src/app/series-metadata-detail/series-metadata-detail.component.html b/UI/Web/src/app/series-metadata-detail/series-metadata-detail.component.html index e7863adc8..7558e31a4 100644 --- a/UI/Web/src/app/series-metadata-detail/series-metadata-detail.component.html +++ b/UI/Web/src/app/series-metadata-detail/series-metadata-detail.component.html @@ -1,14 +1,15 @@ -
+
-
- {{ageRatingName}} +
+ {{metadataService.getAgeRating(this.seriesMetadata.ageRating) | async}} {{seriesMetadata.releaseYear}} + {{seriesMetadata.language}} {{utilityService.mangaFormat(series.format)}} @@ -20,17 +21,25 @@
Genres
- {{genre.title}} + + + {{item.title}} + +
-
+
Collections
- - {{tag.title}} - + + + + {{item.title}} + + +
@@ -38,12 +47,16 @@
Authors
- + + + + +
@@ -53,7 +66,11 @@
Artists
- + + + + +
@@ -62,7 +79,11 @@
Characters
- + + + + +
@@ -71,7 +92,11 @@
Colorists
- + + + + +
@@ -80,7 +105,11 @@
Editors
- + + + + +
@@ -89,7 +118,11 @@
Inkers
- + + + + +
@@ -98,7 +131,35 @@
Letterers
- + + + + + +
+
+
+
+
Tags
+
+
+ + + {{item.title}} + + +
+
+
+
+
Translators
+
+
+ + + + +
@@ -107,7 +168,11 @@
Pencillers
- + + + + +
@@ -116,7 +181,11 @@
Publishers
- + + + + +
\ No newline at end of file diff --git a/UI/Web/src/app/series-metadata-detail/series-metadata-detail.component.ts b/UI/Web/src/app/series-metadata-detail/series-metadata-detail.component.ts index ba1960a56..84973c059 100644 --- a/UI/Web/src/app/series-metadata-detail/series-metadata-detail.component.ts +++ b/UI/Web/src/app/series-metadata-detail/series-metadata-detail.component.ts @@ -19,10 +19,6 @@ export class SeriesMetadataDetailComponent implements OnInit, OnChanges { isCollapsed: boolean = true; hasExtendedProperites: boolean = false; - /** - * String representation of AgeRating enum - */ - ageRatingName: string = ''; /** * Html representation of Series Summary */ @@ -36,7 +32,7 @@ export class SeriesMetadataDetailComponent implements OnInit, OnChanges { return TagBadgeCursor; } - constructor(public utilityService: UtilityService, private metadataService: MetadataService) { } + constructor(public utilityService: UtilityService, public metadataService: MetadataService) { } ngOnChanges(changes: SimpleChanges): void { this.hasExtendedProperites = this.seriesMetadata.colorists.length > 0 || @@ -45,11 +41,9 @@ export class SeriesMetadataDetailComponent implements OnInit, OnChanges { this.seriesMetadata.inkers.length > 0 || this.seriesMetadata.letterers.length > 0 || this.seriesMetadata.pencillers.length > 0 || - this.seriesMetadata.publishers.length > 0; - - this.metadataService.getAgeRating(this.seriesMetadata.ageRating).subscribe(rating => { - this.ageRatingName = rating; - }); + this.seriesMetadata.publishers.length > 0 || + this.seriesMetadata.translators.length > 0 || + this.seriesMetadata.tags.length > 0; if (this.seriesMetadata !== null) { this.seriesSummary = (this.seriesMetadata.summary === null ? '' : this.seriesMetadata.summary).replace(/\n/g, '
'); diff --git a/UI/Web/src/app/shared/badge-expander/badge-expander.component.html b/UI/Web/src/app/shared/badge-expander/badge-expander.component.html new file mode 100644 index 000000000..c17188530 --- /dev/null +++ b/UI/Web/src/app/shared/badge-expander/badge-expander.component.html @@ -0,0 +1,8 @@ +
+
+ + +
+
\ No newline at end of file diff --git a/UI/Web/src/app/shared/badge-expander/badge-expander.component.scss b/UI/Web/src/app/shared/badge-expander/badge-expander.component.scss new file mode 100644 index 000000000..8ed3de4f4 --- /dev/null +++ b/UI/Web/src/app/shared/badge-expander/badge-expander.component.scss @@ -0,0 +1,12 @@ +.content { + width: 100%; +} + +.collapsed { + height: 35px; + overflow: hidden; +} + +.badge-expander { + //display: inline-block; +} \ No newline at end of file diff --git a/UI/Web/src/app/shared/badge-expander/badge-expander.component.ts b/UI/Web/src/app/shared/badge-expander/badge-expander.component.ts new file mode 100644 index 000000000..3f4ddbcb4 --- /dev/null +++ b/UI/Web/src/app/shared/badge-expander/badge-expander.component.ts @@ -0,0 +1,32 @@ +import { Component, ContentChild, Input, OnInit, TemplateRef } from '@angular/core'; + +@Component({ + selector: 'app-badge-expander', + templateUrl: './badge-expander.component.html', + styleUrls: ['./badge-expander.component.scss'] +}) +export class BadgeExpanderComponent implements OnInit { + + @Input() items: Array = []; + @ContentChild('badgeExpanderItem') itemTemplate!: TemplateRef; + + + visibleItems: Array = []; + isCollapsed: boolean = false; + + get itemsLeft() { + return Math.max(this.items.length - 4, 0); + } + constructor() { } + + ngOnInit(): void { + this.visibleItems = this.items.slice(0, 4); + } + + toggleVisible() { + this.isCollapsed = !this.isCollapsed; + + this.visibleItems = this.items; + } + +} diff --git a/UI/Web/src/app/shared/shared.module.ts b/UI/Web/src/app/shared/shared.module.ts index 25e3632ce..6ae54f582 100644 --- a/UI/Web/src/app/shared/shared.module.ts +++ b/UI/Web/src/app/shared/shared.module.ts @@ -17,6 +17,7 @@ import { CircularLoaderComponent } from './circular-loader/circular-loader.compo import { NgCircleProgressModule } from 'ng-circle-progress'; import { SentenceCasePipe } from './sentence-case.pipe'; import { PersonBadgeComponent } from './person-badge/person-badge.component'; +import { BadgeExpanderComponent } from './badge-expander/badge-expander.component'; @NgModule({ declarations: [ @@ -32,7 +33,8 @@ import { PersonBadgeComponent } from './person-badge/person-badge.component'; UpdateNotificationModalComponent, CircularLoaderComponent, SentenceCasePipe, - PersonBadgeComponent + PersonBadgeComponent, + BadgeExpanderComponent ], imports: [ CommonModule, @@ -55,7 +57,8 @@ import { PersonBadgeComponent } from './person-badge/person-badge.component'; SeriesFormatComponent, TagBadgeComponent, CircularLoaderComponent, - PersonBadgeComponent + PersonBadgeComponent, + BadgeExpanderComponent ], }) export class SharedModule { } diff --git a/UI/Web/src/app/typeahead/typeahead.component.scss b/UI/Web/src/app/typeahead/typeahead.component.scss index d238ddf81..40b9b8132 100644 --- a/UI/Web/src/app/typeahead/typeahead.component.scss +++ b/UI/Web/src/app/typeahead/typeahead.component.scss @@ -35,6 +35,9 @@ input { line-height: inherit !important; box-shadow: none !important; } + input:empty { + padding-top: 6px !important; + } } ::ng-deep .bg-dark .typeahead-input { @@ -62,7 +65,7 @@ input { overflow-x: hidden; .list-group-item { - padding: 5px 5px; + padding: 5px 10px; } diff --git a/UI/Web/src/app/typeahead/typeahead.component.ts b/UI/Web/src/app/typeahead/typeahead.component.ts index be093518b..d26912044 100644 --- a/UI/Web/src/app/typeahead/typeahead.component.ts +++ b/UI/Web/src/app/typeahead/typeahead.component.ts @@ -254,7 +254,7 @@ export class TypeaheadComponent implements OnInit, OnDestroy { @HostListener('window:click', ['$event']) - handleDocumentClick() { + handleDocumentClick(event: any) { this.hasFocus = false; } @@ -370,6 +370,8 @@ export class TypeaheadComponent implements OnInit, OnDestroy { } 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; }