diff --git a/.gitignore b/.gitignore index c8d68977f..eec036cbe 100644 --- a/.gitignore +++ b/.gitignore @@ -517,10 +517,12 @@ UI/Web/dist/ /API/config/kavita.db-shm /API/config/kavita.db-wal /API/config/kavita.db-journal +/API/config/*.db +/API/config/*.bak +/API/config/*.backup /API/config/Hangfire.db /API/config/Hangfire-log.db API/config/covers/ -API/config/*.db API/config/stats/* API/config/stats/app_stats.json API/config/pre-metadata/ diff --git a/API.Tests/Extensions/VolumeListExtensionsTests.cs b/API.Tests/Extensions/VolumeListExtensionsTests.cs index 48d39aa24..264437ecd 100644 --- a/API.Tests/Extensions/VolumeListExtensionsTests.cs +++ b/API.Tests/Extensions/VolumeListExtensionsTests.cs @@ -9,67 +9,6 @@ namespace API.Tests.Extensions; public class VolumeListExtensionsTests { - #region FirstWithChapters - - [Fact] - public void FirstWithChapters_ReturnsVolumeWithChapters() - { - var volumes = new List() - { - EntityFactory.CreateVolume("0", new List()), - EntityFactory.CreateVolume("1", new List() - { - EntityFactory.CreateChapter("1", false), - EntityFactory.CreateChapter("2", false), - }), - }; - - Assert.Equal(volumes[1].Number, volumes.FirstWithChapters(false).Number); - Assert.Equal(volumes[1].Number, volumes.FirstWithChapters(true).Number); - } - - [Fact] - public void FirstWithChapters_Book() - { - var volumes = new List() - { - EntityFactory.CreateVolume("1", new List() - { - EntityFactory.CreateChapter("3", false), - EntityFactory.CreateChapter("4", false), - }), - EntityFactory.CreateVolume("0", new List() - { - EntityFactory.CreateChapter("1", false), - EntityFactory.CreateChapter("0", true), - }), - }; - - Assert.Equal(volumes[0].Number, volumes.FirstWithChapters(true).Number); - } - - [Fact] - public void FirstWithChapters_NonBook() - { - var volumes = new List() - { - EntityFactory.CreateVolume("1", new List() - { - EntityFactory.CreateChapter("3", false), - EntityFactory.CreateChapter("4", false), - }), - EntityFactory.CreateVolume("0", new List() - { - EntityFactory.CreateChapter("1", false), - EntityFactory.CreateChapter("0", true), - }), - }; - - Assert.Equal(volumes[0].Number, volumes.FirstWithChapters(false).Number); - } - - #endregion - #region GetCoverImage [Fact] diff --git a/API.Tests/Services/DirectoryServiceTests.cs b/API.Tests/Services/DirectoryServiceTests.cs index 9a4bef08e..23a7dfad1 100644 --- a/API.Tests/Services/DirectoryServiceTests.cs +++ b/API.Tests/Services/DirectoryServiceTests.cs @@ -17,6 +17,7 @@ namespace API.Tests.Services { private readonly ILogger _logger = Substitute.For>(); + #region TraverseTreeParallelForEach [Fact] public void TraverseTreeParallelForEach_JustArchives_ShouldBe28() @@ -575,19 +576,22 @@ namespace API.Tests.Services [Fact] public void CopyFilesToDirectory_ShouldAppendWhenTargetFileExists() { + const string testDirectory = "/manga/"; var fileSystem = new MockFileSystem(); - fileSystem.AddFile($"{testDirectory}file.zip", new MockFileData("")); - fileSystem.AddFile($"/manga/output/file (1).zip", new MockFileData("")); - fileSystem.AddFile($"/manga/output/file (2).zip", new MockFileData("")); + fileSystem.AddFile(MockUnixSupport.Path($"{testDirectory}file.zip"), new MockFileData("")); + fileSystem.AddFile(MockUnixSupport.Path($"/manga/output/file (1).zip"), new MockFileData("")); + fileSystem.AddFile(MockUnixSupport.Path($"/manga/output/file (2).zip"), new MockFileData("")); var ds = new DirectoryService(Substitute.For>(), fileSystem); - ds.CopyFilesToDirectory(new []{$"{testDirectory}file.zip"}, "/manga/output/"); - ds.CopyFilesToDirectory(new []{$"{testDirectory}file.zip"}, "/manga/output/"); + ds.CopyFilesToDirectory(new []{MockUnixSupport.Path($"{testDirectory}file.zip")}, "/manga/output/"); + ds.CopyFilesToDirectory(new []{MockUnixSupport.Path($"{testDirectory}file.zip")}, "/manga/output/"); var outputFiles = ds.GetFiles("/manga/output/").Select(API.Parser.Parser.NormalizePath).ToList(); Assert.Equal(4, outputFiles.Count()); // we have 2 already there and 2 copies - // For some reason, this has C:/ on directory even though everything is emulated - Assert.True(outputFiles.Contains(API.Parser.Parser.NormalizePath("/manga/output/file (3).zip")) || outputFiles.Contains(API.Parser.Parser.NormalizePath("C:/manga/output/file (3).zip"))); + // For some reason, this has C:/ on directory even though everything is emulated (System.IO.Abstractions issue, not changing) + // https://github.com/TestableIO/System.IO.Abstractions/issues/831 + Assert.True(outputFiles.Contains(API.Parser.Parser.NormalizePath("/manga/output/file (3).zip")) + || outputFiles.Contains(API.Parser.Parser.NormalizePath("C:/manga/output/file (3).zip"))); } #endregion diff --git a/API.Tests/Services/SeriesServiceTests.cs b/API.Tests/Services/SeriesServiceTests.cs index 1b9f5fd3f..0726931ea 100644 --- a/API.Tests/Services/SeriesServiceTests.cs +++ b/API.Tests/Services/SeriesServiceTests.cs @@ -22,6 +22,7 @@ using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; +using Xunit.Sdk; namespace API.Tests.Services; @@ -703,6 +704,85 @@ public class SeriesServiceTests Assert.False(series.Metadata.GenresLocked); // GenreLocked is false unless the UI Explicitly says it should be locked } + [Fact] + public async Task UpdateSeriesMetadata_ShouldAddNewPerson_NoExistingPeople() + { + await ResetDb(); + var s = new Series() + { + Name = "Test", + Library = new Library() + { + Name = "Test LIb", + Type = LibraryType.Book, + }, + Metadata = DbFactory.SeriesMetadata(new List()) + }; + var g = DbFactory.Person("Existing Person", PersonRole.Publisher); + _context.Series.Add(s); + + _context.Person.Add(g); + await _context.SaveChangesAsync(); + + var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto() + { + SeriesMetadata = new SeriesMetadataDto() + { + SeriesId = 1, + Publishers = new List() {new () {Id = 0, Name = "Existing Person", Role = PersonRole.Publisher}}, + }, + CollectionTags = new List() + }); + + Assert.True(success); + + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + Assert.NotNull(series.Metadata); + Assert.True(series.Metadata.People.Select(g => g.Name).All(g => g == "Existing Person")); + Assert.False(series.Metadata.PublisherLocked); // PublisherLocked is false unless the UI Explicitly says it should be locked + } + + [Fact] + public async Task UpdateSeriesMetadata_ShouldAddNewPerson_ExistingPeople() + { + await ResetDb(); + var s = new Series() + { + Name = "Test", + Library = new Library() + { + Name = "Test LIb", + Type = LibraryType.Book, + }, + Metadata = DbFactory.SeriesMetadata(new List()) + }; + var g = DbFactory.Person("Existing Person", PersonRole.Publisher); + s.Metadata.People = new List() {DbFactory.Person("Existing Writer", PersonRole.Writer), + DbFactory.Person("Existing Translator", PersonRole.Translator), DbFactory.Person("Existing Publisher 2", PersonRole.Publisher)}; + _context.Series.Add(s); + + _context.Person.Add(g); + await _context.SaveChangesAsync(); + + var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto() + { + SeriesMetadata = new SeriesMetadataDto() + { + SeriesId = 1, + Publishers = new List() {new () {Id = 0, Name = "Existing Person", Role = PersonRole.Publisher}}, + PublisherLocked = true + }, + CollectionTags = new List() + }); + + Assert.True(success); + + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + Assert.NotNull(series.Metadata); + Assert.True(series.Metadata.People.Select(g => g.Name).All(g => g == "Existing Person")); + Assert.True(series.Metadata.PublisherLocked); + } + [Fact] public async Task UpdateSeriesMetadata_ShouldLockIfTold() { @@ -745,4 +825,86 @@ public class SeriesServiceTests } #endregion + + #region GetFirstChapterForMetadata + + private static Series CreateSeriesMock() + { + var files = new List() + { + EntityFactory.CreateMangaFile("Test.cbz", MangaFormat.Archive, 1) + }; + return new Series() + { + Name = "Test", + Library = new Library() + { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("95", false, files, 1), + EntityFactory.CreateChapter("96", false, files, 1), + EntityFactory.CreateChapter("A Special Case", true, files, 1), + }), + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("1", false, files, 1), + EntityFactory.CreateChapter("2", false, files, 1), + }), + EntityFactory.CreateVolume("2", new List() + { + EntityFactory.CreateChapter("21", false, files, 1), + EntityFactory.CreateChapter("22", false, files, 1), + }), + EntityFactory.CreateVolume("3", new List() + { + EntityFactory.CreateChapter("31", false, files, 1), + EntityFactory.CreateChapter("32", false, files, 1), + }), + } + }; + } + + [Fact] + public void GetFirstChapterForMetadata_Book_Test() + { + var series = CreateSeriesMock(); + + var firstChapter = SeriesService.GetFirstChapterForMetadata(series, true); + Assert.Same("1", firstChapter.Range); + } + + [Fact] + public void GetFirstChapterForMetadata_NonBook_ShouldReturnVolume1() + { + var series = CreateSeriesMock(); + + var firstChapter = SeriesService.GetFirstChapterForMetadata(series, false); + Assert.Same("1", firstChapter.Range); + } + + [Fact] + public void GetFirstChapterForMetadata_NonBook_ShouldReturnVolume1_WhenFirstChapterIsFloat() + { + var series = CreateSeriesMock(); + var files = new List() + { + EntityFactory.CreateMangaFile("Test.cbz", MangaFormat.Archive, 1) + }; + series.Volumes[1].Chapters = new List() + { + EntityFactory.CreateChapter("2", false, files, 1), + EntityFactory.CreateChapter("1.1", false, files, 1), + EntityFactory.CreateChapter("1.2", false, files, 1), + }; + + var firstChapter = SeriesService.GetFirstChapterForMetadata(series, false); + Assert.Same("1.1", firstChapter.Range); + } + + #endregion } diff --git a/API/Controllers/CollectionController.cs b/API/Controllers/CollectionController.cs index 6abc22955..3abe97d47 100644 --- a/API/Controllers/CollectionController.cs +++ b/API/Controllers/CollectionController.cs @@ -157,7 +157,7 @@ namespace API.Controllers tag.CoverImageLocked = false; tag.CoverImage = string.Empty; await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, - MessageFactory.CoverUpdateEvent(tag.Id, "collectionTag"), false); + MessageFactory.CoverUpdateEvent(tag.Id, "collection"), false); _unitOfWork.CollectionTagRepository.Update(tag); } diff --git a/API/Controllers/ImageController.cs b/API/Controllers/ImageController.cs index 8b58fe9b3..2393d0ea6 100644 --- a/API/Controllers/ImageController.cs +++ b/API/Controllers/ImageController.cs @@ -88,6 +88,22 @@ namespace API.Controllers return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path)); } + /// + /// Returns cover image for a Reading List + /// + /// + /// + [HttpGet("readinglist-cover")] + public async Task GetReadingListCoverImage(int readingListId) + { + var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.ReadingListRepository.GetCoverImageAsync(readingListId)); + if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"No cover image"); + var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", ""); + + Response.AddCacheHeader(path); + return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path)); + } + /// /// Returns image for a given bookmark page /// diff --git a/API/Controllers/ReadingListController.cs b/API/Controllers/ReadingListController.cs index 83cdc1a04..701f415e6 100644 --- a/API/Controllers/ReadingListController.cs +++ b/API/Controllers/ReadingListController.cs @@ -7,6 +7,7 @@ using API.DTOs.ReadingLists; using API.Entities; using API.Extensions; using API.Helpers; +using API.SignalR; using Microsoft.AspNetCore.Mvc; namespace API.Controllers @@ -14,11 +15,13 @@ namespace API.Controllers public class ReadingListController : BaseApiController { private readonly IUnitOfWork _unitOfWork; + private readonly IEventHub _eventHub; private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst(); - public ReadingListController(IUnitOfWork unitOfWork) + public ReadingListController(IUnitOfWork unitOfWork, IEventHub eventHub) { _unitOfWork = unitOfWork; + _eventHub = eventHub; } /// @@ -233,9 +236,12 @@ namespace API.Controllers var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(dto.ReadingListId); if (readingList == null) return BadRequest("List does not exist"); + + if (!string.IsNullOrEmpty(dto.Title)) { readingList.Title = dto.Title; // Should I check if this is unique? + readingList.NormalizedTitle = Parser.Parser.Normalize(readingList.Title); } if (!string.IsNullOrEmpty(dto.Title)) { @@ -244,6 +250,19 @@ namespace API.Controllers readingList.Promoted = dto.Promoted; + readingList.CoverImageLocked = dto.CoverImageLocked; + + if (!dto.CoverImageLocked) + { + readingList.CoverImageLocked = false; + readingList.CoverImage = string.Empty; + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, + MessageFactory.CoverUpdateEvent(readingList.Id, "readingList"), false); + _unitOfWork.ReadingListRepository.Update(readingList); + } + + + _unitOfWork.ReadingListRepository.Update(readingList); if (await _unitOfWork.CommitAsync()) diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index 3aad34d99..ee1fcd584 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -214,13 +214,6 @@ namespace API.Controllers return Ok(await _unitOfWork.SeriesRepository.GetRecentlyUpdatedSeries(userId)); } - [HttpPost("recently-added-chapters")] - public async Task>> GetRecentlyAddedChaptersAlt() - { - var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - return Ok(await _unitOfWork.SeriesRepository.GetRecentlyAddedChapters(userId)); - } - [HttpPost("all")] public async Task>> GetAllSeries(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0) { diff --git a/API/Controllers/UploadController.cs b/API/Controllers/UploadController.cs index 4d07d4225..2ecc0dc38 100644 --- a/API/Controllers/UploadController.cs +++ b/API/Controllers/UploadController.cs @@ -5,6 +5,7 @@ using API.Data; using API.DTOs.Uploads; using API.Extensions; using API.Services; +using API.SignalR; using Flurl.Http; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -24,16 +25,18 @@ namespace API.Controllers private readonly ILogger _logger; private readonly ITaskScheduler _taskScheduler; private readonly IDirectoryService _directoryService; + private readonly IEventHub _eventHub; /// public UploadController(IUnitOfWork unitOfWork, IImageService imageService, ILogger logger, - ITaskScheduler taskScheduler, IDirectoryService directoryService) + ITaskScheduler taskScheduler, IDirectoryService directoryService, IEventHub eventHub) { _unitOfWork = unitOfWork; _imageService = imageService; _logger = logger; _taskScheduler = taskScheduler; _directoryService = directoryService; + _eventHub = eventHub; } /// @@ -145,6 +148,8 @@ namespace API.Controllers if (_unitOfWork.HasChanges()) { await _unitOfWork.CommitAsync(); + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, + MessageFactory.CoverUpdateEvent(tag.Id, "collection"), false); return Ok(); } @@ -158,6 +163,53 @@ namespace API.Controllers return BadRequest("Unable to save cover image to Collection Tag"); } + /// + /// Replaces reading list cover image and locks it with a base64 encoded image + /// + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [RequestSizeLimit(8_000_000)] + [HttpPost("reading-list")] + public async Task UploadReadingListCoverImageFromUrl(UploadFileDto uploadFileDto) + { + // Check if Url is non empty, request the image and place in temp, then ask image service to handle it. + // See if we can do this all in memory without touching underlying system + if (string.IsNullOrEmpty(uploadFileDto.Url)) + { + return BadRequest("You must pass a url to use"); + } + + try + { + var filePath = _imageService.CreateThumbnailFromBase64(uploadFileDto.Url, $"{ImageService.GetReadingListFormat(uploadFileDto.Id)}"); + var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(uploadFileDto.Id); + + if (!string.IsNullOrEmpty(filePath)) + { + readingList.CoverImage = filePath; + readingList.CoverImageLocked = true; + _unitOfWork.ReadingListRepository.Update(readingList); + } + + if (_unitOfWork.HasChanges()) + { + await _unitOfWork.CommitAsync(); + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, + MessageFactory.CoverUpdateEvent(readingList.Id, "readingList"), false); + return Ok(); + } + + } + catch (Exception e) + { + _logger.LogError(e, "There was an issue uploading cover image for Reading List {Id}", uploadFileDto.Id); + await _unitOfWork.RollbackAsync(); + } + + return BadRequest("Unable to save cover image to Reading List"); + } + /// /// Replaces chapter cover image and locks it with a base64 encoded image. This will update the parent volume's cover image. /// diff --git a/API/DTOs/ReadingLists/ReadingListDto.cs b/API/DTOs/ReadingLists/ReadingListDto.cs index e3837a2e3..3eb5ded79 100644 --- a/API/DTOs/ReadingLists/ReadingListDto.cs +++ b/API/DTOs/ReadingLists/ReadingListDto.cs @@ -9,5 +9,6 @@ /// Reading lists that are promoted are only done by admins /// public bool Promoted { get; set; } + public bool CoverImageLocked { get; set; } } } diff --git a/API/DTOs/ReadingLists/UpdateReadingListDto.cs b/API/DTOs/ReadingLists/UpdateReadingListDto.cs index a9f6f0d59..5b8f69731 100644 --- a/API/DTOs/ReadingLists/UpdateReadingListDto.cs +++ b/API/DTOs/ReadingLists/UpdateReadingListDto.cs @@ -6,5 +6,6 @@ public string Title { get; set; } public string Summary { get; set; } public bool Promoted { get; set; } + public bool CoverImageLocked { get; set; } } } diff --git a/API/Data/Migrations/20220410230540_SeriesLastChapterAddedAndReadingListNormalization.Designer.cs b/API/Data/Migrations/20220410230540_SeriesLastChapterAddedAndReadingListNormalization.Designer.cs new file mode 100644 index 000000000..27d16bfde --- /dev/null +++ b/API/Data/Migrations/20220410230540_SeriesLastChapterAddedAndReadingListNormalization.Designer.cs @@ -0,0 +1,1469 @@ +// +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("20220410230540_SeriesLastChapterAddedAndReadingListNormalization")] + partial class SeriesLastChapterAddedAndReadingListNormalization + { + 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.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.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId", "Format") + .IsUnique(); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20220410230540_SeriesLastChapterAddedAndReadingListNormalization.cs b/API/Data/Migrations/20220410230540_SeriesLastChapterAddedAndReadingListNormalization.cs new file mode 100644 index 000000000..445895472 --- /dev/null +++ b/API/Data/Migrations/20220410230540_SeriesLastChapterAddedAndReadingListNormalization.cs @@ -0,0 +1,58 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class SeriesLastChapterAddedAndReadingListNormalization : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "LastChapterAdded", + table: "Series", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "CoverImage", + table: "ReadingList", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "CoverImageLocked", + table: "ReadingList", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "NormalizedTitle", + table: "ReadingList", + type: "TEXT", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "LastChapterAdded", + table: "Series"); + + migrationBuilder.DropColumn( + name: "CoverImage", + table: "ReadingList"); + + migrationBuilder.DropColumn( + name: "CoverImageLocked", + table: "ReadingList"); + + migrationBuilder.DropColumn( + name: "NormalizedTitle", + table: "ReadingList"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index d46e91af6..c444976be 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.2"); + modelBuilder.HasAnnotation("ProductVersion", "6.0.3"); modelBuilder.Entity("API.Entities.AppRole", b => { @@ -621,12 +621,21 @@ namespace API.Data.Migrations 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"); @@ -695,6 +704,9 @@ namespace API.Data.Migrations b.Property("Format") .HasColumnType("INTEGER"); + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + b.Property("LastModified") .HasColumnType("TEXT"); diff --git a/API/Data/Repositories/ReadingListRepository.cs b/API/Data/Repositories/ReadingListRepository.cs index 59c6ac5c2..007bc3884 100644 --- a/API/Data/Repositories/ReadingListRepository.cs +++ b/API/Data/Repositories/ReadingListRepository.cs @@ -26,6 +26,7 @@ public interface IReadingListRepository void BulkRemove(IEnumerable items); void Update(ReadingList list); Task Count(); + Task GetCoverImageAsync(int readingListId); } public class ReadingListRepository : IReadingListRepository @@ -49,6 +50,15 @@ public class ReadingListRepository : IReadingListRepository return await _context.ReadingList.CountAsync(); } + public async Task GetCoverImageAsync(int readingListId) + { + return await _context.ReadingList + .Where(c => c.Id == readingListId) + .Select(c => c.CoverImage) + .AsNoTracking() + .SingleOrDefaultAsync(); + } + public void Remove(ReadingListItem item) { _context.ReadingListItem.Remove(item); diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index bf634e5c7..b3e89495d 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -95,8 +95,7 @@ public interface ISeriesRepository Task> GetAllAgeRatingsDtosForLibrariesAsync(List libraryIds); Task> GetAllLanguagesForLibrariesAsync(List libraryIds); Task> GetAllPublicationStatusesDtosForLibrariesAsync(List libraryIds); - Task> GetRecentlyUpdatedSeries(int userId); - Task> GetRecentlyAddedChapters(int userId); + Task> GetRecentlyUpdatedSeries(int userId); } public class SeriesRepository : ISeriesRepository @@ -607,7 +606,6 @@ public class SeriesRepository : ISeriesRepository /// public async Task> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter, bool cutoffOnDate = true) { - var cutoffProgressPoint = DateTime.Now - TimeSpan.FromDays(30); var query = (await CreateFilteredSearchQueryable(userId, libraryId, filter)) .Join(_context.AppUserProgresses, s => s.Id, progress => progress.SeriesId, (s, progress) => new @@ -619,24 +617,19 @@ public class SeriesRepository : ISeriesRepository LastReadingProgress = _context.AppUserProgresses .Where(p => p.Id == progress.Id && p.AppUserId == userId) .Max(p => p.LastModified), - // This is only taking into account chapters that have progress on them, not all chapters in said series - //LastChapterCreated = _context.Chapter.Where(c => progress.ChapterId == c.Id).Max(c => c.Created), - LastChapterCreated = s.Volumes.SelectMany(v => v.Chapters).Max(c => c.Created) + s.LastChapterAdded }); if (cutoffOnDate) { - query = query.Where(d => d.LastReadingProgress >= cutoffProgressPoint || d.LastChapterCreated >= cutoffProgressPoint); + var cutoffProgressPoint = DateTime.Now - TimeSpan.FromDays(30); + query = query.Where(d => d.LastReadingProgress >= cutoffProgressPoint || d.LastChapterAdded >= cutoffProgressPoint); } - // I think I need another Join statement. The problem is the chapters are still limited to progress - - - var retSeries = query.Where(s => s.AppUserId == userId && s.PagesRead > 0 && s.PagesRead < s.Series.Pages) .OrderByDescending(s => s.LastReadingProgress) - .ThenByDescending(s => s.LastChapterCreated) + .ThenByDescending(s => s.LastChapterAdded) .Select(s => s.Series) .ProjectTo(_mapper.ConfigurationProvider) .AsSplitQuery() @@ -903,116 +896,79 @@ public class SeriesRepository : ISeriesRepository .ToListAsync(); } - private static string RecentlyAddedItemTitle(RecentlyAddedSeries item) - { - switch (item.LibraryType) - { - case LibraryType.Book: - return string.Empty; - case LibraryType.Comic: - return "Issue"; - case LibraryType.Manga: - default: - return "Chapter"; - } - } - - /// - /// Show all recently added chapters. Provide some mapping for chapter 0 -> Volume 1 - /// - /// - /// - public async Task> GetRecentlyAddedChapters(int userId) - { - var ret = await GetRecentlyAddedChaptersQuery(userId); - - var items = new List(); - foreach (var item in ret) - { - var dto = new RecentlyAddedItemDto() - { - LibraryId = item.LibraryId, - LibraryType = item.LibraryType, - SeriesId = item.SeriesId, - SeriesName = item.SeriesName, - Created = item.Created, - Id = items.Count, - Format = item.Format - }; - - // Add title and Volume/Chapter Id - var chapterTitle = RecentlyAddedItemTitle(item); - string title; - if (item.ChapterNumber.Equals(Parser.Parser.DefaultChapter)) - { - if ((item.VolumeNumber + string.Empty).Equals(Parser.Parser.DefaultChapter)) - { - title = item.ChapterTitle; - } - else - { - title = "Volume " + item.VolumeNumber; - } - - dto.VolumeId = item.VolumeId; - } - else - { - title = item.IsSpecial - ? item.ChapterRange - : $"{chapterTitle} {item.ChapterRange}"; - dto.ChapterId = item.ChapterId; - } - - dto.Title = title; - items.Add(dto); - } - - - return items; - - } - /// /// Return recently updated series, regardless of read progress, and group the number of volume or chapters added. /// /// Used to ensure user has access to libraries /// - public async Task> GetRecentlyUpdatedSeries(int userId) + public async Task> GetRecentlyUpdatedSeries(int userId) { - var ret = await GetRecentlyAddedChaptersQuery(userId, 150); + var ret = await GetRecentlyAddedChaptersQuery(userId, 150); + + var seriesMap = new Dictionary(); + var index = 0; + foreach (var item in ret) + { + if (seriesMap.ContainsKey(item.SeriesName)) + { + seriesMap[item.SeriesName].Count += 1; + } + else + { + seriesMap[item.SeriesName] = new GroupedSeriesDto() + { + LibraryId = item.LibraryId, + LibraryType = item.LibraryType, + SeriesId = item.SeriesId, + SeriesName = item.SeriesName, + Created = item.Created, + Id = index, + Format = item.Format, + Count = 1, + }; + index += 1; + } + } + + return seriesMap.Values.AsEnumerable(); + + //return seriesMap.Values.ToList(); + + // var libraries = await _context.AppUser + // .Where(u => u.Id == userId) + // .SelectMany(u => u.Libraries.Select(l => new {LibraryId = l.Id, LibraryType = l.Type})) + // .ToListAsync(); + // var libraryIds = libraries.Select(l => l.LibraryId).ToList(); + // + // var cuttoffDate = DateTime.Now - TimeSpan.FromDays(12); + // + // var ret2 = _context.Series + // .Where(s => s.LastChapterAdded >= cuttoffDate + // && libraryIds.Contains(s.LibraryId)) + // .Select((s) => new GroupedSeriesDto + // { + // LibraryId = s.LibraryId, + // LibraryType = s.Library.Type, + // SeriesId = s.Id, + // SeriesName = s.Name, + // //Created = s.LastChapterAdded, // Hmm on first migration this wont work + // Created = s.Volumes.SelectMany(v => v.Chapters).Max(c => c.Created), // Hmm on first migration this wont work + // Count = s.Volumes.SelectMany(v => v.Chapters).Count(c => c.Created >= cuttoffDate), + // //Id = index, + // Format = s.Format + // }) + // .Take(50) + // .OrderByDescending(c => c.Created) + // .AsSplitQuery() + // .AsEnumerable(); + // + // return ret2; - var seriesMap = new Dictionary(); - var index = 0; - foreach (var item in ret) - { - if (seriesMap.ContainsKey(item.SeriesName)) - { - seriesMap[item.SeriesName].Count += 1; - } - else - { - seriesMap[item.SeriesName] = new GroupedSeriesDto() - { - LibraryId = item.LibraryId, - LibraryType = item.LibraryType, - SeriesId = item.SeriesId, - SeriesName = item.SeriesName, - Created = item.Created, - Id = index, - Format = item.Format, - Count = 1 - }; - index += 1; - } - } - - return seriesMap.Values.ToList(); } - private async Task> GetRecentlyAddedChaptersQuery(int userId, int maxRecords = 50) + private async Task> GetRecentlyAddedChaptersQuery(int userId, int maxRecords = 50) { var libraries = await _context.AppUser .Where(u => u.Id == userId) @@ -1021,7 +977,7 @@ public class SeriesRepository : ISeriesRepository var libraryIds = libraries.Select(l => l.LibraryId).ToList(); var withinLastWeek = DateTime.Now - TimeSpan.FromDays(12); - var ret = await _context.Chapter + var ret = _context.Chapter .Where(c => c.Created >= withinLastWeek) .AsNoTracking() .Include(c => c.Volume) @@ -1045,8 +1001,9 @@ public class SeriesRepository : ISeriesRepository ChapterTitle = c.Title }) .Take(maxRecords) + .AsSplitQuery() .Where(c => c.Created >= withinLastWeek && libraryIds.Contains(c.LibraryId)) - .ToListAsync(); + .AsEnumerable(); return ret; } } diff --git a/API/Entities/ReadingList.cs b/API/Entities/ReadingList.cs index ef0b4bd9c..b665203c4 100644 --- a/API/Entities/ReadingList.cs +++ b/API/Entities/ReadingList.cs @@ -11,11 +11,21 @@ namespace API.Entities { public int Id { get; init; } public string Title { get; set; } + /// + /// A normalized string used to check if the reading list already exists in the DB + /// + public string NormalizedTitle { get; set; } public string Summary { get; set; } /// /// Reading lists that are promoted are only done by admins /// public bool Promoted { get; set; } + /// + /// Absolute path to the (managed) image file + /// + /// The file is managed internally to Kavita's APPDIR + public string CoverImage { get; set; } + public bool CoverImageLocked { get; set; } public ICollection Items { get; set; } public DateTime Created { get; set; } diff --git a/API/Entities/Series.cs b/API/Entities/Series.cs index 12e169c07..a6ae420b5 100644 --- a/API/Entities/Series.cs +++ b/API/Entities/Series.cs @@ -62,6 +62,11 @@ namespace API.Entities public bool SortNameLocked { get; set; } public bool LocalizedNameLocked { get; set; } + /// + /// When a Chapter was last added onto the Series + /// + public DateTime LastChapterAdded { get; set; } + public SeriesMetadata Metadata { get; set; } public ICollection Ratings { get; set; } = new List(); public ICollection Progress { get; set; } = new List(); diff --git a/API/Extensions/VolumeListExtensions.cs b/API/Extensions/VolumeListExtensions.cs index 97126e28f..8933e04a5 100644 --- a/API/Extensions/VolumeListExtensions.cs +++ b/API/Extensions/VolumeListExtensions.cs @@ -8,14 +8,6 @@ namespace API.Extensions { public static class VolumeListExtensions { - public static Volume FirstWithChapters(this IEnumerable volumes, bool inBookSeries) - { - return inBookSeries - ? volumes.FirstOrDefault(v => v.Chapters.Any()) - : volumes.OrderBy(v => v.Number, new ChapterSortComparer()) - .FirstOrDefault(v => v.Chapters.Any()); - } - /// /// Selects the first Volume to get the cover image from. For a book with only a special, the special will be returned. /// If there are both specials and non-specials, then the first non-special will be returned. diff --git a/API/Helpers/PersonHelper.cs b/API/Helpers/PersonHelper.cs index 36d544d4d..92551b200 100644 --- a/API/Helpers/PersonHelper.cs +++ b/API/Helpers/PersonHelper.cs @@ -64,16 +64,14 @@ public static class PersonHelper /// /// /// - /// Callback for all entities that was removed - public static void KeepOnlySamePeopleBetweenLists(ICollection existingPeople, ICollection removeAllExcept, Action action = null) + /// Callback for all entities that should be removed + public static void KeepOnlySamePeopleBetweenLists(IEnumerable existingPeople, ICollection removeAllExcept, Action action = null) { - var existing = existingPeople.ToList(); - foreach (var person in existing) + foreach (var person in existingPeople) { var existingPerson = removeAllExcept.FirstOrDefault(p => p.Role == person.Role && person.NormalizedName.Equals(p.NormalizedName)); if (existingPerson == null) { - existingPeople.Remove(person); action?.Invoke(person); } } diff --git a/API/Services/ImageService.cs b/API/Services/ImageService.cs index cc852b5bb..b80d1e8f4 100644 --- a/API/Services/ImageService.cs +++ b/API/Services/ImageService.cs @@ -143,4 +143,16 @@ public class ImageService : IImageService { return $"tag{tagId}"; } + + /// + /// Returns the name format for a reading list cover image + /// + /// + /// + public static string GetReadingListFormat(int readingListId) + { + return $"readinglist{readingListId}"; + } + + } diff --git a/API/Services/ReadingItemService.cs b/API/Services/ReadingItemService.cs index d791efd55..a5130c747 100644 --- a/API/Services/ReadingItemService.cs +++ b/API/Services/ReadingItemService.cs @@ -33,7 +33,7 @@ public class ReadingItemService : IReadingItemService } /// - /// Gets the ComicInfo for the file if it exists. Null otherewise. + /// Gets the ComicInfo for the file if it exists. Null otherwise. /// /// Fully qualified path of file /// diff --git a/API/Services/SeriesService.cs b/API/Services/SeriesService.cs index 25f736e99..b1d9e1132 100644 --- a/API/Services/SeriesService.cs +++ b/API/Services/SeriesService.cs @@ -10,6 +10,7 @@ using API.DTOs.CollectionTags; using API.DTOs.Metadata; using API.Entities; using API.Entities.Enums; +using API.Extensions; using API.Helpers; using API.SignalR; using Microsoft.Extensions.Logging; @@ -41,6 +42,19 @@ public class SeriesService : ISeriesService _logger = logger; } + /// + /// Returns the first chapter for a series to extract metadata from (ie Summary, etc) + /// + /// + /// + /// + public static Chapter GetFirstChapterForMetadata(Series series, bool isBookLibrary) + { + return series.Volumes.OrderBy(v => v.Number, new ChapterSortComparer()) + .SelectMany(v => v.Chapters.OrderBy(c => float.Parse(c.Number), new ChapterSortComparer())) + .FirstOrDefault(); + } + public async Task UpdateSeriesMetadata(UpdateSeriesMetadataDto updateSeriesMetadataDto) { try @@ -154,6 +168,8 @@ public class SeriesService : ISeriesService if (!updateSeriesMetadataDto.SeriesMetadata.WriterLocked) series.Metadata.WriterLocked = false; if (!updateSeriesMetadataDto.SeriesMetadata.SummaryLocked) series.Metadata.SummaryLocked = false; + series.Metadata.PublisherLocked = updateSeriesMetadataDto.SeriesMetadata.PublisherLocked; + if (!_unitOfWork.HasChanges()) @@ -334,7 +350,7 @@ public class SeriesService : ISeriesService var existingTag = allTags.SingleOrDefault(t => t.Name == tag.Name && t.Role == tag.Role); if (existingTag != null) { - if (series.Metadata.People.All(t => t.Name != tag.Name && t.Role == tag.Role)) + if (series.Metadata.People.Where(t => t.Role == tag.Role).All(t => !t.Name.Equals(tag.Name))) { handleAdd(existingTag); isModified = true; diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index 85f01aa09..a917396b8 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -564,8 +564,7 @@ public class ScannerService : IScannerService private static void UpdateSeriesMetadata(Series series, ICollection allPeople, ICollection allGenres, ICollection allTags, LibraryType libraryType) { var isBook = libraryType == LibraryType.Book; - var firstVolume = series.Volumes.OrderBy(c => c.Number, new ChapterSortComparer()).FirstWithChapters(isBook); - var firstChapter = firstVolume?.Chapters.GetFirstChapterWithFiles(); + var firstChapter = SeriesService.GetFirstChapterForMetadata(series, isBook); var firstFile = firstChapter?.Files.FirstOrDefault(); if (firstFile == null) return; @@ -695,9 +694,50 @@ public class ScannerService : IScannerService } } + // BUG: The issue here is that people is just from chapter, but series metadata might already have some people on it + // I might be able to filter out people that are in locked fields? var people = chapters.SelectMany(c => c.People).ToList(); PersonHelper.KeepOnlySamePeopleBetweenLists(series.Metadata.People, - people, person => series.Metadata.People.Remove(person)); + people, person => + { + switch (person.Role) + { + case PersonRole.Writer: + if (!series.Metadata.WriterLocked) series.Metadata.People.Remove(person); + break; + case PersonRole.Penciller: + if (!series.Metadata.PencillerLocked) series.Metadata.People.Remove(person); + break; + case PersonRole.Inker: + if (!series.Metadata.InkerLocked) series.Metadata.People.Remove(person); + break; + case PersonRole.Colorist: + if (!series.Metadata.ColoristLocked) series.Metadata.People.Remove(person); + break; + case PersonRole.Letterer: + if (!series.Metadata.LettererLocked) series.Metadata.People.Remove(person); + break; + case PersonRole.CoverArtist: + if (!series.Metadata.CoverArtistLocked) series.Metadata.People.Remove(person); + break; + case PersonRole.Editor: + if (!series.Metadata.EditorLocked) series.Metadata.People.Remove(person); + break; + case PersonRole.Publisher: + if (!series.Metadata.PublisherLocked) series.Metadata.People.Remove(person); + break; + case PersonRole.Character: + if (!series.Metadata.CharacterLocked) series.Metadata.People.Remove(person); + break; + case PersonRole.Translator: + if (!series.Metadata.TranslatorLocked) series.Metadata.People.Remove(person); + break; + case PersonRole.Other: + default: + series.Metadata.People.Remove(person); + break; + } + }); } @@ -720,7 +760,7 @@ public class ScannerService : IScannerService _logger.LogDebug("[ScannerService] Parsing {SeriesName} - Volume {VolumeNumber}", series.Name, volume.Name); var infos = parsedInfos.Where(p => p.Volumes == volumeNumber).ToArray(); - UpdateChapters(volume, infos); + UpdateChapters(series, volume, infos); volume.Pages = volume.Chapters.Sum(c => c.Pages); // Update all the metadata on the Chapters @@ -767,7 +807,7 @@ public class ScannerService : IScannerService series.Name, startingVolumeCount, series.Volumes.Count); } - private void UpdateChapters(Volume volume, IList parsedInfos) + private void UpdateChapters(Series series, Volume volume, IList parsedInfos) { // Add new chapters foreach (var info in parsedInfos) @@ -789,30 +829,18 @@ public class ScannerService : IScannerService { _logger.LogDebug( "[ScannerService] Adding new chapter, {Series} - Vol {Volume} Ch {Chapter}", info.Series, info.Volumes, info.Chapters); - volume.Chapters.Add(DbFactory.Chapter(info)); + chapter = DbFactory.Chapter(info); + volume.Chapters.Add(chapter); + series.LastChapterAdded = DateTime.Now; } else { chapter.UpdateFrom(info); } - } - - // Add files - foreach (var info in parsedInfos) - { - var specialTreatment = info.IsSpecialInfo(); - Chapter chapter; - try - { - chapter = volume.Chapters.GetChapterByRange(info); - } - catch (Exception ex) - { - _logger.LogError(ex, "There was an exception parsing chapter. Skipping {SeriesName} Vol {VolumeNumber} Chapter {ChapterNumber} - Special treatment: {NeedsSpecialTreatment}", info.Series, volume.Name, info.Chapters, specialTreatment); - continue; - } if (chapter == null) continue; + // Add files + var specialTreatment = info.IsSpecialInfo(); AddOrUpdateFileForChapter(chapter, info); chapter.Number = Parser.Parser.MinimumNumberFromRange(info.Chapters) + string.Empty; chapter.Range = specialTreatment ? info.Filename : info.Chapters; diff --git a/API/config/kavita.db.dik b/API/config/kavita.db.dik deleted file mode 100644 index 4d71b8263..000000000 Binary files a/API/config/kavita.db.dik and /dev/null differ diff --git a/API/config/kavita.db.new b/API/config/kavita.db.new deleted file mode 100644 index 774d54743..000000000 Binary files a/API/config/kavita.db.new and /dev/null differ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5b0417947..7e1fae0be 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,4 +54,10 @@ Setup guides, FAQ, the more information we have on the [wiki](https://wiki.kavit - feature/parser-enhancements (Great) - bugfix/book-issues (Great) +### Swagger API ### +If you just want to play with Swagger, you can just +- cd Kavita/API +- dotnet run -c Debug +- Go to http://localhost:5000/swagger/index.html + If you have any questions about any of this, please let us know. diff --git a/README.md b/README.md index abc2ee070..8edb71a5a 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ your reading collection with your friends and family! ## Goals - [x] Serve up Manga/Webtoons/Comics (cbr, cbz, zip/rar, 7zip, raw images) and Books (epub, pdf) - [x] First class responsive readers that work great on any device (phone, tablet, desktop) -- [x] Dark and Light themes +- [x] Dark and Light themes (and customizable themes) - [ ] Provide hooks into metadata providers to fetch metadata for Comics, Manga, and Books - [x] Metadata should allow for collections, want to read integration from 3rd party services, genres. - [x] Ability to manage users, access, and ratings diff --git a/UI/Web/.github/workflows/playwright.yml b/UI/Web/.github/workflows/playwright.yml new file mode 100644 index 000000000..4d7b32a27 --- /dev/null +++ b/UI/Web/.github/workflows/playwright.yml @@ -0,0 +1,27 @@ +name: Playwright Tests +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v2 + with: + node-version: '14.x' + - name: Install dependencies + run: npm ci + - name: Install Playwright + run: npx playwright install --with-deps + - name: Run Playwright tests + run: npx playwright test + - uses: actions/upload-artifact@v2 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/UI/Web/.gitignore b/UI/Web/.gitignore new file mode 100644 index 000000000..dbd64df83 --- /dev/null +++ b/UI/Web/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +test-results/ +playwright-report/ diff --git a/UI/Web/README.md b/UI/Web/README.md index 083807057..3e2904700 100644 --- a/UI/Web/README.md +++ b/UI/Web/README.md @@ -20,7 +20,9 @@ Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github. ## Running end-to-end tests -Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/). +~~Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/).~~ + +Run `npx playwright test --reporter=line` or `npx playwright test` to run e2e tests. ## Further help diff --git a/UI/Web/adminStorageState.json b/UI/Web/adminStorageState.json new file mode 100644 index 000000000..f4ec35503 --- /dev/null +++ b/UI/Web/adminStorageState.json @@ -0,0 +1,4 @@ +{ + "cookies": [], + "origins": [] +} \ No newline at end of file diff --git a/UI/Web/e2e/example.spec.ts.txt b/UI/Web/e2e/example.spec.ts.txt new file mode 100644 index 000000000..0e4037d83 --- /dev/null +++ b/UI/Web/e2e/example.spec.ts.txt @@ -0,0 +1,398 @@ +// import { test, expect, Page } from '@playwright/test'; + +// test.beforeEach(async ({ page }) => { +// await page.goto('https://demo.playwright.dev/todomvc'); +// }); + +// const TODO_ITEMS = [ +// 'buy some cheese', +// 'feed the cat', +// 'book a doctors appointment' +// ]; + +// test.describe('New Todo', () => { +// test('should allow me to add todo items', async ({ page }) => { +// // Create 1st todo. +// await page.locator('.new-todo').fill(TODO_ITEMS[0]); +// await page.locator('.new-todo').press('Enter'); + +// // Make sure the list only has one todo item. +// await expect(page.locator('.view label')).toHaveText([ +// TODO_ITEMS[0] +// ]); + +// // Create 2nd todo. +// await page.locator('.new-todo').fill(TODO_ITEMS[1]); +// await page.locator('.new-todo').press('Enter'); + +// // Make sure the list now has two todo items. +// await expect(page.locator('.view label')).toHaveText([ +// TODO_ITEMS[0], +// TODO_ITEMS[1] +// ]); + +// await checkNumberOfTodosInLocalStorage(page, 2); +// }); + +// test('should clear text input field when an item is added', async ({ page }) => { +// // Create one todo item. +// await page.locator('.new-todo').fill(TODO_ITEMS[0]); +// await page.locator('.new-todo').press('Enter'); + +// // Check that input is empty. +// await expect(page.locator('.new-todo')).toBeEmpty(); +// await checkNumberOfTodosInLocalStorage(page, 1); +// }); + +// test('should append new items to the bottom of the list', async ({ page }) => { +// // Create 3 items. +// await createDefaultTodos(page); + +// // Check test using different methods. +// await expect(page.locator('.todo-count')).toHaveText('3 items left'); +// await expect(page.locator('.todo-count')).toContainText('3'); +// await expect(page.locator('.todo-count')).toHaveText(/3/); + +// // Check all items in one call. +// await expect(page.locator('.view label')).toHaveText(TODO_ITEMS); +// await checkNumberOfTodosInLocalStorage(page, 3); +// }); + +// test('should show #main and #footer when items added', async ({ page }) => { +// await page.locator('.new-todo').fill(TODO_ITEMS[0]); +// await page.locator('.new-todo').press('Enter'); + +// await expect(page.locator('.main')).toBeVisible(); +// await expect(page.locator('.footer')).toBeVisible(); +// await checkNumberOfTodosInLocalStorage(page, 1); +// }); +// }); + +// test.describe('Mark all as completed', () => { +// test.beforeEach(async ({ page }) => { +// await createDefaultTodos(page); +// await checkNumberOfTodosInLocalStorage(page, 3); +// }); + +// test.afterEach(async ({ page }) => { +// await checkNumberOfTodosInLocalStorage(page, 3); +// }); + +// test('should allow me to mark all items as completed', async ({ page }) => { +// // Complete all todos. +// await page.locator('.toggle-all').check(); + +// // Ensure all todos have 'completed' class. +// await expect(page.locator('.todo-list li')).toHaveClass(['completed', 'completed', 'completed']); +// await checkNumberOfCompletedTodosInLocalStorage(page, 3); +// }); + +// test('should allow me to clear the complete state of all items', async ({ page }) => { +// // Check and then immediately uncheck. +// await page.locator('.toggle-all').check(); +// await page.locator('.toggle-all').uncheck(); + +// // Should be no completed classes. +// await expect(page.locator('.todo-list li')).toHaveClass(['', '', '']); +// }); + +// test('complete all checkbox should update state when items are completed / cleared', async ({ page }) => { +// const toggleAll = page.locator('.toggle-all'); +// await toggleAll.check(); +// await expect(toggleAll).toBeChecked(); +// await checkNumberOfCompletedTodosInLocalStorage(page, 3); + +// // Uncheck first todo. +// const firstTodo = page.locator('.todo-list li').nth(0); +// await firstTodo.locator('.toggle').uncheck(); + +// // Reuse toggleAll locator and make sure its not checked. +// await expect(toggleAll).not.toBeChecked(); + +// await firstTodo.locator('.toggle').check(); +// await checkNumberOfCompletedTodosInLocalStorage(page, 3); + +// // Assert the toggle all is checked again. +// await expect(toggleAll).toBeChecked(); +// }); +// }); + +// test.describe('Item', () => { + +// test('should allow me to mark items as complete', async ({ page }) => { +// // Create two items. +// for (const item of TODO_ITEMS.slice(0, 2)) { +// await page.locator('.new-todo').fill(item); +// await page.locator('.new-todo').press('Enter'); +// } + +// // Check first item. +// const firstTodo = page.locator('.todo-list li').nth(0); +// await firstTodo.locator('.toggle').check(); +// await expect(firstTodo).toHaveClass('completed'); + +// // Check second item. +// const secondTodo = page.locator('.todo-list li').nth(1); +// await expect(secondTodo).not.toHaveClass('completed'); +// await secondTodo.locator('.toggle').check(); + +// // Assert completed class. +// await expect(firstTodo).toHaveClass('completed'); +// await expect(secondTodo).toHaveClass('completed'); +// }); + +// test('should allow me to un-mark items as complete', async ({ page }) => { +// // Create two items. +// for (const item of TODO_ITEMS.slice(0, 2)) { +// await page.locator('.new-todo').fill(item); +// await page.locator('.new-todo').press('Enter'); +// } + +// const firstTodo = page.locator('.todo-list li').nth(0); +// const secondTodo = page.locator('.todo-list li').nth(1); +// await firstTodo.locator('.toggle').check(); +// await expect(firstTodo).toHaveClass('completed'); +// await expect(secondTodo).not.toHaveClass('completed'); +// await checkNumberOfCompletedTodosInLocalStorage(page, 1); + +// await firstTodo.locator('.toggle').uncheck(); +// await expect(firstTodo).not.toHaveClass('completed'); +// await expect(secondTodo).not.toHaveClass('completed'); +// await checkNumberOfCompletedTodosInLocalStorage(page, 0); +// }); + +// test('should allow me to edit an item', async ({ page }) => { +// await createDefaultTodos(page); + +// const todoItems = page.locator('.todo-list li'); +// const secondTodo = todoItems.nth(1); +// await secondTodo.dblclick(); +// await expect(secondTodo.locator('.edit')).toHaveValue(TODO_ITEMS[1]); +// await secondTodo.locator('.edit').fill('buy some sausages'); +// await secondTodo.locator('.edit').press('Enter'); + +// // Explicitly assert the new text value. +// await expect(todoItems).toHaveText([ +// TODO_ITEMS[0], +// 'buy some sausages', +// TODO_ITEMS[2] +// ]); +// await checkTodosInLocalStorage(page, 'buy some sausages'); +// }); +// }); + +// test.describe('Editing', () => { +// test.beforeEach(async ({ page }) => { +// await createDefaultTodos(page); +// await checkNumberOfTodosInLocalStorage(page, 3); +// }); + +// test('should hide other controls when editing', async ({ page }) => { +// const todoItem = page.locator('.todo-list li').nth(1); +// await todoItem.dblclick(); +// await expect(todoItem.locator('.toggle')).not.toBeVisible(); +// await expect(todoItem.locator('label')).not.toBeVisible(); +// await checkNumberOfTodosInLocalStorage(page, 3); +// }); + +// test('should save edits on blur', async ({ page }) => { +// const todoItems = page.locator('.todo-list li'); +// await todoItems.nth(1).dblclick(); +// await todoItems.nth(1).locator('.edit').fill('buy some sausages'); +// await todoItems.nth(1).locator('.edit').dispatchEvent('blur'); + +// await expect(todoItems).toHaveText([ +// TODO_ITEMS[0], +// 'buy some sausages', +// TODO_ITEMS[2], +// ]); +// await checkTodosInLocalStorage(page, 'buy some sausages'); +// }); + +// test('should trim entered text', async ({ page }) => { +// const todoItems = page.locator('.todo-list li'); +// await todoItems.nth(1).dblclick(); +// await todoItems.nth(1).locator('.edit').fill(' buy some sausages '); +// await todoItems.nth(1).locator('.edit').press('Enter'); + +// await expect(todoItems).toHaveText([ +// TODO_ITEMS[0], +// 'buy some sausages', +// TODO_ITEMS[2], +// ]); +// await checkTodosInLocalStorage(page, 'buy some sausages'); +// }); + +// test('should remove the item if an empty text string was entered', async ({ page }) => { +// const todoItems = page.locator('.todo-list li'); +// await todoItems.nth(1).dblclick(); +// await todoItems.nth(1).locator('.edit').fill(''); +// await todoItems.nth(1).locator('.edit').press('Enter'); + +// await expect(todoItems).toHaveText([ +// TODO_ITEMS[0], +// TODO_ITEMS[2], +// ]); +// }); + +// test('should cancel edits on escape', async ({ page }) => { +// const todoItems = page.locator('.todo-list li'); +// await todoItems.nth(1).dblclick(); +// await todoItems.nth(1).locator('.edit').press('Escape'); +// await expect(todoItems).toHaveText(TODO_ITEMS); +// }); +// }); + +// test.describe('Counter', () => { +// test('should display the current number of todo items', async ({ page }) => { +// await page.locator('.new-todo').fill(TODO_ITEMS[0]); +// await page.locator('.new-todo').press('Enter'); +// await expect(page.locator('.todo-count')).toContainText('1'); + +// await page.locator('.new-todo').fill(TODO_ITEMS[1]); +// await page.locator('.new-todo').press('Enter'); +// await expect(page.locator('.todo-count')).toContainText('2'); + +// await checkNumberOfTodosInLocalStorage(page, 2); +// }); +// }); + +// test.describe('Clear completed button', () => { +// test.beforeEach(async ({ page }) => { +// await createDefaultTodos(page); +// }); + +// test('should display the correct text', async ({ page }) => { +// await page.locator('.todo-list li .toggle').first().check(); +// await expect(page.locator('.clear-completed')).toHaveText('Clear completed'); +// }); + +// test('should remove completed items when clicked', async ({ page }) => { +// const todoItems = page.locator('.todo-list li'); +// await todoItems.nth(1).locator('.toggle').check(); +// await page.locator('.clear-completed').click(); +// await expect(todoItems).toHaveCount(2); +// await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); +// }); + +// test('should be hidden when there are no items that are completed', async ({ page }) => { +// await page.locator('.todo-list li .toggle').first().check(); +// await page.locator('.clear-completed').click(); +// await expect(page.locator('.clear-completed')).toBeHidden(); +// }); +// }); + +// test.describe('Persistence', () => { +// test('should persist its data', async ({ page }) => { +// for (const item of TODO_ITEMS.slice(0, 2)) { +// await page.locator('.new-todo').fill(item); +// await page.locator('.new-todo').press('Enter'); +// } + +// const todoItems = page.locator('.todo-list li'); +// await todoItems.nth(0).locator('.toggle').check(); +// await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]); +// await expect(todoItems).toHaveClass(['completed', '']); + +// // Ensure there is 1 completed item. +// checkNumberOfCompletedTodosInLocalStorage(page, 1); + +// // Now reload. +// await page.reload(); +// await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]); +// await expect(todoItems).toHaveClass(['completed', '']); +// }); +// }); + +// test.describe('Routing', () => { +// test.beforeEach(async ({ page }) => { +// await createDefaultTodos(page); +// // make sure the app had a chance to save updated todos in storage +// // before navigating to a new view, otherwise the items can get lost :( +// // in some frameworks like Durandal +// await checkTodosInLocalStorage(page, TODO_ITEMS[0]); +// }); + +// test('should allow me to display active items', async ({ page }) => { +// await page.locator('.todo-list li .toggle').nth(1).check(); +// await checkNumberOfCompletedTodosInLocalStorage(page, 1); +// await page.locator('.filters >> text=Active').click(); +// await expect(page.locator('.todo-list li')).toHaveCount(2); +// await expect(page.locator('.todo-list li')).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); +// }); + +// test('should respect the back button', async ({ page }) => { +// await page.locator('.todo-list li .toggle').nth(1).check(); +// await checkNumberOfCompletedTodosInLocalStorage(page, 1); + +// await test.step('Showing all items', async () => { +// await page.locator('.filters >> text=All').click(); +// await expect(page.locator('.todo-list li')).toHaveCount(3); +// }); + +// await test.step('Showing active items', async () => { +// await page.locator('.filters >> text=Active').click(); +// }); + +// await test.step('Showing completed items', async () => { +// await page.locator('.filters >> text=Completed').click(); +// }); + +// await expect(page.locator('.todo-list li')).toHaveCount(1); +// await page.goBack(); +// await expect(page.locator('.todo-list li')).toHaveCount(2); +// await page.goBack(); +// await expect(page.locator('.todo-list li')).toHaveCount(3); +// }); + +// test('should allow me to display completed items', async ({ page }) => { +// await page.locator('.todo-list li .toggle').nth(1).check(); +// await checkNumberOfCompletedTodosInLocalStorage(page, 1); +// await page.locator('.filters >> text=Completed').click(); +// await expect(page.locator('.todo-list li')).toHaveCount(1); +// }); + +// test('should allow me to display all items', async ({ page }) => { +// await page.locator('.todo-list li .toggle').nth(1).check(); +// await checkNumberOfCompletedTodosInLocalStorage(page, 1); +// await page.locator('.filters >> text=Active').click(); +// await page.locator('.filters >> text=Completed').click(); +// await page.locator('.filters >> text=All').click(); +// await expect(page.locator('.todo-list li')).toHaveCount(3); +// }); + +// test('should highlight the currently applied filter', async ({ page }) => { +// await expect(page.locator('.filters >> text=All')).toHaveClass('selected'); +// await page.locator('.filters >> text=Active').click(); +// // Page change - active items. +// await expect(page.locator('.filters >> text=Active')).toHaveClass('selected'); +// await page.locator('.filters >> text=Completed').click(); +// // Page change - completed items. +// await expect(page.locator('.filters >> text=Completed')).toHaveClass('selected'); +// }); +// }); + +// async function createDefaultTodos(page: Page) { +// for (const item of TODO_ITEMS) { +// await page.locator('.new-todo').fill(item); +// await page.locator('.new-todo').press('Enter'); +// } +// } + +// async function checkNumberOfTodosInLocalStorage(page: Page, expected: number) { +// return await page.waitForFunction(e => { +// return JSON.parse(localStorage['react-todos']).length === e; +// }, expected); +// } + +// async function checkNumberOfCompletedTodosInLocalStorage(page: Page, expected: number) { +// return await page.waitForFunction(e => { +// return JSON.parse(localStorage['react-todos']).filter(i => i.completed).length === e; +// }, expected); +// } + +// async function checkTodosInLocalStorage(page: Page, title: string) { +// return await page.waitForFunction(t => { +// return JSON.parse(localStorage['react-todos']).map(i => i.title).includes(t); +// }, title); +// } diff --git a/UI/Web/e2e/src/app.e2e-spec.ts b/UI/Web/e2e/src/app.e2e-spec.ts deleted file mode 100644 index fb7ac99be..000000000 --- a/UI/Web/e2e/src/app.e2e-spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { AppPage } from './app.po'; -import { browser, logging } from 'protractor'; - -describe('workspace-project App', () => { - let page: AppPage; - - beforeEach(() => { - page = new AppPage(); - }); - - it('should display welcome message', async () => { - await page.navigateTo(); - expect(await page.getTitleText()).toEqual('kavita-webui app is running!'); - }); - - afterEach(async () => { - // Assert that there are no errors emitted from the browser - const logs = await browser.manage().logs().get(logging.Type.BROWSER); - expect(logs).not.toContain(jasmine.objectContaining({ - level: logging.Level.SEVERE, - } as logging.Entry)); - }); -}); diff --git a/UI/Web/e2e/src/app.spec.ts b/UI/Web/e2e/src/app.spec.ts new file mode 100644 index 000000000..2f07e3839 --- /dev/null +++ b/UI/Web/e2e/src/app.spec.ts @@ -0,0 +1,18 @@ +import { test, expect } from '@playwright/test'; + +test('When not authenticated, should be redirected to login page', async ({ page }) => { + await page.goto('http://localhost:4200/', { waitUntil: 'networkidle' }); + expect(page.url()).toBe('http://localhost:4200/login'); +}); + +test('When not authenticated, should be redirected to login page from an authenticated page', async ({ page }) => { + await page.goto('http://localhost:4200/library', { waitUntil: 'networkidle' }); + expect(page.url()).toBe('http://localhost:4200/login'); +}); + +// Not sure how to test when we need localStorage: https://github.com/microsoft/playwright/issues/6258 +// test('When authenticated, should be redirected to library page', async ({ page }) => { +// await page.goto('http://localhost:4200/', { waitUntil: 'networkidle' }); +// console.log('url: ', page.url()); +// expect(page.url()).toBe('http://localhost:4200/library'); +// }); \ No newline at end of file diff --git a/UI/Web/e2e/src/login/login.spec.ts b/UI/Web/e2e/src/login/login.spec.ts new file mode 100644 index 000000000..8a504f3f9 --- /dev/null +++ b/UI/Web/e2e/src/login/login.spec.ts @@ -0,0 +1,43 @@ +import { expect, test } from "@playwright/test"; + +test('When not authenticated, should be redirected to login page', async ({ page }) => { + await page.goto('http://localhost:4200/', { waitUntil: 'networkidle' }); + expect(page.url()).toBe('http://localhost:4200/login'); +}); + +test('Should be able to log in', async ({ page }) => { + + await page.goto('http://localhost:4200/login', { waitUntil: 'networkidle' }); + const username = page.locator('#username'); + expect(username).toBeEditable(); + const password = page.locator('#password'); + expect(password).toBeEditable(); + + await username.type('Joe'); + await password.type('P4ssword'); + + const button = page.locator('button[type="submit"]'); + await button.click(); + + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(200); + expect(page.url()).toBe('http://localhost:4200/library'); +}); + +test('Should get a toastr when no username', async ({ page }) => { + + await page.goto('http://localhost:4200/login', { waitUntil: 'networkidle' }); + const username = page.locator('#username'); + expect(username).toBeEditable(); + + await username.type(''); + + const button = page.locator('button[type="submit"]'); + await button.click(); + + await page.waitForTimeout(100); + const toastr = page.locator('#toast-container div[role="alertdialog"]') + await expect(toastr).toHaveText('Invalid username'); + + expect(page.url()).toBe('http://localhost:4200/login'); +}); \ No newline at end of file diff --git a/UI/Web/e2e/src/registration/forgot-password/forgot-password.spec.ts b/UI/Web/e2e/src/registration/forgot-password/forgot-password.spec.ts new file mode 100644 index 000000000..937651d9b --- /dev/null +++ b/UI/Web/e2e/src/registration/forgot-password/forgot-password.spec.ts @@ -0,0 +1,34 @@ +import { expect, test } from "@playwright/test"; + +test('When on login page, clicking Forgot Password should redirect', async ({ page }) => { + await page.goto('http://localhost:4200/login', { waitUntil: 'networkidle' }); + + await page.click('a[routerlink="/registration/reset-password"]') + await page.waitForLoadState('networkidle'); + + expect(page.url()).toBe('http://localhost:4200/registration/reset-password'); +}); + +test('Going directly to reset url should stay on the page', async ({page}) => { + await page.goto('http://localhost:4200/registration/reset-password', { waitUntil: 'networkidle' }); + const email = page.locator('#email'); + expect(email).toBeEditable(); +}) + +test('Submitting an email, should give a prompt to user, redirect back to login', async ({ page }) => { + await page.goto('http://localhost:4200/registration/reset-password', { waitUntil: 'networkidle' }); + + const email = page.locator('#email'); + expect(email).toBeEditable(); + + await email.type('XXX@gmail.com'); + + const button = page.locator('button[type="submit"]'); + await button.click(); + + const toastr = page.locator('#toast-container div[role="alertdialog"]') + await expect(toastr).toHaveText('An email will be sent to the email if it exists in our database'); + await page.waitForLoadState('networkidle'); + + expect(page.url()).toBe('http://localhost:4200/login'); +}); \ No newline at end of file diff --git a/UI/Web/e2e/src/side-nav/side-nav.spec.ts b/UI/Web/e2e/src/side-nav/side-nav.spec.ts new file mode 100644 index 000000000..b74d07eea --- /dev/null +++ b/UI/Web/e2e/src/side-nav/side-nav.spec.ts @@ -0,0 +1,13 @@ +import { expect, test } from "@playwright/test"; + +test.use({ storageState: 'storage/admin.json' }); + +test('When on login page, side nav should not render', async ({ page }) => { + await page.goto('http://localhost:4200/login', { waitUntil: 'networkidle' }); + await expect(page.locator(".side-nav")).toHaveCount(0) +}); + +test('When on library page, side nav should render', async ({ page }) => { + await page.goto('http://localhost:4200/library', { waitUntil: 'networkidle' }); + await expect(page.locator(".side-nav")).toHaveCount(1) +}); \ No newline at end of file diff --git a/UI/Web/global-setup.ts b/UI/Web/global-setup.ts new file mode 100644 index 000000000..8db0f8bde --- /dev/null +++ b/UI/Web/global-setup.ts @@ -0,0 +1,46 @@ +import { Browser, chromium, FullConfig, request } from '@playwright/test'; + +async function globalSetup(config: FullConfig) { + let requestContext = await request.newContext(); + var token = await requestContext.post('http://localhost:5000/account/login', { + form: { + 'user': 'Joe', + 'password': 'P4ssword' + } + }); + console.log(token.json()); + // Save signed-in state to 'storageState.json'. + //await requestContext.storageState({ path: 'adminStorageState.json' }); + await requestContext.dispose(); + + requestContext = await request.newContext(); + await requestContext.post('http://localhost:5000/account/login', { + form: { + 'user': 'nonadmin', + 'password': 'P4ssword' + } + }); + // Save signed-in state to 'storageState.json'. + //await requestContext.storageState({ path: 'nonAdminStorageState.json' }); + await requestContext.dispose(); +} + + + +// async function globalSetup (config: FullConfig) { +// const browser = await chromium.launch() +// await saveStorage(browser, 'nonadmin', 'P4ssword', 'storage/user.json') +// await saveStorage(browser, 'Joe', 'P4ssword', 'storage/admin.json') +// await browser.close() +// } + +async function saveStorage (browser: Browser, username: string, password: string, saveStoragePath: string) { + const page = await browser.newPage() + await page.goto('http://localhost:5000/account/login') + await page.type('#username', username) + await page.type('#password', password) + await page.click('button[type="submit"]') + await page.context().storageState({ path: saveStoragePath }) +} + +export default globalSetup; \ No newline at end of file diff --git a/UI/Web/nonAdminStorageState.json b/UI/Web/nonAdminStorageState.json new file mode 100644 index 000000000..f4ec35503 --- /dev/null +++ b/UI/Web/nonAdminStorageState.json @@ -0,0 +1,4 @@ +{ + "cookies": [], + "origins": [] +} \ No newline at end of file diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index c0b0ce2cd..a43469c5e 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -1599,6 +1599,17 @@ "@babel/helper-plugin-utils": "^7.16.7" } }, + "@babel/plugin-transform-typescript": { + "version": "7.16.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.16.8.tgz", + "integrity": "sha512-bHdQ9k7YpBDO2d0NVfkj51DpQcvwIzIusJ7mEUaMlbZq3Kt/U47j24inXZHQ5MDiYpCs+oZiwnXyKedE8+q7AQ==", + "dev": true, + "requires": { + "@babel/helper-create-class-features-plugin": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-typescript": "^7.16.7" + } + }, "@babel/plugin-transform-unicode-escapes": { "version": "7.16.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.16.7.tgz", @@ -1721,6 +1732,17 @@ "esutils": "^2.0.2" } }, + "@babel/preset-typescript": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.16.7.tgz", + "integrity": "sha512-WbVEmgXdIyvzB77AQjGBEyYPZx+8tTsO50XtfozQrkW8QB2rLJpH2lgx0TRw5EJrBxOZQ+wCcyPVQvS8tjEHpQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/helper-validator-option": "^7.16.7", + "@babel/plugin-transform-typescript": "^7.16.7" + } + }, "@babel/runtime": { "version": "7.16.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.16.7.tgz", @@ -2553,6 +2575,278 @@ "read-package-json-fast": "^2.0.1" } }, + "@playwright/test": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.20.2.tgz", + "integrity": "sha512-unkLa+xe/lP7MVC0qpgadc9iSG1+LEyGBzlXhGS/vLGAJaSFs8DNfI89hNd5shHjWfNzb34JgPVnkRKCSNo5iw==", + "dev": true, + "requires": { + "@babel/code-frame": "7.16.7", + "@babel/core": "7.16.12", + "@babel/helper-plugin-utils": "7.16.7", + "@babel/plugin-proposal-class-properties": "7.16.7", + "@babel/plugin-proposal-dynamic-import": "7.16.7", + "@babel/plugin-proposal-export-namespace-from": "7.16.7", + "@babel/plugin-proposal-logical-assignment-operators": "7.16.7", + "@babel/plugin-proposal-nullish-coalescing-operator": "7.16.7", + "@babel/plugin-proposal-numeric-separator": "7.16.7", + "@babel/plugin-proposal-optional-chaining": "7.16.7", + "@babel/plugin-proposal-private-methods": "7.16.11", + "@babel/plugin-proposal-private-property-in-object": "7.16.7", + "@babel/plugin-syntax-async-generators": "7.8.4", + "@babel/plugin-syntax-json-strings": "7.8.3", + "@babel/plugin-syntax-object-rest-spread": "7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "7.8.3", + "@babel/plugin-transform-modules-commonjs": "7.16.8", + "@babel/preset-typescript": "7.16.7", + "colors": "1.4.0", + "commander": "8.3.0", + "debug": "4.3.3", + "expect": "27.2.5", + "jest-matcher-utils": "27.2.5", + "json5": "2.2.1", + "mime": "3.0.0", + "minimatch": "3.0.4", + "ms": "2.1.3", + "open": "8.4.0", + "pirates": "4.0.4", + "playwright-core": "1.20.2", + "rimraf": "3.0.2", + "source-map-support": "0.4.18", + "stack-utils": "2.0.5", + "yazl": "2.5.1" + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz", + "integrity": "sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==", + "dev": true, + "requires": { + "@babel/highlight": "^7.16.7" + } + }, + "@babel/core": { + "version": "7.16.12", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.16.12.tgz", + "integrity": "sha512-dK5PtG1uiN2ikk++5OzSYsitZKny4wOCD0nrO4TqnW4BVBTQ2NGS3NgilvT/TEyxTST7LNyWV/T4tXDoD3fOgg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.16.7", + "@babel/generator": "^7.16.8", + "@babel/helper-compilation-targets": "^7.16.7", + "@babel/helper-module-transforms": "^7.16.7", + "@babel/helpers": "^7.16.7", + "@babel/parser": "^7.16.12", + "@babel/template": "^7.16.7", + "@babel/traverse": "^7.16.10", + "@babel/types": "^7.16.8", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.1.2", + "semver": "^6.3.0", + "source-map": "^0.5.0" + } + }, + "@babel/helper-validator-identifier": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz", + "integrity": "sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==", + "dev": true + }, + "@babel/highlight": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.17.9.tgz", + "integrity": "sha512-J9PfEKCbFIv2X5bjTMiZu6Vf341N05QIY+d6FvVKynkG1S7G0j3I0QoRtWIrXhZ+/Nlb5Q0MzqL7TokEJ5BNHg==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.16.7", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + } + }, + "agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "requires": { + "debug": "4" + } + }, + "ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "dev": true + }, + "expect": { + "version": "27.2.5", + "resolved": "https://registry.npmjs.org/expect/-/expect-27.2.5.tgz", + "integrity": "sha512-ZrO0w7bo8BgGoP/bLz+HDCI+0Hfei9jUSZs5yI/Wyn9VkG9w8oJ7rHRgYj+MA7yqqFa0IwHA3flJzZtYugShJA==", + "dev": true, + "requires": { + "@jest/types": "^27.2.5", + "ansi-styles": "^5.0.0", + "jest-get-type": "^27.0.6", + "jest-matcher-utils": "^27.2.5", + "jest-message-util": "^27.2.5", + "jest-regex-util": "^27.0.6" + } + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "https-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz", + "integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==", + "dev": true, + "requires": { + "agent-base": "6", + "debug": "4" + } + }, + "jest-matcher-utils": { + "version": "27.2.5", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.2.5.tgz", + "integrity": "sha512-qNR/kh6bz0Dyv3m68Ck2g1fLW5KlSOUNcFQh87VXHZwWc/gY6XwnKofx76Qytz3x5LDWT09/2+yXndTkaG4aWg==", + "dev": true, + "requires": { + "chalk": "^4.0.0", + "jest-diff": "^27.2.5", + "jest-get-type": "^27.0.6", + "pretty-format": "^27.2.5" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "json5": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz", + "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==", + "dev": true + }, + "mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "dev": true + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "pirates": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.4.tgz", + "integrity": "sha512-ZIrVPH+A52Dw84R0L3/VS9Op04PuQ2SEoJL6bkshmiTic/HldyW9Tf7oH5mhJZBK7NmDx27vSMrYEXPXclpDKw==", + "dev": true + }, + "playwright-core": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.20.2.tgz", + "integrity": "sha512-iV6+HftSPalynkq0CYJala1vaTOq7+gU9BRfKCdM9bAxNq/lFLrwbluug2Wt5OoUwbMABcnTThIEm3/qUhCdJQ==", + "dev": true, + "requires": { + "colors": "1.4.0", + "commander": "8.3.0", + "debug": "4.3.3", + "extract-zip": "2.0.1", + "https-proxy-agent": "5.0.0", + "jpeg-js": "0.4.3", + "mime": "3.0.0", + "pixelmatch": "5.2.1", + "pngjs": "6.0.0", + "progress": "2.0.3", + "proper-lockfile": "4.1.2", + "proxy-from-env": "1.1.0", + "rimraf": "3.0.2", + "socks-proxy-agent": "6.1.1", + "stack-utils": "2.0.5", + "ws": "8.4.2", + "yauzl": "2.10.0", + "yazl": "2.5.1" + } + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + }, + "source-map-support": { + "version": "0.4.18", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.4.18.tgz", + "integrity": "sha512-try0/JqxPLF9nOjvSta7tVondkP5dwgyLDjVoyMDlmjugT2lRZ1OfsrYTkCd2hkDnJTKRbO/Rl3orm8vlsUzbA==", + "dev": true, + "requires": { + "source-map": "^0.5.6" + } + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "ws": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.4.2.tgz", + "integrity": "sha512-Kbk4Nxyq7/ZWqr/tarI9yIt/+iNNFOjBXEWgTb4ydaNHBNGgvf2QHbS9fdfsndfjFlFwEd4Al+mw83YkaD10ZA==", + "dev": true + } + } + }, "@polka/url": { "version": "1.0.0-next.21", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.21.tgz", @@ -2919,6 +3213,16 @@ "integrity": "sha512-7tFImggNeNBVMsn0vLrpn1H1uPrUBdnARPTpZoitY37ZrdJREzf7I16tMrlK3hen349gr1NYh8CmZQa7CTG6Aw==", "dev": true }, + "@types/yauzl": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.9.2.tgz", + "integrity": "sha512-8uALY5LTvSuHgloDVUvWP3pIauILm+8/0pDMokuDYIoNsOkSwd5AiHBTSEJjKTDcZr5z8UpgOWZkxBF4iJftoA==", + "dev": true, + "optional": true, + "requires": { + "@types/node": "*" + } + }, "@webassemblyjs/ast": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz", @@ -3906,6 +4210,12 @@ "ieee754": "^1.1.13" } }, + "buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=", + "dev": true + }, "buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -4237,6 +4547,12 @@ "integrity": "sha512-hUewv7oMjCp+wkBv5Rm0v87eJhq4woh5rSR+42YSQJKecCqgIqNkZ6lAlQms/BwHPJA5NKMRlpxPRv0n8HQW6g==", "dev": true }, + "colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", + "dev": true + }, "combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -5113,6 +5429,15 @@ } } }, + "end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dev": true, + "requires": { + "once": "^1.4.0" + } + }, "enhanced-resolve": { "version": "5.9.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.9.0.tgz", @@ -5558,6 +5883,29 @@ "tmp": "^0.0.33" } }, + "extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "requires": { + "@types/yauzl": "^2.9.1", + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "dependencies": { + "get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "requires": { + "pump": "^3.0.0" + } + } + } + }, "extsprintf": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", @@ -5639,6 +5987,15 @@ "bser": "2.1.1" } }, + "fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4=", + "dev": true, + "requires": { + "pend": "~1.2.0" + } + }, "fetch-cookie": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-0.11.0.tgz", @@ -8301,6 +8658,12 @@ } } }, + "jpeg-js": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.3.tgz", + "integrity": "sha512-ru1HWKek8octvUHFHvE5ZzQ1yAsJmIvRdGWvSoKV52XKyuyYA437QWDttXT8eZXDSbuMpHlLzPDZUPd6idIz+Q==", + "dev": true + }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -9897,6 +10260,12 @@ "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", "dev": true }, + "pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=", + "dev": true + }, "performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -9955,6 +10324,23 @@ "nice-napi": "^1.0.2" } }, + "pixelmatch": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-5.2.1.tgz", + "integrity": "sha512-WjcAdYSnKrrdDdqTcVEY7aB7UhhwjYQKYhHiBXdJef0MOaQeYpUdQ+iVyBLa5YBKS8MPVPPMX7rpOByISLpeEQ==", + "dev": true, + "requires": { + "pngjs": "^4.0.1" + }, + "dependencies": { + "pngjs": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-4.0.1.tgz", + "integrity": "sha512-rf5+2/ioHeQxR6IxuYNYGFytUyG3lma/WW1nsmjeHlWwtb2aByla6dkVc8pmJ9nplzkTA0q2xx7mMWrOTqT4Gg==", + "dev": true + } + } + }, "pkg-dir": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", @@ -9964,6 +10350,86 @@ "find-up": "^4.0.0" } }, + "playwright": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.20.2.tgz", + "integrity": "sha512-p6GE8A/f2G7t8FIk/AwQ94nT7R7tyPRJyKt1FwRjwBDf4WdpgoAr4hDfMgHy+CkClR22adFjopGwhxXAPsewhg==", + "dev": true, + "requires": { + "playwright-core": "1.20.2" + }, + "dependencies": { + "agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "requires": { + "debug": "4" + } + }, + "commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "dev": true + }, + "https-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz", + "integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==", + "dev": true, + "requires": { + "agent-base": "6", + "debug": "4" + } + }, + "mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "dev": true + }, + "playwright-core": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.20.2.tgz", + "integrity": "sha512-iV6+HftSPalynkq0CYJala1vaTOq7+gU9BRfKCdM9bAxNq/lFLrwbluug2Wt5OoUwbMABcnTThIEm3/qUhCdJQ==", + "dev": true, + "requires": { + "colors": "1.4.0", + "commander": "8.3.0", + "debug": "4.3.3", + "extract-zip": "2.0.1", + "https-proxy-agent": "5.0.0", + "jpeg-js": "0.4.3", + "mime": "3.0.0", + "pixelmatch": "5.2.1", + "pngjs": "6.0.0", + "progress": "2.0.3", + "proper-lockfile": "4.1.2", + "proxy-from-env": "1.1.0", + "rimraf": "3.0.2", + "socks-proxy-agent": "6.1.1", + "stack-utils": "2.0.5", + "ws": "8.4.2", + "yauzl": "2.10.0", + "yazl": "2.5.1" + } + }, + "ws": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.4.2.tgz", + "integrity": "sha512-Kbk4Nxyq7/ZWqr/tarI9yIt/+iNNFOjBXEWgTb4ydaNHBNGgvf2QHbS9fdfsndfjFlFwEd4Al+mw83YkaD10ZA==", + "dev": true + } + } + }, + "pngjs": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz", + "integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==", + "dev": true + }, "portfinder": { "version": "1.0.28", "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.28.tgz", @@ -10366,6 +10832,12 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true }, + "progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true + }, "promise-inflight": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", @@ -10400,6 +10872,25 @@ "sisteransi": "^1.0.5" } }, + "proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + }, + "dependencies": { + "retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs=", + "dev": true + } + } + }, "protractor": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/protractor/-/protractor-7.0.0.tgz", @@ -10761,6 +11252,12 @@ } } }, + "proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true + }, "prr": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", @@ -10773,6 +11270,16 @@ "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==" }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "punycode": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", @@ -12953,6 +13460,25 @@ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.0.0.tgz", "integrity": "sha512-z9kApYUOCwoeZ78rfRYYWdiU/iNL6mwwYlkkZfJoyMR1xps+NEBX5X7XmRpxkZHhXJ6+Ey00IwKxBBSW9FIjyA==" }, + "yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk=", + "dev": true, + "requires": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "yazl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/yazl/-/yazl-2.5.1.tgz", + "integrity": "sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw==", + "dev": true, + "requires": { + "buffer-crc32": "~0.2.3" + } + }, "yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/UI/Web/package.json b/UI/Web/package.json index 235cbd85f..8d1020079 100644 --- a/UI/Web/package.json +++ b/UI/Web/package.json @@ -49,12 +49,14 @@ "@angular-devkit/build-angular": "~13.2.3", "@angular/cli": "^13.2.3", "@angular/compiler-cli": "~13.2.2", + "@playwright/test": "^1.20.2", "@types/jest": "^27.4.0", "@types/node": "^17.0.17", "codelyzer": "^6.0.2", "jest": "^27.5.1", "jest-preset-angular": "^11.1.0", "karma-coverage": "~2.2.0", + "playwright": "^1.20.2", "protractor": "~7.0.0", "ts-node": "~10.5.0", "tslint": "^6.1.3", diff --git a/UI/Web/playwright.config.ts b/UI/Web/playwright.config.ts new file mode 100644 index 000000000..8c5e0ca57 --- /dev/null +++ b/UI/Web/playwright.config.ts @@ -0,0 +1,106 @@ +import type { PlaywrightTestConfig } from '@playwright/test'; +import { devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +const config: PlaywrightTestConfig = { + testDir: './e2e', + /* Maximum time one test can run for. */ + timeout: 30 * 1000, + expect: { + /** + * Maximum time expect() should wait for the condition to be met. + * For example in `await expect(locator).toHaveText();` + */ + timeout: 5000 + }, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ + actionTimeout: 0, + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: 'http://localhost:4200', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + globalSetup: require.resolve('./global-setup'), + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + }, + }, + + // { + // name: 'firefox', + // use: { + // ...devices['Desktop Firefox'], + // }, + // }, + + // { + // name: 'webkit', + // use: { + // ...devices['Desktop Safari'], + // }, + // }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { + // ...devices['Pixel 5'], + // }, + // }, + // { + // name: 'Mobile Safari', + // use: { + // ...devices['iPhone 12'], + // }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { + // channel: 'msedge', + // }, + // }, + // { + // name: 'Google Chrome', + // use: { + // channel: 'chrome', + // }, + // }, + ], + + /* Folder for test artifacts such as screenshots, videos, traces, etc. */ + // outputDir: 'test-results/', + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // port: 3000, + // }, +}; + +export default config; diff --git a/UI/Web/src/app/_models/reading-list.ts b/UI/Web/src/app/_models/reading-list.ts index ad1a325b8..da7932acc 100644 --- a/UI/Web/src/app/_models/reading-list.ts +++ b/UI/Web/src/app/_models/reading-list.ts @@ -19,5 +19,6 @@ export interface ReadingList { title: string; summary: string; promoted: boolean; + coverImageLocked: boolean; items: Array; } \ No newline at end of file diff --git a/UI/Web/src/app/_models/series-metadata.ts b/UI/Web/src/app/_models/series-metadata.ts index 9905cc1a6..e67666ab9 100644 --- a/UI/Web/src/app/_models/series-metadata.ts +++ b/UI/Web/src/app/_models/series-metadata.ts @@ -32,7 +32,7 @@ export interface SeriesMetadata { tagsLocked: boolean; writersLocked: boolean; coverArtistsLocked: boolean; - publishersLocked: boolean; + publisherLocked: boolean; charactersLocked: boolean; pencillersLocked: boolean; inkersLocked: boolean; diff --git a/UI/Web/src/app/_services/action.service.ts b/UI/Web/src/app/_services/action.service.ts index b6fc00704..6d627ca6a 100644 --- a/UI/Web/src/app/_services/action.service.ts +++ b/UI/Web/src/app/_services/action.service.ts @@ -460,7 +460,7 @@ export class ActionService implements OnDestroy { } editReadingList(readingList: ReadingList, callback?: ReadingListActionCallback) { - const readingListModalRef = this.modalService.open(EditReadingListModalComponent, { scrollable: true, size: 'md' }); + const readingListModalRef = this.modalService.open(EditReadingListModalComponent, { scrollable: true, size: 'lg' }); readingListModalRef.componentInstance.readingList = readingList; readingListModalRef.closed.pipe(take(1)).subscribe((list) => { if (callback && list !== undefined) { diff --git a/UI/Web/src/app/_services/image.service.ts b/UI/Web/src/app/_services/image.service.ts index d0abe8c12..0577bd663 100644 --- a/UI/Web/src/app/_services/image.service.ts +++ b/UI/Web/src/app/_services/image.service.ts @@ -75,6 +75,10 @@ export class ImageService implements OnDestroy { return this.baseUrl + 'image/collection-cover?collectionTagId=' + collectionTagId; } + getReadingListCoverImage(readingListId: number) { + return this.baseUrl + 'image/readinglist-cover?readingListId=' + readingListId; + } + getChapterCoverImage(chapterId: number) { return this.baseUrl + 'image/chapter-cover?chapterId=' + chapterId; } diff --git a/UI/Web/src/app/_services/series.service.ts b/UI/Web/src/app/_services/series.service.ts index e6c8e1cf2..141481a05 100644 --- a/UI/Web/src/app/_services/series.service.ts +++ b/UI/Web/src/app/_services/series.service.ts @@ -133,9 +133,6 @@ export class SeriesService { getRecentlyUpdatedSeries() { return this.httpClient.post(this.baseUrl + 'series/recently-updated-series', {}); } - getRecentlyAddedChapters() { - return this.httpClient.post(this.baseUrl + 'series/recently-added-chapters', {}); - } getOnDeck(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) { const data = this.createSeriesFilter(filter); diff --git a/UI/Web/src/app/_services/upload.service.ts b/UI/Web/src/app/_services/upload.service.ts index 7d930e8e6..8f3c1d07a 100644 --- a/UI/Web/src/app/_services/upload.service.ts +++ b/UI/Web/src/app/_services/upload.service.ts @@ -30,6 +30,10 @@ export class UploadService { return this.httpClient.post(this.baseUrl + 'upload/collection', {id: tagId, url: this._cleanBase64Url(url)}); } + updateReadingListCoverImage(readingListId: number, url: string) { + return this.httpClient.post(this.baseUrl + 'upload/reading-list', {id: readingListId, url: this._cleanBase64Url(url)}); + } + updateChapterCoverImage(chapterId: number, url: string) { return this.httpClient.post(this.baseUrl + 'upload/chapter', {id: chapterId, url: this._cleanBase64Url(url)}); } diff --git a/UI/Web/src/app/book-reader/book-reader/book-reader.component.html b/UI/Web/src/app/book-reader/book-reader/book-reader.component.html index 93446c1d0..5812144e5 100644 --- a/UI/Web/src/app/book-reader/book-reader/book-reader.component.html +++ b/UI/Web/src/app/book-reader/book-reader/book-reader.component.html @@ -130,7 +130,7 @@  {{readingDirection === ReadingDirection.LeftToRight ? 'Previous' : 'Next'}} - +
diff --git a/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.html b/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.html index 27a3916f2..3e014b1af 100644 --- a/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.html +++ b/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.html @@ -5,16 +5,15 @@
-