diff --git a/API.Tests/Comparers/ChapterSortComparerZeroFirstTests.cs b/API.Tests/Comparers/ChapterSortComparerZeroFirstTests.cs index 72d6908c6..df3934884 100644 --- a/API.Tests/Comparers/ChapterSortComparerZeroFirstTests.cs +++ b/API.Tests/Comparers/ChapterSortComparerZeroFirstTests.cs @@ -14,4 +14,11 @@ public class ChapterSortComparerZeroFirstTests { Assert.Equal(expected, input.OrderBy(f => f, new ChapterSortComparerZeroFirst()).ToArray()); } + + [Theory] + [InlineData(new[] {1.0, 0.5, 0.3}, new[] {0.3, 0.5, 1.0})] + public void ChapterSortComparerZeroFirstTest_Doubles(double[] input, double[] expected) + { + Assert.Equal(expected, input.OrderBy(f => f, new ChapterSortComparerZeroFirst()).ToArray()); + } } diff --git a/API.Tests/Comparers/NumericComparerTests.cs b/API.Tests/Comparers/NumericComparerTests.cs index 9a66e7666..8a1f23773 100644 --- a/API.Tests/Comparers/NumericComparerTests.cs +++ b/API.Tests/Comparers/NumericComparerTests.cs @@ -11,6 +11,10 @@ public class NumericComparerTests new[] {"x1.jpg", "x10.jpg", "x3.jpg", "x4.jpg", "x11.jpg"}, new[] {"x1.jpg", "x3.jpg", "x4.jpg", "x10.jpg", "x11.jpg"} )] + [InlineData( + new[] {"x1.0.jpg", "0.5.jpg", "0.3.jpg"}, + new[] {"0.3.jpg", "0.5.jpg", "x1.0.jpg",} + )] public void NumericComparerTest(string[] input, string[] expected) { var nc = new NumericComparer(); diff --git a/API.Tests/Services/ScannerServiceTests.cs b/API.Tests/Services/ScannerServiceTests.cs index 280fe5c10..e3331bf6d 100644 --- a/API.Tests/Services/ScannerServiceTests.cs +++ b/API.Tests/Services/ScannerServiceTests.cs @@ -125,5 +125,6 @@ namespace API.Tests.Services // } + } } diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index ee1fcd584..a473e1fd7 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -6,8 +6,10 @@ using API.Data; using API.Data.Repositories; using API.DTOs; using API.DTOs.Filtering; +using API.DTOs.SeriesDetail; using API.Entities; using API.Entities.Enums; +using API.Entities.Metadata; using API.Extensions; using API.Helpers; using API.Services; @@ -339,5 +341,79 @@ namespace API.Controllers var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); return await _seriesService.GetSeriesDetail(seriesId, userId); } + + /// + /// Fetches the related series for a given series + /// + /// + /// Type of Relationship to pull back + /// + [HttpGet("related")] + public async Task>> GetRelatedSeries(int seriesId, RelationKind relation) + { + // Send back a custom DTO with each type or maybe sorted in some way + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + return Ok(await _unitOfWork.SeriesRepository.GetSeriesForRelationKind(userId, seriesId, relation)); + } + + [HttpGet("all-related")] + public async Task> GetAllRelatedSeries(int seriesId) + { + // Send back a custom DTO with each type or maybe sorted in some way + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + return Ok(await _unitOfWork.SeriesRepository.GetRelatedSeries(userId, seriesId)); + } + + [Authorize(Policy="RequireAdminRole")] + [HttpPost("update-related")] + public async Task UpdateRelatedSeries(UpdateRelatedSeriesDto dto) + { + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(dto.SeriesId, SeriesIncludes.Related); + + UpdateRelationForKind(dto.Adaptations, series.Relations.Where(r => r.RelationKind == RelationKind.Adaptation).ToList(), series, RelationKind.Adaptation); + UpdateRelationForKind(dto.Characters, series.Relations.Where(r => r.RelationKind == RelationKind.Character).ToList(), series, RelationKind.Character); + UpdateRelationForKind(dto.Contains, series.Relations.Where(r => r.RelationKind == RelationKind.Contains).ToList(), series, RelationKind.Contains); + UpdateRelationForKind(dto.Others, series.Relations.Where(r => r.RelationKind == RelationKind.Other).ToList(), series, RelationKind.Other); + UpdateRelationForKind(dto.SideStories, series.Relations.Where(r => r.RelationKind == RelationKind.SideStory).ToList(), series, RelationKind.SideStory); + UpdateRelationForKind(dto.SpinOffs, series.Relations.Where(r => r.RelationKind == RelationKind.SpinOff).ToList(), series, RelationKind.SpinOff); + UpdateRelationForKind(dto.AlternativeSettings, series.Relations.Where(r => r.RelationKind == RelationKind.AlternativeSetting).ToList(), series, RelationKind.AlternativeSetting); + UpdateRelationForKind(dto.AlternativeVersions, series.Relations.Where(r => r.RelationKind == RelationKind.AlternativeVersion).ToList(), series, RelationKind.AlternativeVersion); + UpdateRelationForKind(dto.Doujinshis, series.Relations.Where(r => r.RelationKind == RelationKind.Doujinshi).ToList(), series, RelationKind.Doujinshi); + UpdateRelationForKind(dto.Prequels, series.Relations.Where(r => r.RelationKind == RelationKind.Prequel).ToList(), series, RelationKind.Prequel); + UpdateRelationForKind(dto.Sequels, series.Relations.Where(r => r.RelationKind == RelationKind.Sequel).ToList(), series, RelationKind.Sequel); + + if (!_unitOfWork.HasChanges()) return Ok(); + if (await _unitOfWork.CommitAsync()) return Ok(); + + + return BadRequest("There was an issue updating relationships"); + } + + private void UpdateRelationForKind(IList dtoTargetSeriesIds, IEnumerable adaptations, Series series, RelationKind kind) + { + foreach (var adaptation in adaptations.Where(adaptation => !dtoTargetSeriesIds.Contains(adaptation.TargetSeriesId))) + { + // If the seriesId isn't in dto, it means we've removed or reclassified + series.Relations.Remove(adaptation); + } + + // At this point, we only have things to add + foreach (var targetSeriesId in dtoTargetSeriesIds) + { + // This ensures we don't allow any duplicates to be added + if (series.Relations.SingleOrDefault(r => + r.RelationKind == kind && r.TargetSeriesId == targetSeriesId) != + null) continue; + + series.Relations.Add(new SeriesRelation() + { + Series = series, + SeriesId = series.Id, + TargetSeriesId = targetSeriesId, + RelationKind = kind + }); + _unitOfWork.SeriesRepository.Update(series); + } + } } } diff --git a/API/DTOs/SeriesDetail/RelatedSeriesDto.cs b/API/DTOs/SeriesDetail/RelatedSeriesDto.cs new file mode 100644 index 000000000..f3c3fd644 --- /dev/null +++ b/API/DTOs/SeriesDetail/RelatedSeriesDto.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using API.Entities.Enums; + +namespace API.DTOs.SeriesDetail; + +public class RelatedSeriesDto +{ + /// + /// The parent relationship Series + /// + public int SourceSeriesId { get; set; } + + public IEnumerable Sequels { get; set; } + public IEnumerable Prequels { get; set; } + public IEnumerable SpinOffs { get; set; } + public IEnumerable Adaptations { get; set; } + public IEnumerable SideStories { get; set; } + public IEnumerable Characters { get; set; } + public IEnumerable Contains { get; set; } + public IEnumerable Others { get; set; } + public IEnumerable AlternativeSettings { get; set; } + public IEnumerable AlternativeVersions { get; set; } + public IEnumerable Doujinshis { get; set; } + public IEnumerable Parent { get; set; } +} diff --git a/API/DTOs/SeriesDetailDto.cs b/API/DTOs/SeriesDetail/SeriesDetailDto.cs similarity index 100% rename from API/DTOs/SeriesDetailDto.cs rename to API/DTOs/SeriesDetail/SeriesDetailDto.cs diff --git a/API/DTOs/SeriesDetail/UpdateRelatedSeriesDto.cs b/API/DTOs/SeriesDetail/UpdateRelatedSeriesDto.cs new file mode 100644 index 000000000..b39f91244 --- /dev/null +++ b/API/DTOs/SeriesDetail/UpdateRelatedSeriesDto.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; + +namespace API.DTOs.SeriesDetail; + +public class UpdateRelatedSeriesDto +{ + public int SeriesId { get; set; } + public IList Adaptations { get; set; } + public IList Characters { get; set; } + public IList Contains { get; set; } + public IList Others { get; set; } + public IList Prequels { get; set; } + public IList Sequels { get; set; } + public IList SideStories { get; set; } + public IList SpinOffs { get; set; } + public IList AlternativeSettings { get; set; } + public IList AlternativeVersions { get; set; } + public IList Doujinshis { get; set; } +} diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index 6822467a8..90a6718f4 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -41,24 +41,36 @@ namespace API.Data public DbSet Genre { get; set; } public DbSet Tag { get; set; } public DbSet SiteTheme { get; set; } + public DbSet SeriesRelation { get; set; } - protected override void OnModelCreating(ModelBuilder builder) + protected override void OnModelCreating(ModelBuilder modelBuilder) { - base.OnModelCreating(builder); + base.OnModelCreating(modelBuilder); - builder.Entity() + modelBuilder.Entity() .HasMany(ur => ur.UserRoles) .WithOne(u => u.User) .HasForeignKey(ur => ur.UserId) .IsRequired(); - builder.Entity() + modelBuilder.Entity() .HasMany(ur => ur.UserRoles) .WithOne(u => u.Role) .HasForeignKey(ur => ur.RoleId) .IsRequired(); + + modelBuilder.Entity() + .HasOne(pt => pt.Series) + .WithMany(p => p.Relations) + .HasForeignKey(pt => pt.SeriesId) + .OnDelete(DeleteBehavior.ClientCascade); + + modelBuilder.Entity() + .HasOne(pt => pt.TargetSeries) + .WithMany(t => t.RelationOf) + .HasForeignKey(pt => pt.TargetSeriesId); } diff --git a/API/Data/Migrations/20220421214448_SeriesRelations.Designer.cs b/API/Data/Migrations/20220421214448_SeriesRelations.Designer.cs new file mode 100644 index 000000000..11937eb15 --- /dev/null +++ b/API/Data/Migrations/20220421214448_SeriesRelations.Designer.cs @@ -0,0 +1,1513 @@ +// +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("20220421214448_SeriesRelations")] + partial class SeriesRelations + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.3"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .HasColumnType("TEXT"); + + 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("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NameLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20220421214448_SeriesRelations.cs b/API/Data/Migrations/20220421214448_SeriesRelations.cs new file mode 100644 index 000000000..1f6d5d7ab --- /dev/null +++ b/API/Data/Migrations/20220421214448_SeriesRelations.cs @@ -0,0 +1,55 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class SeriesRelations : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "SeriesRelation", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + RelationKind = table.Column(type: "INTEGER", nullable: false), + TargetSeriesId = table.Column(type: "INTEGER", nullable: false), + SeriesId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SeriesRelation", x => x.Id); + table.ForeignKey( + name: "FK_SeriesRelation_Series_SeriesId", + column: x => x.SeriesId, + principalTable: "Series", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_SeriesRelation_Series_TargetSeriesId", + column: x => x.TargetSeriesId, + principalTable: "Series", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_SeriesRelation_SeriesId", + table: "SeriesRelation", + column: "SeriesId"); + + migrationBuilder.CreateIndex( + name: "IX_SeriesRelation_TargetSeriesId", + table: "SeriesRelation", + column: "TargetSeriesId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "SeriesRelation"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 43b955788..8cb97845c 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -592,6 +592,30 @@ namespace API.Data.Migrations b.ToTable("SeriesMetadata"); }); + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + modelBuilder.Entity("API.Entities.Person", b => { b.Property("Id") @@ -1182,6 +1206,25 @@ namespace API.Data.Migrations b.Navigation("Series"); }); + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + modelBuilder.Entity("API.Entities.ReadingList", b => { b.HasOne("API.Entities.AppUser", "AppUser") @@ -1451,6 +1494,10 @@ namespace API.Data.Migrations b.Navigation("Ratings"); + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + b.Navigation("Volumes"); }); diff --git a/API/Data/Repositories/CollectionTagRepository.cs b/API/Data/Repositories/CollectionTagRepository.cs index a519c774f..7fbf14dd5 100644 --- a/API/Data/Repositories/CollectionTagRepository.cs +++ b/API/Data/Repositories/CollectionTagRepository.cs @@ -84,7 +84,6 @@ public class CollectionTagRepository : ICollectionTagRepository public async Task> GetAllTagDtosAsync() { return await _context.CollectionTag - .Select(c => c) .OrderBy(c => c.NormalizedTitle) .AsNoTracking() .ProjectTo(_mapper.ConfigurationProvider) diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index fd9dcc52d..ec44de970 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -11,6 +11,7 @@ using API.DTOs.Filtering; using API.DTOs.Metadata; using API.DTOs.ReadingLists; using API.DTOs.Search; +using API.DTOs.SeriesDetail; using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; @@ -24,6 +25,17 @@ using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +[Flags] +public enum SeriesIncludes +{ + None = 1, + Volumes = 2, + Metadata = 4, + Related = 8, + //Related = 16, + //UserPreferences = 32 +} + internal class RecentlyAddedSeries { public int LibraryId { get; init; } @@ -68,7 +80,7 @@ public interface ISeriesRepository Task> GetSeriesForLibraryIdAsync(int libraryId); Task GetSeriesDtoByIdAsync(int seriesId, int userId); Task DeleteSeriesAsync(int seriesId); - Task GetSeriesByIdAsync(int seriesId); + Task GetSeriesByIdAsync(int seriesId, SeriesIncludes includes = SeriesIncludes.Volumes | SeriesIncludes.Metadata); Task> GetSeriesByIdsAsync(IList seriesIds); Task GetChapterIdsForSeriesAsync(IList seriesIds); Task>> GetChapterIdWithSeriesIdForSeriesAsync(int[] seriesIds); @@ -96,6 +108,9 @@ public interface ISeriesRepository Task> GetAllLanguagesForLibrariesAsync(List libraryIds); IEnumerable GetAllPublicationStatusesDtosForLibrariesAsync(List libraryIds); Task> GetRecentlyUpdatedSeries(int userId, int pageSize = 30); + Task GetRelatedSeries(int userId, int seriesId); + + Task> GetSeriesForRelationKind(int userId, int seriesId, RelationKind kind); } public class SeriesRepository : ISeriesRepository @@ -376,19 +391,35 @@ public class SeriesRepository : ISeriesRepository /// /// /// - public async Task GetSeriesByIdAsync(int seriesId) + public async Task GetSeriesByIdAsync(int seriesId, SeriesIncludes includes = SeriesIncludes.Volumes | SeriesIncludes.Metadata) { - return await _context.Series - .Include(s => s.Volumes) - .Include(s => s.Metadata) - .ThenInclude(m => m.CollectionTags) - .Include(s => s.Metadata) - .ThenInclude(m => m.Genres) - .Include(s => s.Metadata) - .ThenInclude(m => m.People) + var query = _context.Series .Where(s => s.Id == seriesId) - .AsSplitQuery() - .SingleOrDefaultAsync(); + .AsSplitQuery(); + + if (includes.HasFlag(SeriesIncludes.Volumes)) + { + query = query.Include(s => s.Volumes); + } + + if (includes.HasFlag(SeriesIncludes.Related)) + { + query = query.Include(s => s.Relations) + .ThenInclude(r => r.TargetSeries) + .Include(s => s.RelationOf); + } + + if (includes.HasFlag(SeriesIncludes.Metadata)) + { + query = query.Include(s => s.Metadata) + .ThenInclude(m => m.CollectionTags) + .Include(s => s.Metadata) + .ThenInclude(m => m.Genres) + .Include(s => s.Metadata) + .ThenInclude(m => m.People); + } + + return await query.SingleOrDefaultAsync(); } /// @@ -939,6 +970,79 @@ public class SeriesRepository : ISeriesRepository return seriesMap.Values.AsEnumerable(); } + public async Task> GetSeriesForRelationKind(int userId, int seriesId, RelationKind kind) + { + var libraryIds = _context.AppUser + .Where(u => u.Id == userId) + .SelectMany(l => l.Libraries.Select(lib => lib.Id)); + var usersSeriesIds = _context.Series + .Where(s => libraryIds.Contains(s.LibraryId)) + .Select(s => s.Id); + + var targetSeries = _context.SeriesRelation + .Where(sr => + sr.SeriesId == seriesId && sr.RelationKind == kind && usersSeriesIds.Contains(sr.TargetSeriesId)) + .Include(sr => sr.TargetSeries) + .AsSplitQuery() + .AsNoTracking() + .Select(sr => sr.TargetSeriesId); + + return await _context.Series + .Where(s => targetSeries.Contains(s.Id)) + .AsSplitQuery() + .AsNoTracking() + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } + + public async Task GetRelatedSeries(int userId, int seriesId) + { + var libraryIds = _context.AppUser + .Where(u => u.Id == userId) + .SelectMany(l => l.Libraries.Select(lib => lib.Id)); + var usersSeriesIds = _context.Series + .Where(s => libraryIds.Contains(s.LibraryId)) + .Select(s => s.Id); + + return new RelatedSeriesDto() + { + SourceSeriesId = seriesId, + Adaptations = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Adaptation), + Characters = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Character), + Prequels = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Prequel), + Sequels = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Sequel), + Contains = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Contains), + SideStories = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.SideStory), + SpinOffs = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.SpinOff), + Others = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Other), + AlternativeSettings = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.AlternativeSetting), + AlternativeVersions = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.AlternativeVersion), + Doujinshis = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Doujinshi), + Parent = await _context.Series + .SelectMany(s => + s.RelationOf.Where(r => r.TargetSeriesId == seriesId + && usersSeriesIds.Contains(r.TargetSeriesId) + && r.RelationKind != RelationKind.Prequel + && r.RelationKind != RelationKind.Sequel) + .Select(sr => sr.Series)) + .AsSplitQuery() + .AsNoTracking() + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync() + }; + } + + private async Task> GetRelatedSeriesQuery(int seriesId, IEnumerable usersSeriesIds, RelationKind kind) + { + return await _context.Series.SelectMany(s => + s.Relations.Where(sr => sr.RelationKind == kind && sr.SeriesId == seriesId && usersSeriesIds.Contains(sr.TargetSeriesId)) + .Select(sr => sr.TargetSeries)) + .AsSplitQuery() + .AsNoTracking() + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } + private async Task> GetRecentlyAddedChaptersQuery(int userId, int maxRecords = 3000) { var libraries = await _context.AppUser diff --git a/API/Entities/Enums/RelationKind.cs b/API/Entities/Enums/RelationKind.cs new file mode 100644 index 000000000..c01ab9571 --- /dev/null +++ b/API/Entities/Enums/RelationKind.cs @@ -0,0 +1,66 @@ +using System.ComponentModel; + +namespace API.Entities.Enums; + +/// +/// Represents a relationship between Series +/// +public enum RelationKind +{ + /// + /// Story that occurred before the original. + /// + [Description("Prequel")] + Prequel = 1, + /// + /// Direct continuation of the story. + /// + [Description("Sequel")] + Sequel = 2, + /// + /// Uses characters of a different series, but is not an alternate setting or story. + /// + [Description("Spin Off")] + SpinOff = 3, + /// + /// Manga/Anime/Light Novel adaptation + /// + [Description("Adaptation")] + Adaptation = 4, + /// + /// Takes place sometime during the parent storyline. + /// + [Description("Side Story")] + SideStory = 5, + /// + /// When characters appear in both series, but is not a spin-off + /// + [Description("Character")] + Character = 6, + /// + /// When the story contains another story, useful for One-Shots + /// + [Description("Contains")] + Contains = 7, + /// + /// When nothing else fits + /// + [Description("Other")] + Other = 8, + /// + /// Same universe/world/reality/timeline, completely different characters + /// + [Description("Alternative Setting")] + AlternativeSetting = 9, + /// + /// Same setting, same characters, story is told differently + /// + [Description("Alternative Version")] + AlternativeVersion = 10, + /// + /// Doujinshi or Fan work + /// + [Description("Doujinshi")] + Doujinshi = 11 + +} diff --git a/API/Entities/Metadata/SeriesRelation.cs b/API/Entities/Metadata/SeriesRelation.cs new file mode 100644 index 000000000..46e6c34b9 --- /dev/null +++ b/API/Entities/Metadata/SeriesRelation.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using API.Entities.Enums; + +namespace API.Entities.Metadata; + +/// +/// A relation flows between one series and another. +/// Series ---kind---> target +/// +public class SeriesRelation +{ + public int Id { get; set; } + public RelationKind RelationKind { get; set; } + + public virtual Series TargetSeries { get; set; } + /// + /// A is Sequel to B. In this example, TargetSeries is A. B will hold the foreign key. + /// + public int TargetSeriesId { get; set; } + + // Relationships + public virtual Series Series { get; set; } + public int SeriesId { get; set; } +} diff --git a/API/Entities/Series.cs b/API/Entities/Series.cs index ef4b62eb4..1ddd8f082 100644 --- a/API/Entities/Series.cs +++ b/API/Entities/Series.cs @@ -66,9 +66,18 @@ public class Series : IEntityDate public DateTime LastChapterAdded { get; set; } public SeriesMetadata Metadata { get; set; } + public ICollection Ratings { get; set; } = new List(); public ICollection Progress { get; set; } = new List(); + /// + /// Relations to other Series, like Sequels, Prequels, etc + /// + /// 1 to Many relationship + public virtual ICollection Relations { get; set; } = new List(); + public virtual ICollection RelationOf { get; set; } = new List(); + + // Relationships public List Volumes { get; set; } public Library Library { get; set; } diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index 3765b7e47..0a292934a 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -6,6 +6,7 @@ using API.DTOs.Metadata; using API.DTOs.Reader; using API.DTOs.ReadingLists; using API.DTOs.Search; +using API.DTOs.SeriesDetail; using API.DTOs.Settings; using API.DTOs.Theme; using API.Entities; @@ -96,6 +97,10 @@ namespace API.Helpers opt => opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Editor))); + // CreateMap() + // .ForMember(dest => dest.Adaptations, + // opt => + // opt.MapFrom(src => src.Where(p => p.Role == PersonRole.Writer))) CreateMap(); CreateMap(); diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index a917396b8..32d469818 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -272,13 +272,8 @@ public class ScannerService : IScannerService _logger.LogInformation("[ScannerService] Beginning file scan on {LibraryName}", library.Name); - // await _eventHub.SendMessageAsync(SignalREvents.NotificationProgress, - // MessageFactory.ScanLibraryProgressEvent(libraryId, 0F)); - var (totalFiles, scanElapsedTime, series) = await ScanFiles(library, library.Folders.Select(fp => fp.Path)); - // var scanner = new ParseScannedFiles(_logger, _directoryService, _readingItemService); - // var series = scanner.ScanLibrariesForSeries(library.Type, library.Folders.Select(fp => fp.Path), out var totalFiles, out var scanElapsedTime); _logger.LogInformation("[ScannerService] Finished file scan. Updating database"); foreach (var folderPath in library.Folders) @@ -305,8 +300,6 @@ public class ScannerService : IScannerService await CleanupDbEntities(); - // await _eventHub.SendMessageAsync(SignalREvents.NotificationProgress, - // MessageFactory.ScanLibraryProgressEvent(libraryId, 1F)); BackgroundJob.Enqueue(() => _metadataService.RefreshMetadata(libraryId, false)); } diff --git a/UI/Web/src/app/_models/series-detail/related-series.ts b/UI/Web/src/app/_models/series-detail/related-series.ts new file mode 100644 index 000000000..02d394382 --- /dev/null +++ b/UI/Web/src/app/_models/series-detail/related-series.ts @@ -0,0 +1,17 @@ +import { Series } from "../series"; + +export interface RelatedSeries { + sourceSeriesId: number; + sequels: Array; + prequels: Array; + spinOffs: Array; + adaptations: Array; + sideStories: Array; + characters: Array; + contains: Array; + others: Array; + alternativeSettings: Array; + alternativeVersions: Array; + doujinshis: Array; + parent: Array; +} \ No newline at end of file diff --git a/UI/Web/src/app/_models/series-detail/relation-kind.ts b/UI/Web/src/app/_models/series-detail/relation-kind.ts new file mode 100644 index 000000000..203401af3 --- /dev/null +++ b/UI/Web/src/app/_models/series-detail/relation-kind.ts @@ -0,0 +1,31 @@ +export enum RelationKind { + Prequel = 1, + Sequel = 2, + SpinOff = 3, + Adaptation = 4, + SideStory = 5, + Character = 6, + Contains = 7, + Other = 8, + AlternativeSetting = 9, + AlternativeVersion = 10, + Doujinshi = 11, + /** + * This is UI only. Backend will generate Parent series for everything but Prequel/Sequel + */ + Parent = 12 +} + +export const RelationKinds = [ + {text: 'Prequel', value: RelationKind.Prequel}, + {text: 'Sequel', value: RelationKind.Sequel}, + {text: 'Spin Off', value: RelationKind.SpinOff}, + {text: 'Adaptation', value: RelationKind.Adaptation}, + {text: 'Alternative Setting', value: RelationKind.AlternativeSetting}, + {text: 'Alternative Version', value: RelationKind.AlternativeVersion}, + {text: 'Side Story', value: RelationKind.SideStory}, + {text: 'Character', value: RelationKind.Character}, + {text: 'Contains', value: RelationKind.Contains}, + {text: 'Doujinshi', value: RelationKind.Doujinshi}, + {text: 'Other', value: RelationKind.Other}, +]; \ No newline at end of file diff --git a/UI/Web/src/app/_services/library.service.ts b/UI/Web/src/app/_services/library.service.ts index 7aea516f0..697de7b1c 100644 --- a/UI/Web/src/app/_services/library.service.ts +++ b/UI/Web/src/app/_services/library.service.ts @@ -4,7 +4,6 @@ import { of } from 'rxjs'; import { map, take } from 'rxjs/operators'; import { environment } from 'src/environments/environment'; import { Library, LibraryType } from '../_models/library'; -import { SearchResult } from '../_models/search-result'; import { SearchResultGroup } from '../_models/search/search-result-group'; diff --git a/UI/Web/src/app/_services/series.service.ts b/UI/Web/src/app/_services/series.service.ts index 30c0f5971..0c340a802 100644 --- a/UI/Web/src/app/_services/series.service.ts +++ b/UI/Web/src/app/_services/series.service.ts @@ -9,6 +9,8 @@ import { CollectionTag } from '../_models/collection-tag'; import { PaginatedResult } from '../_models/pagination'; import { RecentlyAddedItem } from '../_models/recently-added-item'; import { Series } from '../_models/series'; +import { RelatedSeries } from '../_models/series-detail/related-series'; +import { RelationKind } from '../_models/series-detail/relation-kind'; import { SeriesDetail } from '../_models/series-detail/series-detail'; import { SeriesFilter } from '../_models/series-filter'; import { SeriesGroup } from '../_models/series-group'; @@ -182,6 +184,19 @@ export class SeriesService { ); } + getRelatedForSeries(seriesId: number) { + return this.httpClient.get(this.baseUrl + 'series/all-related?seriesId=' + seriesId); + } + + updateRelationships(seriesId: number, adaptations: Array, characters: Array, + contains: Array, others: Array, prequels: Array, + sequels: Array, sideStories: Array, spinOffs: Array, + alternativeSettings: Array, alternativeVersions: Array, doujinshis: Array) { + return this.httpClient.post(this.baseUrl + 'series/update-related?seriesId=' + seriesId, + {seriesId, adaptations, characters, sequels, prequels, contains, others, sideStories, spinOffs, + alternativeSettings, alternativeVersions, doujinshis}); + } + getSeriesDetail(seriesId: number) { return this.httpClient.get(this.baseUrl + 'series/series-detail?seriesId=' + seriesId); } diff --git a/UI/Web/src/app/app.component.html b/UI/Web/src/app/app.component.html index 524a1c29f..df9ff69cb 100644 --- a/UI/Web/src/app/app.component.html +++ b/UI/Web/src/app/app.component.html @@ -1,10 +1,10 @@ -
+
-
+
diff --git a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html index 86a89e1ec..5ed555cf8 100644 --- a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html +++ b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html @@ -330,7 +330,13 @@
  • - {{tabs[4]}} + {{tabs[4]}} + + + +
  • +
  • + {{tabs[5]}}

    Information

    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 d057cd02d..d156ac282 100644 --- a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts +++ b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { Component, EventEmitter, Input, OnDestroy, OnInit } from '@angular/core'; import { FormBuilder, FormControl, FormGroup } from '@angular/forms'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { forkJoin, Observable, of, Subject } from 'rxjs'; @@ -39,7 +39,7 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { isCollapsed = true; volumeCollapsed: any = {}; - tabs = ['General', 'Metadata', 'People', 'Cover Image', 'Info']; + tabs = ['General', 'Metadata', 'People', 'Cover Image', 'Related', 'Info']; active = this.tabs[0]; editSeriesForm!: FormGroup; libraryName: string | undefined = undefined; @@ -73,6 +73,8 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { coverImageReset = false; + saveNestedComponents: EventEmitter = new EventEmitter(); + get Breakpoint(): typeof Breakpoint { return Breakpoint; } @@ -420,6 +422,9 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { apis.push(this.uploadService.updateSeriesCoverImage(model.id, this.selectedCover)); } + this.saveNestedComponents.emit(); + + forkJoin(apis).subscribe(results => { this.modal.close({success: true, series: model, coverImageUpdate: selectedIndex > 0}); 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 540c9091b..4a4151664 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 @@ -1,24 +1,24 @@
    -
    -

    - -   - - - {{header}}  - {{pagination.totalItems}} - -

    -
    +
    +

    + +   + + + {{header}}  + {{pagination.totalItems}} + +

    +
    - + - + - -
    + +
    @@ -27,50 +27,63 @@

    +
    - + + + +
    +
    + +
    + +

    + There is no data +

    +
    +
    -
    - +
    + - -
  • -
    - - - - of {{pagination.totalPages}} -
    -
  • - + +
  • +
    + + + + of {{pagination.totalPages}} +
    +
  • +
    - -
    + +
    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 1dd9e8000..8fd472023 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 @@ -52,6 +52,7 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy { private onDestory: Subject = new Subject(); + isMobile: boolean = false; constructor(private seriesService: SeriesService) { this.filter = this.seriesService.createSeriesFilter(); @@ -62,9 +63,15 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy { if (this.filterSettings === undefined) { - console.log('filter settings was empty, creating our own'); this.filterSettings = new FilterSettings(); } + + if (this.pagination === undefined) { + this.pagination = {currentPage: 1, itemsPerPage: this.items.length, totalItems: this.items.length, totalPages: 1} + } + + this.isMobile = window.innerWidth <= 480; + window.onresize = () => this.isMobile = window.innerWidth <= 480; } ngOnDestroy() { diff --git a/UI/Web/src/app/cards/card-item/card-item.component.html b/UI/Web/src/app/cards/card-item/card-item.component.html index 1ea863f41..2a7997cdc 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.html +++ b/UI/Web/src/app/cards/card-item/card-item.component.html @@ -30,6 +30,11 @@ {{count}}
    +
    +
    + {{overlayInformation}} +
    +
    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 f436a0fa8..14a8a82d4 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 @@ -100,6 +100,14 @@ $image-width: 160px; } } +.overlay-information { + position: absolute; + top: 5px; + left: 5px; + border-radius: 15px; + padding: 0 10px; + background-color: var(--card-bg-color); +} .overlay { height: $image-height; diff --git a/UI/Web/src/app/cards/card-item/card-item.component.ts b/UI/Web/src/app/cards/card-item/card-item.component.ts index feb4fe9af..51122f90f 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.ts +++ b/UI/Web/src/app/cards/card-item/card-item.component.ts @@ -71,11 +71,15 @@ export class CardItemComponent implements OnInit, OnDestroy { /** * This will supress the cannot read archive warning when total pages is 0 */ - @Input() supressArchiveWarning: boolean = false; + @Input() supressArchiveWarning: boolean = false; /** * The number of updates/items within the card. If less than 2, will not be shown. */ - @Input() count: number = 0; + @Input() count: number = 0; + /** + * Additional information to show on the overlay area. Will always render. + */ + @Input() overlayInformation: string = ''; /** * Event emitted when item is clicked */ diff --git a/UI/Web/src/app/cards/cards.module.ts b/UI/Web/src/app/cards/cards.module.ts index 1f8d2d23a..3095fa415 100644 --- a/UI/Web/src/app/cards/cards.module.ts +++ b/UI/Web/src/app/cards/cards.module.ts @@ -21,6 +21,7 @@ import { PipeModule } from '../pipe/pipe.module'; import { ChapterMetadataDetailComponent } from './chapter-metadata-detail/chapter-metadata-detail.component'; import { FileInfoComponent } from './file-info/file-info.component'; import { MetadataFilterModule } from '../metadata-filter/metadata-filter.module'; +import { EditSeriesRelationComponent } from './edit-series-relation/edit-series-relation.component'; @@ -39,19 +40,20 @@ import { MetadataFilterModule } from '../metadata-filter/metadata-filter.module' BulkAddToCollectionComponent, ChapterMetadataDetailComponent, FileInfoComponent, + EditSeriesRelationComponent, ], imports: [ CommonModule, RouterModule, ReactiveFormsModule, FormsModule, // EditCollectionsModal - + PipeModule, SharedModule, TypeaheadModule, // edit series modal MetadataFilterModule, - + NgbNavModule, NgbTooltipModule, // Card item NgbCollapseModule, @@ -79,7 +81,8 @@ import { MetadataFilterModule } from '../metadata-filter/metadata-filter.module' CardDetailLayoutComponent, CardDetailsModalComponent, BulkOperationsComponent, - ChapterMetadataDetailComponent + ChapterMetadataDetailComponent, + EditSeriesRelationComponent ] }) export class CardsModule { } diff --git a/UI/Web/src/app/cards/edit-series-relation/edit-series-relation.component.html b/UI/Web/src/app/cards/edit-series-relation/edit-series-relation.component.html new file mode 100644 index 000000000..57b7be59f --- /dev/null +++ b/UI/Web/src/app/cards/edit-series-relation/edit-series-relation.component.html @@ -0,0 +1,36 @@ +
    + +

    + Not sure what relationship to add? See our wiki for hints. +

    + +
    + + +
    + +
    +
    +
    + + + {{item.name}} ({{libraryNames[item.libraryId]}}) + + + {{item.name}} ({{libraryNames[item.libraryId]}}) + + +
    +
    + +
    + +
    +
    + +
    + +
    +
    \ No newline at end of file diff --git a/UI/Web/src/app/cards/edit-series-relation/edit-series-relation.component.scss b/UI/Web/src/app/cards/edit-series-relation/edit-series-relation.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/UI/Web/src/app/cards/edit-series-relation/edit-series-relation.component.ts b/UI/Web/src/app/cards/edit-series-relation/edit-series-relation.component.ts new file mode 100644 index 000000000..381abbb1e --- /dev/null +++ b/UI/Web/src/app/cards/edit-series-relation/edit-series-relation.component.ts @@ -0,0 +1,150 @@ +import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { map, Subject, Observable, of, firstValueFrom, takeUntil, ReplaySubject } from 'rxjs'; +import { UtilityService } from 'src/app/shared/_services/utility.service'; +import { TypeaheadSettings } from 'src/app/typeahead/typeahead-settings'; +import { SearchResult } from 'src/app/_models/search-result'; +import { Series } from 'src/app/_models/series'; +import { RelationKind, RelationKinds } from 'src/app/_models/series-detail/relation-kind'; +import { ImageService } from 'src/app/_services/image.service'; +import { LibraryService } from 'src/app/_services/library.service'; +import { SeriesService } from 'src/app/_services/series.service'; + +interface RelationControl { + series: {id: number, name: string} | undefined; // Will add type as well + typeaheadSettings: TypeaheadSettings; + formControl: FormControl; +} + +@Component({ + selector: 'app-edit-series-relation', + templateUrl: './edit-series-relation.component.html', + styleUrls: ['./edit-series-relation.component.scss'] +}) +export class EditSeriesRelationComponent implements OnInit, OnDestroy { + + @Input() series!: Series; + /** + * This will tell the component to save based on it's internal state + */ + @Input() save: EventEmitter = new EventEmitter(); + + @Output() saveApi = new ReplaySubject(1); + relationOptions = RelationKinds; + + relations: Array = []; + seriesSettings: TypeaheadSettings = new TypeaheadSettings(); + libraryNames: {[key:number]: string} = {}; + + + + private onDestroy: Subject = new Subject(); + + constructor(private seriesService: SeriesService, private utilityService: UtilityService, + public imageService: ImageService, private libraryService: LibraryService) { } + + ngOnInit(): void { + this.seriesService.getRelatedForSeries(this.series.id).subscribe(async relations => { + this.setupRelationRows(relations.prequels, RelationKind.Prequel); + this.setupRelationRows(relations.sequels, RelationKind.Sequel); + this.setupRelationRows(relations.sideStories, RelationKind.SideStory); + this.setupRelationRows(relations.spinOffs, RelationKind.SpinOff); + this.setupRelationRows(relations.adaptations, RelationKind.Adaptation); + this.setupRelationRows(relations.others, RelationKind.Other); + this.setupRelationRows(relations.characters, RelationKind.Character); + this.setupRelationRows(relations.alternativeSettings, RelationKind.AlternativeSetting); + this.setupRelationRows(relations.alternativeVersions, RelationKind.AlternativeVersion); + this.setupRelationRows(relations.doujinshis, RelationKind.Doujinshi); + }); + + this.libraryService.getLibraryNames().subscribe(names => { + this.libraryNames = names; + }); + + this.save.pipe(takeUntil(this.onDestroy)).subscribe(() => this.saveState()); + } + + ngOnDestroy(): void { + this.onDestroy.next(); + this.onDestroy.complete(); + } + + setupRelationRows(relations: Array, kind: RelationKind) { + relations.map(async item => { + const settings = await firstValueFrom(this.createSeriesTypeahead(item, kind)); + return {series: item, typeaheadSettings: settings, formControl: new FormControl(kind, [])} + }).forEach(async p => { + this.relations.push(await p); + }); + } + + async addNewRelation() { + this.relations.push({series: undefined, formControl: new FormControl(RelationKind.Adaptation, []), typeaheadSettings: await firstValueFrom(this.createSeriesTypeahead(undefined, RelationKind.Adaptation))}); + } + + removeRelation(index: number) { + this.relations.splice(index, 1); + } + + + updateSeries(event: Array, relation: RelationControl) { + if (event[0] === undefined) { + relation.series = undefined; + return; + } + relation.series = {id: event[0].seriesId, name: event[0].name}; + } + + createSeriesTypeahead(series: Series | undefined, relationship: RelationKind): Observable> { + const seriesSettings = new TypeaheadSettings(); + seriesSettings.minCharacters = 0; + seriesSettings.multiple = false; + seriesSettings.id = 'format'; + seriesSettings.unique = true; + seriesSettings.addIfNonExisting = false; + seriesSettings.fetchFn = (searchFilter: string) => this.libraryService.search(searchFilter).pipe( + map(group => group.series), + map(items => seriesSettings.compareFn(items, searchFilter)), + map(series => series.filter(s => s.seriesId !== this.series.id)), + ); + + seriesSettings.compareFn = (options: SearchResult[], filter: string) => { + return options.filter(m => this.utilityService.filter(m.name, filter)); + } + + seriesSettings.selectionCompareFn = (a: SearchResult, b: SearchResult) => { + return a.seriesId == b.seriesId; + } + + if (series !== undefined) { + return this.libraryService.search(series.name).pipe( + map(group => group.series), map(results => { + seriesSettings.savedData = results.filter(s => s.seriesId === series.id); + return seriesSettings; + })); + } + + return of(seriesSettings); + } + + saveState() { + const adaptations = this.relations.filter(item => (parseInt(item.formControl.value, 10) as RelationKind) === RelationKind.Adaptation && item.series !== undefined).map(item => item.series!.id); + const characters = this.relations.filter(item => (parseInt(item.formControl.value, 10) as RelationKind) === RelationKind.Character && item.series !== undefined).map(item => item.series!.id); + const contains = this.relations.filter(item => (parseInt(item.formControl.value, 10) as RelationKind) === RelationKind.Contains && item.series !== undefined).map(item => item.series!.id); + const others = this.relations.filter(item => (parseInt(item.formControl.value, 10) as RelationKind) === RelationKind.Other && item.series !== undefined).map(item => item.series!.id); + const prequels = this.relations.filter(item => (parseInt(item.formControl.value, 10) as RelationKind) === RelationKind.Prequel && item.series !== undefined).map(item => item.series!.id); + const sequels = this.relations.filter(item => (parseInt(item.formControl.value, 10) as RelationKind) === RelationKind.Sequel && item.series !== undefined).map(item => item.series!.id); + const sideStories = this.relations.filter(item => (parseInt(item.formControl.value, 10) as RelationKind) === RelationKind.SideStory && item.series !== undefined).map(item => item.series!.id); + const spinOffs = this.relations.filter(item => (parseInt(item.formControl.value, 10) as RelationKind) === RelationKind.SpinOff && item.series !== undefined).map(item => item.series!.id); + const alternativeSettings = this.relations.filter(item => (parseInt(item.formControl.value, 10) as RelationKind) === RelationKind.AlternativeSetting && item.series !== undefined).map(item => item.series!.id); + const alternativeVersions = this.relations.filter(item => (parseInt(item.formControl.value, 10) as RelationKind) === RelationKind.AlternativeVersion && item.series !== undefined).map(item => item.series!.id); + const doujinshis = this.relations.filter(item => (parseInt(item.formControl.value, 10) as RelationKind) === RelationKind.Doujinshi && item.series !== undefined).map(item => item.series!.id); + + // TODO: We can actually emit this onto an observable and in main parent, use mergeMap into the forkJoin + + //this.saveApi.next(this.seriesService.updateRelationships(this.series.id, adaptations, characters, contains, others, prequels, sequels, sideStories, spinOffs, alternativeSettings, alternativeVersions, doujinshis)); + this.seriesService.updateRelationships(this.series.id, adaptations, characters, contains, others, prequels, sequels, sideStories, spinOffs, alternativeSettings, alternativeVersions, doujinshis).subscribe(() => {}); + + } + +} diff --git a/UI/Web/src/app/cards/series-card/series-card.component.html b/UI/Web/src/app/cards/series-card/series-card.component.html index dc741e946..385e46b27 100644 --- a/UI/Web/src/app/cards/series-card/series-card.component.html +++ b/UI/Web/src/app/cards/series-card/series-card.component.html @@ -2,5 +2,6 @@ \ No newline at end of file diff --git a/UI/Web/src/app/cards/series-card/series-card.component.ts b/UI/Web/src/app/cards/series-card/series-card.component.ts index adf0560d1..b202b89b7 100644 --- a/UI/Web/src/app/cards/series-card/series-card.component.ts +++ b/UI/Web/src/app/cards/series-card/series-card.component.ts @@ -13,6 +13,7 @@ import { ActionService } from 'src/app/_services/action.service'; import { EditSeriesModalComponent } from '../_modals/edit-series-modal/edit-series-modal.component'; import { MessageHubService } from 'src/app/_services/message-hub.service'; import { Subject } from 'rxjs'; +import { RelationKind } from 'src/app/_models/series-detail/relation-kind'; @Component({ selector: 'app-series-card', @@ -26,11 +27,15 @@ export class SeriesCardComponent implements OnInit, OnChanges, OnDestroy { /** * If the entity is selected or not. */ - @Input() selected: boolean = false; - /** - * If the entity should show selection code - */ - @Input() allowSelection: boolean = false; + @Input() selected: boolean = false; + /** + * If the entity should show selection code + */ + @Input() allowSelection: boolean = false; + /** + * If the Series has a relationship to display + */ + @Input() relation: RelationKind | undefined = undefined; @Output() clicked = new EventEmitter(); @Output() reload = new EventEmitter(); diff --git a/UI/Web/src/app/nav-header/nav-header.component.html b/UI/Web/src/app/nav-header/nav-header.component.html index d61e6bc60..2c2c6c69d 100644 --- a/UI/Web/src/app/nav-header/nav-header.component.html +++ b/UI/Web/src/app/nav-header/nav-header.component.html @@ -9,97 +9,97 @@
    + #search + id="nav-search" + [minQueryLength]="2" + initialValue="" + placeholder="Search…" + [grouppedData]="searchResults" + (inputChanged)="onChangeSearch($event)" + (clearField)="clearSearch()" + (focusChanged)="focusUpdate($event)" + > - -
    -
    - {{item.name}} + +
    +
    + {{item.name}} +
    -
    - + - -
    -
    - + +
    +
    + +
    +
    + + {{item.name}} + + + +
    in {{item.libraryName}}
    +
    -
    - - {{item.name}} - - - -
    in {{item.libraryName}}
    -
    -
    - + - -
    -
    - + +
    +
    + +
    +
    + {{item.title}} + +   + (promoted) + +
    -
    - {{item.title}} - -   - (promoted) - -
    -
    - + - -
    -
    - {{item.title}} - -   - (promoted) - + +
    +
    + {{item.title}} + +   + (promoted) + +
    -
    - + - -
    -
    - {{item.title}} + +
    +
    + {{item.title}} +
    -
    - + - -
    -
    - -
    -
    {{item.role | personRole}}
    + +
    +
    + +
    +
    {{item.role | personRole}}
    +
    -
    - + - -
    -
    -
    + +
    +
    +
    +
    -
    - + - - No results found - + + No results found +
    diff --git a/UI/Web/src/app/nav-header/nav-header.component.ts b/UI/Web/src/app/nav-header/nav-header.component.ts index ae73a2c77..70f8d5eca 100644 --- a/UI/Web/src/app/nav-header/nav-header.component.ts +++ b/UI/Web/src/app/nav-header/nav-header.component.ts @@ -1,10 +1,10 @@ import { DOCUMENT } from '@angular/common'; import { Component, HostListener, Inject, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { Router } from '@angular/router'; -import { from, fromEvent, Subject } from 'rxjs'; +import { fromEvent, Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; import { ScrollService } from '../scroll.service'; -import { FilterQueryParam, FilterUtilitiesService } from '../shared/_services/filter-utilities.service'; +import { FilterQueryParam } from '../shared/_services/filter-utilities.service'; import { CollectionTag } from '../_models/collection-tag'; import { Library } from '../_models/library'; import { PersonRole } from '../_models/person'; @@ -46,19 +46,20 @@ export class NavHeaderComponent implements OnInit, OnDestroy { searchFocused: boolean = false; private readonly onDestroy = new Subject(); - constructor(public accountService: AccountService, private router: Router, public navService: NavService, - private libraryService: LibraryService, public imageService: ImageService, @Inject(DOCUMENT) private document: Document, - private scrollService: ScrollService, private filterUtilityService: FilterUtilitiesService) { } + constructor(public accountService: AccountService, private router: Router, public navService: NavService, + private libraryService: LibraryService, public imageService: ImageService, @Inject(DOCUMENT) private document: Document, + private scrollService: ScrollService) { } - ngOnInit(): void { - fromEvent(this.document.body, 'scroll').pipe(takeUntil(this.onDestroy)).subscribe(() => { - const offset = this.scrollService.scrollPosition; - if (offset > 100) { - this.backToTopNeeded = true; - } else if (offset < 40) { - this.backToTopNeeded = false; - } - }) + ngOnInit(): void {} + + @HostListener('body:scroll', []) + checkBackToTopNeeded() { + const offset = this.scrollService.scrollPosition; + if (offset > 100) { + this.backToTopNeeded = true; + } else if (offset < 40) { + this.backToTopNeeded = false; + } } ngOnDestroy() { @@ -77,7 +78,7 @@ export class NavHeaderComponent implements OnInit, OnDestroy { this.document.getElementById('content')?.focus(); } - + onChangeSearch(val: string) { this.isLoading = true; diff --git a/UI/Web/src/app/pipe/pipe.module.ts b/UI/Web/src/app/pipe/pipe.module.ts index a010428f6..7e2a29659 100644 --- a/UI/Web/src/app/pipe/pipe.module.ts +++ b/UI/Web/src/app/pipe/pipe.module.ts @@ -5,6 +5,7 @@ import { PublicationStatusPipe } from './publication-status.pipe'; import { SentenceCasePipe } from './sentence-case.pipe'; import { PersonRolePipe } from './person-role.pipe'; import { SafeHtmlPipe } from './safe-html.pipe'; +import { RelationshipPipe } from './relationship.pipe'; @@ -14,7 +15,8 @@ import { SafeHtmlPipe } from './safe-html.pipe'; PersonRolePipe, PublicationStatusPipe, SentenceCasePipe, - SafeHtmlPipe + SafeHtmlPipe, + RelationshipPipe ], imports: [ CommonModule, @@ -24,7 +26,8 @@ import { SafeHtmlPipe } from './safe-html.pipe'; PersonRolePipe, PublicationStatusPipe, SentenceCasePipe, - SafeHtmlPipe + SafeHtmlPipe, + RelationshipPipe ] }) export class PipeModule { } diff --git a/UI/Web/src/app/pipe/relationship.pipe.spec.ts b/UI/Web/src/app/pipe/relationship.pipe.spec.ts new file mode 100644 index 000000000..4a527479d --- /dev/null +++ b/UI/Web/src/app/pipe/relationship.pipe.spec.ts @@ -0,0 +1,8 @@ +import { RelationshipPipe } from './relationship.pipe'; + +describe('RelationshipPipe', () => { + it('create an instance', () => { + const pipe = new RelationshipPipe(); + expect(pipe).toBeTruthy(); + }); +}); diff --git a/UI/Web/src/app/pipe/relationship.pipe.ts b/UI/Web/src/app/pipe/relationship.pipe.ts new file mode 100644 index 000000000..0aa84782b --- /dev/null +++ b/UI/Web/src/app/pipe/relationship.pipe.ts @@ -0,0 +1,41 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import { RelationKind } from '../_models/series-detail/relation-kind'; + +@Pipe({ + name: 'relationship' +}) +export class RelationshipPipe implements PipeTransform { + + transform(relationship: RelationKind | undefined): string { + if (relationship === undefined) return ''; + switch (relationship) { + case RelationKind.Adaptation: + return 'Adaptaion'; + case RelationKind.AlternativeSetting: + return 'Alternative Setting'; + case RelationKind.AlternativeVersion: + return 'Alternative Version'; + case RelationKind.Character: + return 'Character'; + case RelationKind.Contains: + return 'Contains'; + case RelationKind.Doujinshi: + return 'Doujinshi'; + case RelationKind.Other: + return 'Other'; + case RelationKind.Prequel: + return 'Prequel'; + case RelationKind.Sequel: + return 'Sequel'; + case RelationKind.SideStory: + return 'Side Story'; + case RelationKind.SpinOff: + return 'Spin Off'; + case RelationKind.Parent: + return 'Parent'; + default: + return ''; + } + } + +} diff --git a/UI/Web/src/app/series-detail/series-detail.component.html b/UI/Web/src/app/series-detail/series-detail.component.html index 2c18e1598..ba95cfdc9 100644 --- a/UI/Web/src/app/series-detail/series-detail.component.html +++ b/UI/Web/src/app/series-detail/series-detail.component.html @@ -69,6 +69,17 @@