From 28ab34c66d64c23678c1b28e917ae4358e878d19 Mon Sep 17 00:00:00 2001 From: Joe Milazzo Date: Mon, 26 Sep 2022 12:40:25 -0500 Subject: [PATCH] Disable Animations + Lots of bugfixes and Polish (#1561) * Fixed inputs not showing inline validation due to a missing class * Fixed some checks * Increased the button size on manga reader (develop) * Migrated a type cast to a pure pipe * Sped up the check for if SendTo should render on the menu * Don't allow user to bookmark in bookmark mode * Fixed a bug where Scan Series would skip over Specials due to how new scan loop works. * Fixed scroll to top button persisting when navigating between pages * Edit Series modal now doesn't have a lock field for Series, which can't be locked as it is inheritently locked. Added some validation to ensure Name and SortName are required. * Fixed up some spacing * Fixed actionable menu not opening submenu on mobile * Cleaned up the layout of cover image on series detail * Show all volume or chapters (if only one volume) for cover selection on series * Don't open submenu to right if there is no space * Fixed up cover image not allowing custom saves of existing series/chapter/volume images. Fixed up logging so console output matches log file. * Implemented the ability to turn off css transitions in the UI. * Updated a note internally * Code smells * Added InstallId when pinging the email service to allow throughput tracking --- API.Tests/BasicTest.cs | 2 +- .../ParserInfoListExtensionsTests.cs | 1 + API.Tests/Parser/ComicParserTests.cs | 1 + API.Tests/Parser/DefaultParserTests.cs | 16 + API.Tests/Services/DeviceServiceTests.cs | 3 +- API.Tests/Services/ParseScannedFilesTests.cs | 1 + API/Controllers/UploadController.cs | 4 +- API/Controllers/UsersController.cs | 1 + API/DTOs/UserPreferencesDto.cs | 10 +- ...0220926145902_AddNoTransitions.Designer.cs | 1661 +++++++++++++++++ .../20220926145902_AddNoTransitions.cs | 26 + .../Migrations/DataContextModelSnapshot.cs | 3 + API/Data/Repositories/UserRepository.cs | 25 - API/Entities/AppUserPreferences.cs | 4 + API/Entities/Device.cs | 3 - API/Entities/Library.cs | 2 +- API/Helpers/Filters/ETagFromFilename.cs | 232 --- API/Logging/LogLevelOptions.cs | 6 +- API/Services/BookService.cs | 2 +- API/Services/EmailService.cs | 11 +- API/Services/ReadingItemService.cs | 1 + API/Services/Tasks/Scanner/LibraryWatcher.cs | 2 +- .../Tasks/Scanner/Parser/DefaultParser.cs | 104 +- API/Services/Tasks/Scanner/ProcessSeries.cs | 2 - API/Services/Tasks/ScannerService.cs | 2 - .../app/_models/preferences/preferences.ts | 1 + .../app/_services/action-factory.service.ts | 105 +- UI/Web/src/app/_services/theme.service.ts | 5 +- .../admin/edit-user/edit-user.component.html | 4 +- .../invite-user/invite-user.component.html | 2 +- .../manage-settings.component.html | 11 +- UI/Web/src/app/app.component.html | 25 +- UI/Web/src/app/app.component.ts | 9 +- UI/Web/src/app/app.module.ts | 1 + .../edit-series-modal.component.html | 20 +- .../edit-series-modal.component.ts | 12 +- .../card-detail-drawer.component.ts | 6 + .../card-actionables.component.html | 4 +- .../card-actionables.component.ts | 7 +- .../cards/card-item/card-item.component.ts | 40 +- UI/Web/src/app/cards/cards.module.ts | 2 + .../cover-image-chooser.component.ts | 48 +- UI/Web/src/app/cards/dynamic-list.pipe.ts | 14 + .../manga-reader/manga-reader.component.html | 12 +- .../manga-reader/manga-reader.component.ts | 2 + .../metadata-filter.component.html | 2 +- .../nav/nav-header/nav-header.component.ts | 11 +- UI/Web/src/app/pipe/pipe.module.ts | 4 +- ...-to-account-migration-modal.component.html | 13 +- ...il-to-account-migration-modal.component.ts | 2 +- .../confirm-email.component.html | 6 +- .../register/register.component.html | 9 +- .../reset-password.component.html | 2 +- .../series-detail.component.html | 4 +- .../series-detail/series-detail.component.ts | 1 + .../edit-device/edit-device.component.html | 10 +- .../user-preferences.component.html | 21 +- .../user-preferences.component.ts | 3 + UI/Web/src/theme/utilities/_global.scss | 4 + 59 files changed, 2103 insertions(+), 444 deletions(-) create mode 100644 API/Data/Migrations/20220926145902_AddNoTransitions.Designer.cs create mode 100644 API/Data/Migrations/20220926145902_AddNoTransitions.cs delete mode 100644 API/Helpers/Filters/ETagFromFilename.cs create mode 100644 UI/Web/src/app/cards/dynamic-list.pipe.ts diff --git a/API.Tests/BasicTest.cs b/API.Tests/BasicTest.cs index c40b3706d..fb2f2bbf0 100644 --- a/API.Tests/BasicTest.cs +++ b/API.Tests/BasicTest.cs @@ -91,7 +91,7 @@ public abstract class BasicTest return await _context.SaveChangesAsync() > 0; } - protected async Task ResetDB() + protected async Task ResetDb() { _context.Series.RemoveRange(_context.Series.ToList()); _context.Users.RemoveRange(_context.Users.ToList()); diff --git a/API.Tests/Extensions/ParserInfoListExtensionsTests.cs b/API.Tests/Extensions/ParserInfoListExtensionsTests.cs index 6a00a829b..b6a5ca362 100644 --- a/API.Tests/Extensions/ParserInfoListExtensionsTests.cs +++ b/API.Tests/Extensions/ParserInfoListExtensionsTests.cs @@ -5,6 +5,7 @@ using API.Entities.Enums; using API.Extensions; using API.Parser; using API.Services; +using API.Services.Tasks.Scanner.Parser; using API.Tests.Helpers; using Microsoft.Extensions.Logging; using NSubstitute; diff --git a/API.Tests/Parser/ComicParserTests.cs b/API.Tests/Parser/ComicParserTests.cs index 2787f0ed0..fa0448ff9 100644 --- a/API.Tests/Parser/ComicParserTests.cs +++ b/API.Tests/Parser/ComicParserTests.cs @@ -1,6 +1,7 @@ using System.IO.Abstractions.TestingHelpers; using API.Parser; using API.Services; +using API.Services.Tasks.Scanner.Parser; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; diff --git a/API.Tests/Parser/DefaultParserTests.cs b/API.Tests/Parser/DefaultParserTests.cs index f32838dd3..32768d11a 100644 --- a/API.Tests/Parser/DefaultParserTests.cs +++ b/API.Tests/Parser/DefaultParserTests.cs @@ -3,6 +3,7 @@ using System.IO.Abstractions.TestingHelpers; using API.Entities.Enums; using API.Parser; using API.Services; +using API.Services.Tasks.Scanner.Parser; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; @@ -77,6 +78,21 @@ public class DefaultParserTests Assert.Equal(expectedParseInfo, actual.Series); } + [Theory] + [InlineData("/manga/Btooom!/Specials/Art Book.cbz", "Btooom!")] + public void ParseFromFallbackFolders_ShouldUseExistingSeriesName_NewScanLoop(string inputFile, string expectedParseInfo) + { + const string rootDirectory = "/manga/"; + var fs = new MockFileSystem(); + fs.AddDirectory(rootDirectory); + fs.AddFile(inputFile, new MockFileData("")); + var ds = new DirectoryService(Substitute.For>(), fs); + var parser = new DefaultParser(ds); + var actual = parser.Parse(inputFile, rootDirectory); + _defaultParser.ParseFromFallbackFolders(inputFile, rootDirectory, LibraryType.Manga, ref actual); + Assert.Equal(expectedParseInfo, actual.Series); + } + #endregion diff --git a/API.Tests/Services/DeviceServiceTests.cs b/API.Tests/Services/DeviceServiceTests.cs index a03f1e715..717f3e98b 100644 --- a/API.Tests/Services/DeviceServiceTests.cs +++ b/API.Tests/Services/DeviceServiceTests.cs @@ -22,9 +22,10 @@ public class DeviceServiceTests : BasicTest _deviceService = new DeviceService(_unitOfWork, _logger, Substitute.For()); } - protected void ResetDB() + protected new Task ResetDb() { _context.Users.RemoveRange(_context.Users.ToList()); + return Task.CompletedTask; } diff --git a/API.Tests/Services/ParseScannedFilesTests.cs b/API.Tests/Services/ParseScannedFilesTests.cs index c019b9643..4139168f1 100644 --- a/API.Tests/Services/ParseScannedFilesTests.cs +++ b/API.Tests/Services/ParseScannedFilesTests.cs @@ -12,6 +12,7 @@ using API.Entities.Enums; using API.Parser; using API.Services; using API.Services.Tasks.Scanner; +using API.Services.Tasks.Scanner.Parser; using API.SignalR; using API.Tests.Helpers; using AutoMapper; diff --git a/API/Controllers/UploadController.cs b/API/Controllers/UploadController.cs index 28593c621..68d28e442 100644 --- a/API/Controllers/UploadController.cs +++ b/API/Controllers/UploadController.cs @@ -49,8 +49,8 @@ public class UploadController : BaseApiController [HttpPost("upload-by-url")] public async Task> GetImageFromFile(UploadUrlDto dto) { - var dateString = $"{DateTime.Now.ToShortDateString()}_{DateTime.Now.ToLongTimeString()}".Replace("/", "_").Replace(":", "_"); - var format = _directoryService.FileSystem.Path.GetExtension(dto.Url.Split('?')[0]).Replace(".", ""); + var dateString = $"{DateTime.Now.ToShortDateString()}_{DateTime.Now.ToLongTimeString()}".Replace('/', '_').Replace(':', '_'); + var format = _directoryService.FileSystem.Path.GetExtension(dto.Url.Split('?')[0]).Replace(".", string.Empty); try { var path = await dto.Url diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs index 58c3f4828..72d99e13c 100644 --- a/API/Controllers/UsersController.cs +++ b/API/Controllers/UsersController.cs @@ -102,6 +102,7 @@ public class UsersController : BaseApiController existingPreferences.Theme = await _unitOfWork.SiteThemeRepository.GetThemeById(preferencesDto.Theme.Id); existingPreferences.LayoutMode = preferencesDto.LayoutMode; existingPreferences.PromptForDownloadSize = preferencesDto.PromptForDownloadSize; + existingPreferences.NoTransitions = preferencesDto.NoTransitions; _unitOfWork.UserRepository.Update(existingPreferences); diff --git a/API/DTOs/UserPreferencesDto.cs b/API/DTOs/UserPreferencesDto.cs index 2ec3a79bb..6e5d51442 100644 --- a/API/DTOs/UserPreferencesDto.cs +++ b/API/DTOs/UserPreferencesDto.cs @@ -1,7 +1,4 @@ -using System; -using System.ComponentModel.DataAnnotations; -using API.Data; -using API.DTOs.Theme; +using System.ComponentModel.DataAnnotations; using API.Entities; using API.Entities.Enums; using API.Entities.Enums.UserPreferences; @@ -117,4 +114,9 @@ public class UserPreferencesDto /// [Required] public bool PromptForDownloadSize { get; set; } = true; + /// + /// UI Site Global Setting: Should Kavita disable CSS transitions + /// + [Required] + public bool NoTransitions { get; set; } = false; } diff --git a/API/Data/Migrations/20220926145902_AddNoTransitions.Designer.cs b/API/Data/Migrations/20220926145902_AddNoTransitions.Designer.cs new file mode 100644 index 000000000..af7f8bd07 --- /dev/null +++ b/API/Data/Migrations/20220926145902_AddNoTransitions.Designer.cs @@ -0,0 +1,1661 @@ +// +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("20220926145902_AddNoTransitions")] + partial class AddNoTransitions + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.9"); + + 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("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("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("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("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("Created") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NameLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.ClientCascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.ClientCascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany("WantToRead") + .HasForeignKey("AppUserId"); + + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Devices"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20220926145902_AddNoTransitions.cs b/API/Data/Migrations/20220926145902_AddNoTransitions.cs new file mode 100644 index 000000000..fcef3979a --- /dev/null +++ b/API/Data/Migrations/20220926145902_AddNoTransitions.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class AddNoTransitions : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "NoTransitions", + table: "AppUserPreferences", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "NoTransitions", + table: "AppUserPreferences"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 525e07bdc..be8a39c73 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -219,6 +219,9 @@ namespace API.Data.Migrations b.Property("LayoutMode") .HasColumnType("INTEGER"); + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + b.Property("PageSplitOption") .HasColumnType("INTEGER"); diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index 6dc59fea5..bfd739917 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -351,31 +351,6 @@ public class UserRepository : IUserRepository || EF.Functions.Like(o.series.NormalizedName, $"%{seriesNameQueryNormalized}%") ); - // This doesn't work on bookmarks themselves, only the series. For now, I don't think there is much value add - // if (filter.SortOptions != null) - // { - // if (filter.SortOptions.IsAscending) - // { - // filterSeriesQuery = filter.SortOptions.SortField switch - // { - // SortField.SortName => filterSeriesQuery.OrderBy(s => s.series.SortName), - // SortField.CreatedDate => filterSeriesQuery.OrderBy(s => s.bookmark.Created), - // SortField.LastModifiedDate => filterSeriesQuery.OrderBy(s => s.bookmark.LastModified), - // _ => filterSeriesQuery - // }; - // } - // else - // { - // filterSeriesQuery = filter.SortOptions.SortField switch - // { - // SortField.SortName => filterSeriesQuery.OrderByDescending(s => s.series.SortName), - // SortField.CreatedDate => filterSeriesQuery.OrderByDescending(s => s.bookmark.Created), - // SortField.LastModifiedDate => filterSeriesQuery.OrderByDescending(s => s.bookmark.LastModified), - // _ => filterSeriesQuery - // }; - // } - // } - query = filterSeriesQuery.Select(o => o.bookmark); } diff --git a/API/Entities/AppUserPreferences.cs b/API/Entities/AppUserPreferences.cs index d96736f19..f29ede382 100644 --- a/API/Entities/AppUserPreferences.cs +++ b/API/Entities/AppUserPreferences.cs @@ -102,6 +102,10 @@ public class AppUserPreferences /// UI Site Global Setting: Should Kavita prompt user to confirm downloads that are greater than 100 MB. /// public bool PromptForDownloadSize { get; set; } = true; + /// + /// UI Site Global Setting: Should Kavita disable CSS transitions + /// + public bool NoTransitions { get; set; } = false; public AppUser AppUser { get; set; } public int AppUserId { get; set; } diff --git a/API/Entities/Device.cs b/API/Entities/Device.cs index 224f96aed..e4ceabff5 100644 --- a/API/Entities/Device.cs +++ b/API/Entities/Device.cs @@ -33,9 +33,6 @@ public class Device : IEntityDate /// public DevicePlatform Platform { get; set; } - - //public ICollection SupportedExtensions { get; set; } // TODO: This requires some sort of information at mangaFile level (unless i repack) - public int AppUserId { get; set; } public AppUser AppUser { get; set; } diff --git a/API/Entities/Library.cs b/API/Entities/Library.cs index 819bf76d5..1208151f0 100644 --- a/API/Entities/Library.cs +++ b/API/Entities/Library.cs @@ -12,7 +12,7 @@ public class Library : IEntityDate public int Id { get; set; } public string Name { get; set; } /// - /// Update this summary with a way it's used, else let's remove it. + /// This is not used, but planned once we build out a Library detail page /// [Obsolete("This has never been coded for. Likely we can remove it.")] public string CoverImage { get; set; } diff --git a/API/Helpers/Filters/ETagFromFilename.cs b/API/Helpers/Filters/ETagFromFilename.cs deleted file mode 100644 index 4160aaa49..000000000 --- a/API/Helpers/Filters/ETagFromFilename.cs +++ /dev/null @@ -1,232 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Security.Cryptography; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Filters; -using Microsoft.Net.Http.Headers; -using Newtonsoft.Json; - -namespace API.Helpers.Filters; - -// NOTE: I'm leaving this in, but I don't think it's needed. Will validate in next release. - -//[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = false)] -// public class ETagFromFilename : ActionFilterAttribute, IAsyncActionFilter -// { -// public override async Task OnActionExecutionAsync(ActionExecutingContext executingContext, -// ActionExecutionDelegate next) -// { -// var request = executingContext.HttpContext.Request; -// -// var executedContext = await next(); -// var response = executedContext.HttpContext.Response; -// -// // Computing ETags for Response Caching on GET requests -// if (request.Method == HttpMethod.Get.Method && response.StatusCode == (int) HttpStatusCode.OK) -// { -// ValidateETagForResponseCaching(executedContext); -// } -// } -// -// private void ValidateETagForResponseCaching(ActionExecutedContext executedContext) -// { -// if (executedContext.Result == null) -// { -// return; -// } -// -// var request = executedContext.HttpContext.Request; -// var response = executedContext.HttpContext.Response; -// -// var objectResult = executedContext.Result as ObjectResult; -// if (objectResult == null) return; -// var result = (PhysicalFileResult) objectResult.Value; -// -// // generate ETag from LastModified property -// //var etag = GenerateEtagFromFilename(result.); -// -// // generates ETag from the entire response Content -// //var etag = GenerateEtagFromResponseBodyWithHash(result); -// -// if (request.Headers.ContainsKey(HeaderNames.IfNoneMatch)) -// { -// // fetch etag from the incoming request header -// var incomingEtag = request.Headers[HeaderNames.IfNoneMatch].ToString(); -// -// // if both the etags are equal -// // raise a 304 Not Modified Response -// if (incomingEtag.Equals(etag)) -// { -// executedContext.Result = new StatusCodeResult((int) HttpStatusCode.NotModified); -// } -// } -// -// // add ETag response header -// response.Headers.Add(HeaderNames.ETag, new[] {etag}); -// } -// - // private static string GenerateEtagFromFilename(HttpResponse response, string filename, int maxAge = 10) - // { - // if (filename is not {Length: > 0}) return string.Empty; - // var hashContent = filename + File.GetLastWriteTimeUtc(filename); - // using var sha1 = SHA256.Create(); - // return string.Concat(sha1.ComputeHash(Encoding.UTF8.GetBytes(hashContent)).Select(x => x.ToString("X2"))); - // } -// } - -[AttributeUsage(AttributeTargets.Method)] -public class ETagFilterAttribute : Attribute, IActionFilter -{ - private readonly int[] _statusCodes; - - public ETagFilterAttribute(params int[] statusCodes) - { - _statusCodes = statusCodes; - if (statusCodes.Length == 0) _statusCodes = new[] { 200 }; - } - - public void OnActionExecuting(ActionExecutingContext context) - { - /* Nothing needs to be done here */ - } - - public void OnActionExecuted(ActionExecutedContext context) - { - if (context.HttpContext.Request.Method != "GET" || context.HttpContext.Request.Method != "HEAD") return; - if (!_statusCodes.Contains(context.HttpContext.Response.StatusCode)) return; - - var etag = string.Empty; - //I just serialize the result to JSON, could do something less costly - if (context.Result is PhysicalFileResult fileResult) - { - // Do a cheap LastWriteTime etag gen - etag = ETagGenerator.GenerateEtagFromFilename(fileResult.FileName); - context.HttpContext.Response.Headers.LastModified = File.GetLastWriteTimeUtc(fileResult.FileName).ToLongDateString(); - } - - if (string.IsNullOrEmpty(etag)) - { - var content = JsonConvert.SerializeObject(context.Result); - etag = ETagGenerator.GetETag(context.HttpContext.Request.Path.ToString(), Encoding.UTF8.GetBytes(content)); - } - - - if (context.HttpContext.Request.Headers.IfNoneMatch.ToString() == etag) - { - context.Result = new StatusCodeResult(304); - } - - //context.HttpContext.Response.Headers.ETag = etag; - } - - -} - -// Helper class that generates the etag from a key (route) and content (response) -public static class ETagGenerator -{ - public static string GetETag(string key, byte[] contentBytes) - { - var keyBytes = Encoding.UTF8.GetBytes(key); - var combinedBytes = Combine(keyBytes, contentBytes); - - return GenerateETag(combinedBytes); - } - - private static string GenerateETag(byte[] data) - { - using var md5 = MD5.Create(); - var hash = md5.ComputeHash(data); - var hex = BitConverter.ToString(hash); - return hex.Replace("-", ""); - } - - private static byte[] Combine(byte[] a, byte[] b) - { - var c = new byte[a.Length + b.Length]; - Buffer.BlockCopy(a, 0, c, 0, a.Length); - Buffer.BlockCopy(b, 0, c, a.Length, b.Length); - return c; - } - - public static string GenerateEtagFromFilename(string filename) - { - if (filename is not {Length: > 0}) return string.Empty; - var hashContent = filename + File.GetLastWriteTimeUtc(filename); - using var md5 = MD5.Create(); - return string.Concat(md5.ComputeHash(Encoding.UTF8.GetBytes(hashContent)).Select(x => x.ToString("X2"))); - } -} - -// /// -// /// Enables HTTP Response CacheControl management with ETag values. -// /// -// public class ClientCacheWithEtagAttribute : ActionFilterAttribute -// { -// private readonly TimeSpan _clientCache; -// -// private readonly HttpMethod[] _supportedRequestMethods = { -// HttpMethod.Get, -// HttpMethod.Head -// }; -// -// /// -// /// Default constructor -// /// -// /// Indicates for how long the client should cache the response. The value is in seconds -// public ClientCacheWithEtagAttribute(int clientCacheInSeconds) -// { -// _clientCache = TimeSpan.FromSeconds(clientCacheInSeconds); -// } -// -// public override async Task OnActionExecutionAsync(ActionExecutingContext executingContext, ActionExecutionDelegate next) -// { -// -// if (executingContext.Response?.Content == null) -// { -// return; -// } -// -// var body = await executingContext.Response.Content.ReadAsStringAsync(); -// if (body == null) -// { -// return; -// } -// -// var computedEntityTag = GetETag(Encoding.UTF8.GetBytes(body)); -// -// if (actionExecutedContext.Request.Headers.IfNoneMatch.Any() -// && actionExecutedContext.Request.Headers.IfNoneMatch.First().Tag.Trim('"').Equals(computedEntityTag, StringComparison.InvariantCultureIgnoreCase)) -// { -// actionExecutedContext.Response.StatusCode = HttpStatusCode.NotModified; -// actionExecutedContext.Response.Content = null; -// } -// -// var cacheControlHeader = new CacheControlHeaderValue -// { -// Private = true, -// MaxAge = _clientCache -// }; -// -// actionExecutedContext.Response.Headers.ETag = new EntityTagHeaderValue($"\"{computedEntityTag}\"", false); -// actionExecutedContext.Response.Headers.CacheControl = cacheControlHeader; -// } -// -// private static string GetETag(byte[] contentBytes) -// { -// using (var md5 = MD5.Create()) -// { -// var hash = md5.ComputeHash(contentBytes); -// string hex = BitConverter.ToString(hash); -// return hex.Replace("-", ""); -// } -// } -// } - diff --git a/API/Logging/LogLevelOptions.cs b/API/Logging/LogLevelOptions.cs index e7e97268b..f5e877b79 100644 --- a/API/Logging/LogLevelOptions.cs +++ b/API/Logging/LogLevelOptions.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Configuration; using Serilog; using Serilog.Core; using Serilog.Events; +using Serilog.Formatting.Display; namespace API.Logging; @@ -39,6 +40,7 @@ public static class LogLevelOptions public static LoggerConfiguration CreateConfig(LoggerConfiguration configuration) { + const string outputTemplate = "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {CorrelationId} {ThreadId}] [{Level}] {SourceContext} {Message:lj}{NewLine}{Exception}"; return configuration .MinimumLevel .ControlledBy(LogLevelSwitch) @@ -51,11 +53,11 @@ public static class LogLevelOptions .MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Error) .Enrich.FromLogContext() .Enrich.WithThreadId() - .WriteTo.Console() + .WriteTo.Console(new MessageTemplateTextFormatter(outputTemplate)) .WriteTo.File(LogFile, shared: true, rollingInterval: RollingInterval.Day, - outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {CorrelationId} {ThreadId}] [{Level}] {SourceContext} {Message:lj}{NewLine}{Exception}"); + outputTemplate: outputTemplate); } public static void SwitchLogLevel(string level) diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index 3267218f4..728b6f8ff 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -962,7 +962,7 @@ public class BookService : IBookService } catch (Exception) { - /* Swallow exception. Some css don't have style rules ending in ; */ + //Swallow exception. Some css don't have style rules ending in ';' } body = Regex.Replace(body, @"([\s:]0)(px|pt|%|em)", "$1"); diff --git a/API/Services/EmailService.cs b/API/Services/EmailService.cs index 535ab49cc..a9a78e0ea 100644 --- a/API/Services/EmailService.cs +++ b/API/Services/EmailService.cs @@ -46,6 +46,7 @@ public class EmailService : IEmailService _unitOfWork = unitOfWork; _downloadService = downloadService; + FlurlHttp.ConfigureClient(DefaultApiUrl, cli => cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); } @@ -126,15 +127,17 @@ public class EmailService : IEmailService return await SendEmailWithFiles(emailLink + "/api/sendto", data.FilePaths, data.DestinationEmail); } - private static async Task SendEmailWithGet(string url, int timeoutSecs = 30) + private async Task SendEmailWithGet(string url, int timeoutSecs = 30) { try { + var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); var response = await (url) .WithHeader("Accept", "application/json") .WithHeader("User-Agent", "Kavita") .WithHeader("x-api-key", "MsnvA2DfQqxSK5jh") .WithHeader("x-kavita-version", BuildInfo.Version) + .WithHeader("x-kavita-installId", settings.InstallId) .WithHeader("Content-Type", "application/json") .WithTimeout(TimeSpan.FromSeconds(timeoutSecs)) .GetStringAsync(); @@ -152,15 +155,17 @@ public class EmailService : IEmailService } - private static async Task SendEmailWithPost(string url, object data, int timeoutSecs = 30) + private async Task SendEmailWithPost(string url, object data, int timeoutSecs = 30) { try { + var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); var response = await (url) .WithHeader("Accept", "application/json") .WithHeader("User-Agent", "Kavita") .WithHeader("x-api-key", "MsnvA2DfQqxSK5jh") .WithHeader("x-kavita-version", BuildInfo.Version) + .WithHeader("x-kavita-installId", settings.InstallId) .WithHeader("Content-Type", "application/json") .WithTimeout(TimeSpan.FromSeconds(timeoutSecs)) .PostJsonAsync(data); @@ -182,10 +187,12 @@ public class EmailService : IEmailService { try { + var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); var response = await (url) .WithHeader("User-Agent", "Kavita") .WithHeader("x-api-key", "MsnvA2DfQqxSK5jh") .WithHeader("x-kavita-version", BuildInfo.Version) + .WithHeader("x-kavita-installId", settings.InstallId) .WithTimeout(TimeSpan.FromSeconds(timeoutSecs)) .PostMultipartAsync(mp => { diff --git a/API/Services/ReadingItemService.cs b/API/Services/ReadingItemService.cs index 8e4676639..4c4933aed 100644 --- a/API/Services/ReadingItemService.cs +++ b/API/Services/ReadingItemService.cs @@ -2,6 +2,7 @@ using API.Data.Metadata; using API.Entities.Enums; using API.Parser; +using API.Services.Tasks.Scanner.Parser; namespace API.Services; diff --git a/API/Services/Tasks/Scanner/LibraryWatcher.cs b/API/Services/Tasks/Scanner/LibraryWatcher.cs index dd6e3cc34..6788872da 100644 --- a/API/Services/Tasks/Scanner/LibraryWatcher.cs +++ b/API/Services/Tasks/Scanner/LibraryWatcher.cs @@ -150,7 +150,7 @@ public class LibraryWatcher : ILibraryWatcher private void OnError(object sender, ErrorEventArgs e) { - _logger.LogError(e.GetException(), "[LibraryWatcher] An error occured, likely too many watches occured at once. Restarting Watchers"); + _logger.LogError(e.GetException(), "[LibraryWatcher] An error occured, likely too many changes occured at once or the folder being watched was deleted. Restarting Watchers"); Task.Run(RestartWatching); } diff --git a/API/Services/Tasks/Scanner/Parser/DefaultParser.cs b/API/Services/Tasks/Scanner/Parser/DefaultParser.cs index a92256941..f9889d74f 100644 --- a/API/Services/Tasks/Scanner/Parser/DefaultParser.cs +++ b/API/Services/Tasks/Scanner/Parser/DefaultParser.cs @@ -1,9 +1,9 @@ using System.IO; using System.Linq; using API.Entities.Enums; -using API.Services; +using API.Parser; -namespace API.Parser; +namespace API.Services.Tasks.Scanner.Parser; public interface IDefaultParser { @@ -36,81 +36,81 @@ public class DefaultParser : IDefaultParser var fileName = _directoryService.FileSystem.Path.GetFileNameWithoutExtension(filePath); ParserInfo ret; - if (Services.Tasks.Scanner.Parser.Parser.IsEpub(filePath)) + if (Parser.IsEpub(filePath)) { - ret = new ParserInfo() + ret = new ParserInfo { - Chapters = Services.Tasks.Scanner.Parser.Parser.ParseChapter(fileName) ?? Services.Tasks.Scanner.Parser.Parser.ParseComicChapter(fileName), - Series = Services.Tasks.Scanner.Parser.Parser.ParseSeries(fileName) ?? Services.Tasks.Scanner.Parser.Parser.ParseComicSeries(fileName), - Volumes = Services.Tasks.Scanner.Parser.Parser.ParseVolume(fileName) ?? Services.Tasks.Scanner.Parser.Parser.ParseComicVolume(fileName), + Chapters = Parser.ParseChapter(fileName) ?? Parser.ParseComicChapter(fileName), + Series = Parser.ParseSeries(fileName) ?? Parser.ParseComicSeries(fileName), + Volumes = Parser.ParseVolume(fileName) ?? Parser.ParseComicVolume(fileName), Filename = Path.GetFileName(filePath), - Format = Services.Tasks.Scanner.Parser.Parser.ParseFormat(filePath), + Format = Parser.ParseFormat(filePath), FullFilePath = filePath }; } else { - ret = new ParserInfo() + ret = new ParserInfo { - Chapters = type == LibraryType.Comic ? Services.Tasks.Scanner.Parser.Parser.ParseComicChapter(fileName) : Services.Tasks.Scanner.Parser.Parser.ParseChapter(fileName), - Series = type == LibraryType.Comic ? Services.Tasks.Scanner.Parser.Parser.ParseComicSeries(fileName) : Services.Tasks.Scanner.Parser.Parser.ParseSeries(fileName), - Volumes = type == LibraryType.Comic ? Services.Tasks.Scanner.Parser.Parser.ParseComicVolume(fileName) : Services.Tasks.Scanner.Parser.Parser.ParseVolume(fileName), + Chapters = type == LibraryType.Comic ? Parser.ParseComicChapter(fileName) : Parser.ParseChapter(fileName), + Series = type == LibraryType.Comic ? Parser.ParseComicSeries(fileName) : Parser.ParseSeries(fileName), + Volumes = type == LibraryType.Comic ? Parser.ParseComicVolume(fileName) : Parser.ParseVolume(fileName), Filename = Path.GetFileName(filePath), - Format = Services.Tasks.Scanner.Parser.Parser.ParseFormat(filePath), + Format = Parser.ParseFormat(filePath), Title = Path.GetFileNameWithoutExtension(fileName), FullFilePath = filePath }; } - if (Services.Tasks.Scanner.Parser.Parser.IsImage(filePath) && Services.Tasks.Scanner.Parser.Parser.IsCoverImage(filePath)) return null; + if (Parser.IsCoverImage(filePath)) return null; - if (Services.Tasks.Scanner.Parser.Parser.IsImage(filePath)) + if (Parser.IsImage(filePath)) { // Reset Chapters, Volumes, and Series as images are not good to parse information out of. Better to use folders. - ret.Volumes = Services.Tasks.Scanner.Parser.Parser.DefaultVolume; - ret.Chapters = Services.Tasks.Scanner.Parser.Parser.DefaultChapter; + ret.Volumes = Parser.DefaultVolume; + ret.Chapters = Parser.DefaultChapter; ret.Series = string.Empty; } - if (ret.Series == string.Empty || Services.Tasks.Scanner.Parser.Parser.IsImage(filePath)) + if (ret.Series == string.Empty || Parser.IsImage(filePath)) { // Try to parse information out of each folder all the way to rootPath ParseFromFallbackFolders(filePath, rootPath, type, ref ret); } - var edition = Services.Tasks.Scanner.Parser.Parser.ParseEdition(fileName); + var edition = Parser.ParseEdition(fileName); if (!string.IsNullOrEmpty(edition)) { - ret.Series = Services.Tasks.Scanner.Parser.Parser.CleanTitle(ret.Series.Replace(edition, ""), type is LibraryType.Comic); + ret.Series = Parser.CleanTitle(ret.Series.Replace(edition, ""), type is LibraryType.Comic); ret.Edition = edition; } - var isSpecial = type == LibraryType.Comic ? Services.Tasks.Scanner.Parser.Parser.IsComicSpecial(fileName) : Services.Tasks.Scanner.Parser.Parser.IsMangaSpecial(fileName); + var isSpecial = type == LibraryType.Comic ? Parser.IsComicSpecial(fileName) : Parser.IsMangaSpecial(fileName); // We must ensure that we can only parse a special out. As some files will have v20 c171-180+Omake and that // could cause a problem as Omake is a special term, but there is valid volume/chapter information. - if (ret.Chapters == Services.Tasks.Scanner.Parser.Parser.DefaultChapter && ret.Volumes == Services.Tasks.Scanner.Parser.Parser.DefaultVolume && isSpecial) + if (ret.Chapters == Parser.DefaultChapter && ret.Volumes == Parser.DefaultVolume && isSpecial) { ret.IsSpecial = true; ParseFromFallbackFolders(filePath, rootPath, type, ref ret); // NOTE: This can cause some complications, we should try to be a bit less aggressive to fallback to folder } // If we are a special with marker, we need to ensure we use the correct series name. we can do this by falling back to Folder name - if (Services.Tasks.Scanner.Parser.Parser.HasSpecialMarker(fileName)) + if (Parser.HasSpecialMarker(fileName)) { ret.IsSpecial = true; - ret.Chapters = Services.Tasks.Scanner.Parser.Parser.DefaultChapter; - ret.Volumes = Services.Tasks.Scanner.Parser.Parser.DefaultVolume; + ret.Chapters = Parser.DefaultChapter; + ret.Volumes = Parser.DefaultVolume; ParseFromFallbackFolders(filePath, rootPath, type, ref ret); } if (string.IsNullOrEmpty(ret.Series)) { - ret.Series = Services.Tasks.Scanner.Parser.Parser.CleanTitle(fileName, type is LibraryType.Comic); + ret.Series = Parser.CleanTitle(fileName, type is LibraryType.Comic); } // Pdfs may have .pdf in the series name, remove that - if (Services.Tasks.Scanner.Parser.Parser.IsPdf(filePath) && ret.Series.ToLower().EndsWith(".pdf")) + if (Parser.IsPdf(filePath) && ret.Series.ToLower().EndsWith(".pdf")) { ret.Series = ret.Series.Substring(0, ret.Series.Length - ".pdf".Length); } @@ -127,35 +127,55 @@ public class DefaultParser : IDefaultParser /// Expects a non-null ParserInfo which this method will populate public void ParseFromFallbackFolders(string filePath, string rootPath, LibraryType type, ref ParserInfo ret) { - var fallbackFolders = _directoryService.GetFoldersTillRoot(rootPath, filePath).ToList(); + var fallbackFolders = _directoryService.GetFoldersTillRoot(rootPath, filePath) + .Where(f => !Parser.IsMangaSpecial(f)) + .ToList(); + + if (fallbackFolders.Count == 0) + { + var rootFolderName = _directoryService.FileSystem.DirectoryInfo.FromDirectoryName(rootPath).Name; + var series = Parser.ParseSeries(rootFolderName); + + if (string.IsNullOrEmpty(series)) + { + ret.Series = Parser.CleanTitle(rootFolderName, type is LibraryType.Comic); + return; + } + + if (!string.IsNullOrEmpty(series) && (string.IsNullOrEmpty(ret.Series) || !rootFolderName.Contains(ret.Series))) + { + ret.Series = series; + return; + } + } + for (var i = 0; i < fallbackFolders.Count; i++) { var folder = fallbackFolders[i]; - if (Services.Tasks.Scanner.Parser.Parser.IsMangaSpecial(folder)) continue; - var parsedVolume = type is LibraryType.Manga ? Services.Tasks.Scanner.Parser.Parser.ParseVolume(folder) : Services.Tasks.Scanner.Parser.Parser.ParseComicVolume(folder); - var parsedChapter = type is LibraryType.Manga ? Services.Tasks.Scanner.Parser.Parser.ParseChapter(folder) : Services.Tasks.Scanner.Parser.Parser.ParseComicChapter(folder); + var parsedVolume = type is LibraryType.Manga ? Parser.ParseVolume(folder) : Parser.ParseComicVolume(folder); + var parsedChapter = type is LibraryType.Manga ? Parser.ParseChapter(folder) : Parser.ParseComicChapter(folder); - if (!parsedVolume.Equals(Services.Tasks.Scanner.Parser.Parser.DefaultVolume) || !parsedChapter.Equals(Services.Tasks.Scanner.Parser.Parser.DefaultChapter)) + if (!parsedVolume.Equals(Parser.DefaultVolume) || !parsedChapter.Equals(Parser.DefaultChapter)) { - if ((string.IsNullOrEmpty(ret.Volumes) || ret.Volumes.Equals(Services.Tasks.Scanner.Parser.Parser.DefaultVolume)) && !parsedVolume.Equals(Services.Tasks.Scanner.Parser.Parser.DefaultVolume)) - { - ret.Volumes = parsedVolume; - } - if ((string.IsNullOrEmpty(ret.Chapters) || ret.Chapters.Equals(Services.Tasks.Scanner.Parser.Parser.DefaultChapter)) && !parsedChapter.Equals(Services.Tasks.Scanner.Parser.Parser.DefaultChapter)) - { - ret.Chapters = parsedChapter; - } + if ((string.IsNullOrEmpty(ret.Volumes) || ret.Volumes.Equals(Parser.DefaultVolume)) && !parsedVolume.Equals(Parser.DefaultVolume)) + { + ret.Volumes = parsedVolume; + } + if ((string.IsNullOrEmpty(ret.Chapters) || ret.Chapters.Equals(Parser.DefaultChapter)) && !parsedChapter.Equals(Parser.DefaultChapter)) + { + ret.Chapters = parsedChapter; + } } // Generally users group in series folders. Let's try to parse series from the top folder if (!folder.Equals(ret.Series) && i == fallbackFolders.Count - 1) { - var series = Services.Tasks.Scanner.Parser.Parser.ParseSeries(folder); + var series = Parser.ParseSeries(folder); if (string.IsNullOrEmpty(series)) { - ret.Series = Services.Tasks.Scanner.Parser.Parser.CleanTitle(folder, type is LibraryType.Comic); + ret.Series = Parser.CleanTitle(folder, type is LibraryType.Comic); break; } diff --git a/API/Services/Tasks/Scanner/ProcessSeries.cs b/API/Services/Tasks/Scanner/ProcessSeries.cs index 2c07f1805..c13a93758 100644 --- a/API/Services/Tasks/Scanner/ProcessSeries.cs +++ b/API/Services/Tasks/Scanner/ProcessSeries.cs @@ -633,13 +633,11 @@ public class ProcessSeries : IProcessSeries void AddGenre(Genre genre) { - //chapter.Genres.Add(genre); GenreHelper.AddGenreIfNotExists(chapter.Genres, genre); } void AddTag(Tag tag, bool added) { - //chapter.Tags.Add(tag); TagHelper.AddTagIfNotExists(chapter.Tags, tag); } diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index 5b26768fc..f64e85302 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -206,8 +206,6 @@ public class ScannerService : IScannerService var scanElapsedTime = await ScanFiles(library, new []{folderPath}, false, TrackFiles, true); _logger.LogInformation("ScanFiles for {Series} took {Time}", series.Name, scanElapsedTime); - //await Task.WhenAll(processTasks); - await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.LibraryScanProgressEvent(library.Name, ProgressEventType.Ended, series.Name)); // Remove any parsedSeries keys that don't belong to our series. This can occur when users store 2 series in the same folder diff --git a/UI/Web/src/app/_models/preferences/preferences.ts b/UI/Web/src/app/_models/preferences/preferences.ts index 1fd31856d..aeb92b3bf 100644 --- a/UI/Web/src/app/_models/preferences/preferences.ts +++ b/UI/Web/src/app/_models/preferences/preferences.ts @@ -35,6 +35,7 @@ export interface Preferences { globalPageLayoutMode: PageLayoutMode; blurUnreadSummaries: boolean; promptForDownloadSize: boolean; + noTransitions: boolean; } export const readingDirections = [{text: 'Left to Right', value: ReadingDirection.LeftToRight}, {text: 'Right to Left', value: ReadingDirection.RightToLeft}]; diff --git a/UI/Web/src/app/_services/action-factory.service.ts b/UI/Web/src/app/_services/action-factory.service.ts index 1fa0072d1..e1ee9fc87 100644 --- a/UI/Web/src/app/_services/action-factory.service.ts +++ b/UI/Web/src/app/_services/action-factory.service.ts @@ -4,6 +4,7 @@ import { Chapter } from '../_models/chapter'; import { CollectionTag } from '../_models/collection-tag'; import { Device } from '../_models/device/device'; import { Library } from '../_models/library'; +import { MangaFormat } from '../_models/manga-format'; import { ReadingList } from '../_models/reading-list'; import { Series } from '../_models/series'; import { Volume } from '../_models/volume'; @@ -92,6 +93,10 @@ export interface ActionItem { callback: (action: ActionItem, data: T) => void; requiresAdmin: boolean; children: Array>; + /** + * An optional class which applies to an item. ie) danger on a delete action + */ + class?: string; /** * Indicates that there exists a separate list will be loaded from an API. * Rule: If using this, only one child should exist in children with the Action for dynamicList. @@ -168,7 +173,15 @@ export class ActionFactoryService { dummyCallback(action: ActionItem, data: any) {} - _resetActions() { + filterSendToAction(actions: Array>, chapter: Chapter) { + if (chapter.files.filter(f => f.format === MangaFormat.EPUB || f.format === MangaFormat.PDF).length !== chapter.files.length) { + // Remove Send To as it doesn't apply + return actions.filter(item => item.title !== 'Send To'); + } + return actions; + } + + private _resetActions() { this.libraryActions = [ { action: Action.Scan, @@ -226,6 +239,13 @@ export class ActionFactoryService { requiresAdmin: false, children: [], }, + { + action: Action.Scan, + title: 'Scan Series', + callback: this.dummyCallback, + requiresAdmin: false, + children: [], + }, { action: Action.Submenu, title: 'Add to', @@ -263,18 +283,22 @@ export class ActionFactoryService { ], }, { - action: Action.Scan, - title: 'Scan Series', + action: Action.Submenu, + title: 'Send To', callback: this.dummyCallback, requiresAdmin: false, - children: [], - }, - { - action: Action.Edit, - title: 'Edit', - callback: this.dummyCallback, - requiresAdmin: true, - children: [], + children: [ + { + action: Action.SendTo, + title: '', + callback: this.dummyCallback, + requiresAdmin: false, + dynamicList: this.deviceService.devices$.pipe(map((devices: Array) => devices.map(d => { + return {'title': d.name, 'data': d}; + }), shareReplay())), + children: [] + } + ], }, { action: Action.Submenu, @@ -301,10 +325,25 @@ export class ActionFactoryService { title: 'Delete', callback: this.dummyCallback, requiresAdmin: true, + class: 'danger', children: [], }, ], }, + { + action: Action.Download, + title: 'Download', + callback: this.dummyCallback, + requiresAdmin: false, + children: [], + }, + { + action: Action.Edit, + title: 'Edit', + callback: this.dummyCallback, + requiresAdmin: true, + children: [], + }, ]; this.volumeActions = [ @@ -345,15 +384,15 @@ export class ActionFactoryService { ] }, { - action: Action.Edit, - title: 'Details', + action: Action.Download, + title: 'Download', callback: this.dummyCallback, requiresAdmin: false, children: [], }, { - action: Action.Download, - title: 'Download', + action: Action.Edit, + title: 'Details', callback: this.dummyCallback, requiresAdmin: false, children: [], @@ -397,29 +436,11 @@ export class ActionFactoryService { } ] }, - { - action: Action.Edit, - title: 'Details', - callback: this.dummyCallback, - requiresAdmin: false, - children: [], - }, - // RBS will handle rendering this, so non-admins with download are appicable - { - action: Action.Download, - title: 'Download', - callback: this.dummyCallback, - requiresAdmin: false, - children: [], - }, { action: Action.Submenu, title: 'Send To', callback: this.dummyCallback, requiresAdmin: false, - // dynamicList: this.deviceService.devices$.pipe(map((devices: Array) => devices.map(d => { - // return {'title': d.name, 'data': d}; - // }), shareReplay())), children: [ { action: Action.SendTo, @@ -433,6 +454,21 @@ export class ActionFactoryService { } ], }, + // RBS will handle rendering this, so non-admins with download are appicable + { + action: Action.Download, + title: 'Download', + callback: this.dummyCallback, + requiresAdmin: false, + children: [], + }, + { + action: Action.Edit, + title: 'Details', + callback: this.dummyCallback, + requiresAdmin: false, + children: [], + }, ]; this.readingListActions = [ @@ -448,6 +484,7 @@ export class ActionFactoryService { title: 'Delete', callback: this.dummyCallback, requiresAdmin: false, + class: 'danger', children: [], }, ]; @@ -471,6 +508,7 @@ export class ActionFactoryService { action: Action.Delete, title: 'Clear', callback: this.dummyCallback, + class: 'danger', requiresAdmin: false, children: [], }, @@ -494,4 +532,5 @@ export class ActionFactoryService { actions.forEach((action) => this.applyCallback(action, callback)); return actions; } + } diff --git a/UI/Web/src/app/_services/theme.service.ts b/UI/Web/src/app/_services/theme.service.ts index 18e3764b9..fb2bc61c4 100644 --- a/UI/Web/src/app/_services/theme.service.ts +++ b/UI/Web/src/app/_services/theme.service.ts @@ -3,11 +3,12 @@ import { HttpClient } from '@angular/common/http'; import { Inject, Injectable, OnDestroy, Renderer2, RendererFactory2, SecurityContext } from '@angular/core'; import { DomSanitizer } from '@angular/platform-browser'; import { ToastrService } from 'ngx-toastr'; -import { map, ReplaySubject, Subject, takeUntil, take } from 'rxjs'; +import { map, ReplaySubject, Subject, takeUntil, take, distinctUntilChanged, Observable } from 'rxjs'; import { environment } from 'src/environments/environment'; import { ConfirmService } from '../shared/confirm.service'; import { NotificationProgressEvent } from '../_models/events/notification-progress-event'; import { SiteTheme, ThemeProvider } from '../_models/preferences/site-theme'; +import { AccountService } from './account.service'; import { EVENTS, MessageHubService } from './message-hub.service'; @@ -24,7 +25,7 @@ export class ThemeService implements OnDestroy { private themesSource = new ReplaySubject(1); public themes$ = this.themesSource.asObservable(); - + /** * Maintain a cache of themes. SignalR will inform us if we need to refresh cache */ diff --git a/UI/Web/src/app/admin/edit-user/edit-user.component.html b/UI/Web/src/app/admin/edit-user/edit-user.component.html index 9f4039b16..433c200a5 100644 --- a/UI/Web/src/app/admin/edit-user/edit-user.component.html +++ b/UI/Web/src/app/admin/edit-user/edit-user.component.html @@ -11,7 +11,7 @@
- +
This field is required @@ -23,7 +23,7 @@
-
+
This field is required
diff --git a/UI/Web/src/app/admin/invite-user/invite-user.component.html b/UI/Web/src/app/admin/invite-user/invite-user.component.html index 64d97438e..620af2e67 100644 --- a/UI/Web/src/app/admin/invite-user/invite-user.component.html +++ b/UI/Web/src/app/admin/invite-user/invite-user.component.html @@ -14,7 +14,7 @@
- +
This field is required diff --git a/UI/Web/src/app/admin/manage-settings/manage-settings.component.html b/UI/Web/src/app/admin/manage-settings/manage-settings.component.html index e9d0309c3..dccaea5d8 100644 --- a/UI/Web/src/app/admin/manage-settings/manage-settings.component.html +++ b/UI/Web/src/app/admin/manage-settings/manage-settings.component.html @@ -32,7 +32,9 @@   The number of backups to maintain. Default is 30, minumum is 1, maximum is 30. The number of backups to maintain. Default is 30, minumum is 1, maximum is 30. - +

You must have at least 1 backup @@ -50,7 +52,9 @@   The number of logs to maintain. Default is 30, minumum is 1, maximum is 30. The number of backups to maintain. Default is 30, minumum is 1, maximum is 30. - +

You must have at least 1 log @@ -68,7 +72,8 @@   Use debug to help identify issues. Debug can eat up a lot of disk space. Port the server listens on. -

diff --git a/UI/Web/src/app/app.component.html b/UI/Web/src/app/app.component.html index c26ea6283..cb589925c 100644 --- a/UI/Web/src/app/app.component.html +++ b/UI/Web/src/app/app.component.html @@ -1,16 +1,17 @@ - -
- - -
-
-
- +
+ +
+ + +
+
+
+ +
+ + +
- - -
-
diff --git a/UI/Web/src/app/app.component.ts b/UI/Web/src/app/app.component.ts index 0b6136ac2..378271e56 100644 --- a/UI/Web/src/app/app.component.ts +++ b/UI/Web/src/app/app.component.ts @@ -1,6 +1,6 @@ import { Component, HostListener, Inject, OnInit } from '@angular/core'; import { NavigationStart, Router } from '@angular/router'; -import { take } from 'rxjs/operators'; +import { distinctUntilChanged, map, take } from 'rxjs/operators'; import { AccountService } from './_services/account.service'; import { LibraryService } from './_services/library.service'; import { MessageHubService } from './_services/message-hub.service'; @@ -9,6 +9,7 @@ import { filter } from 'rxjs/operators'; import { NgbModal, NgbRatingConfig } from '@ng-bootstrap/ng-bootstrap'; import { DOCUMENT } from '@angular/common'; import { DeviceService } from './_services/device.service'; +import { Observable } from 'rxjs'; @Component({ selector: 'app-root', @@ -17,6 +18,8 @@ import { DeviceService } from './_services/device.service'; }) export class AppComponent implements OnInit { + transitionState$!: Observable; + constructor(private accountService: AccountService, public navService: NavService, private messageHub: MessageHubService, private libraryService: LibraryService, router: Router, private ngbModal: NgbModal, ratingConfig: NgbRatingConfig, @@ -35,6 +38,10 @@ export class AppComponent implements OnInit { } }); + this.transitionState$ = this.accountService.currentUser$.pipe(map((user) => { + if (!user) return false; + return user.preferences.noTransitions; + })); } @HostListener('window:resize', ['$event']) diff --git a/UI/Web/src/app/app.module.ts b/UI/Web/src/app/app.module.ts index 02c6c85ee..015b4c062 100644 --- a/UI/Web/src/app/app.module.ts +++ b/UI/Web/src/app/app.module.ts @@ -15,6 +15,7 @@ import { NavModule } from './nav/nav.module'; import { DevicesComponent } from './devices/devices.component'; + // Disable Web Animations if the user's browser (such as iOS 12.5.5) does not support this. const disableAnimations = !('animate' in document.documentElement); if (disableAnimations) console.error("Web Animations have been disabled as your current browser does not support this."); diff --git a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html index cb60bfb40..a2f97b5ef 100644 --- a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html +++ b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html @@ -16,9 +16,13 @@
-
- - +
+ + +
+ This field is required +
+
@@ -26,9 +30,15 @@
-
+
+ +
+ This field is required +
+
@@ -418,7 +428,7 @@
diff --git a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts index 177ad9293..76f053fe1 100644 --- a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts +++ b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts @@ -1,5 +1,5 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnDestroy, OnInit } from '@angular/core'; -import { FormBuilder, FormControl, FormGroup } from '@angular/forms'; +import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { forkJoin, Observable, of, Subject } from 'rxjs'; import { map, takeUntil } from 'rxjs/operators'; @@ -126,9 +126,9 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { this.editSeriesForm = this.fb.group({ id: new FormControl(this.series.id, []), summary: new FormControl('', []), - name: new FormControl(this.series.name, []), + name: new FormControl(this.series.name, [Validators.required]), localizedName: new FormControl(this.series.localizedName, []), - sortName: new FormControl(this.series.sortName, []), + sortName: new FormControl(this.series.sortName, [Validators.required]), rating: new FormControl(this.series.userRating, []), coverImageIndex: new FormControl(0, []), @@ -209,6 +209,12 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { this.seriesVolumes = volumes; this.isLoadingVolumes = false; + if (this.seriesVolumes.length === 1) { + this.imageUrls.push(...this.seriesVolumes[0].chapters.map((c: Chapter) => this.imageService.getChapterCoverImage(c.id))); + } else { + this.imageUrls.push(...this.seriesVolumes.map(v => this.imageService.getVolumeCoverImage(v.id))); + } + volumes.forEach(v => { this.volumeCollapsed[v.name] = true; }); diff --git a/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.ts b/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.ts index caf3c238b..ca0eb01fd 100644 --- a/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.ts +++ b/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.ts @@ -134,6 +134,12 @@ export class CardDetailDrawerComponent implements OnInit, OnDestroy { this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this)) .filter(item => item.action !== Action.Edit); this.chapterActions.push({title: 'Read', action: Action.Read, callback: this.handleChapterActionCallback.bind(this), requiresAdmin: false, children: []}); + if (this.isChapter) { + const chapter = this.utilityService.asChapter(this.data); + this.chapterActions = this.actionFactoryService.filterSendToAction(this.chapterActions, chapter); + } else { + this.chapterActions = this.actionFactoryService.filterSendToAction(this.chapterActions, this.chapters[0]); + } this.libraryService.getLibraryType(this.libraryId).subscribe(type => { this.libraryType = type; diff --git a/UI/Web/src/app/cards/card-item/card-actionables/card-actionables.component.html b/UI/Web/src/app/cards/card-item/card-actionables/card-actionables.component.html index 86d573326..33c2be6c1 100644 --- a/UI/Web/src/app/cards/card-item/card-actionables/card-actionables.component.html +++ b/UI/Web/src/app/cards/card-item/card-actionables/card-actionables.component.html @@ -10,7 +10,7 @@ - + @@ -23,7 +23,7 @@ -
+
diff --git a/UI/Web/src/app/cards/card-item/card-actionables/card-actionables.component.ts b/UI/Web/src/app/cards/card-item/card-actionables/card-actionables.component.ts index df54b42d3..846399bd6 100644 --- a/UI/Web/src/app/cards/card-item/card-actionables/card-actionables.component.ts +++ b/UI/Web/src/app/cards/card-item/card-actionables/card-actionables.component.ts @@ -19,6 +19,7 @@ export class CardActionablesComponent implements OnInit { @Input() disabled: boolean = false; @Output() actionHandler = new EventEmitter>(); + isAdmin: boolean = false; canDownload: boolean = false; submenu: {[key: string]: NgbDropdown} = {}; @@ -74,10 +75,4 @@ export class CardActionablesComponent implements OnInit { action._extra = dynamicItem; this.performAction(event, action); } - - toDList(d: any) { - console.log('d: ', d); - if (d === undefined || d === null) return []; - return d as {title: string, data: any}[]; - } } diff --git a/UI/Web/src/app/cards/card-item/card-item.component.ts b/UI/Web/src/app/cards/card-item/card-item.component.ts index 3f5248578..0a4b8d910 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.ts +++ b/UI/Web/src/app/cards/card-item/card-item.component.ts @@ -13,7 +13,7 @@ import { Series } from 'src/app/_models/series'; import { User } from 'src/app/_models/user'; import { Volume } from 'src/app/_models/volume'; import { AccountService } from 'src/app/_services/account.service'; -import { Action, ActionItem } from 'src/app/_services/action-factory.service'; +import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/action-factory.service'; import { ImageService } from 'src/app/_services/image.service'; import { LibraryService } from 'src/app/_services/library.service'; import { EVENTS, MessageHubService } from 'src/app/_services/message-hub.service'; @@ -126,9 +126,11 @@ export class CardItemComponent implements OnInit, OnDestroy { public utilityService: UtilityService, private downloadService: DownloadService, public bulkSelectionService: BulkSelectionService, private messageHub: MessageHubService, private accountService: AccountService, - private scrollService: ScrollService, private readonly cdRef: ChangeDetectorRef) {} + private scrollService: ScrollService, private readonly cdRef: ChangeDetectorRef, + private actionFactoryService: ActionFactoryService) {} ngOnInit(): void { + if (this.entity.hasOwnProperty('promoted') && this.entity.hasOwnProperty('title')) { this.suppressArchiveWarning = true; this.cdRef.markForCheck(); @@ -172,6 +174,8 @@ export class CardItemComponent implements OnInit, OnDestroy { } else if (this.utilityService.isSeries(this.entity)) { this.tooltipTitle = this.title || (this.utilityService.asSeries(this.entity).name); } + + this.filterSendTo(); this.accountService.currentUser$.pipe(takeUntil(this.onDestroy)).subscribe(user => { this.user = user; }); @@ -192,26 +196,10 @@ export class CardItemComponent implements OnInit, OnDestroy { chapter.pagesRead = updateEvent.pagesRead; } } else { - // Ignore return; - // re-request progress for the series - // const s = this.utilityService.asSeries(this.entity); - // let pagesRead = 0; - // if (s.hasOwnProperty('volumes')) { - // s.volumes.forEach(v => { - // v.chapters.forEach(c => { - // if (c.id === updateEvent.chapterId) { - // c.pagesRead = updateEvent.pagesRead; - // } - // pagesRead += c.pagesRead; - // }); - // }); - // s.pagesRead = pagesRead; - // } } } - this.read = updateEvent.pagesRead; this.cdRef.detectChanges(); }); @@ -312,4 +300,20 @@ export class CardItemComponent implements OnInit, OnDestroy { this.selection.emit(this.selected); this.cdRef.detectChanges(); } + + filterSendTo() { + if (!this.actions || this.actions.length === 0) return; + + if (this.utilityService.isChapter(this.entity)) { + this.actions = this.actionFactoryService.filterSendToAction(this.actions, this.entity as Chapter); + } else if (this.utilityService.isVolume(this.entity)) { + const vol = this.utilityService.asVolume(this.entity); + this.actions = this.actionFactoryService.filterSendToAction(this.actions, vol.chapters[0]); + } else if (this.utilityService.isSeries(this.entity)) { + const series = (this.entity as Series); + if (series.format === MangaFormat.EPUB || series.format === MangaFormat.PDF) { + this.actions = this.actions.filter(a => a.title !== 'Send To'); + } + } + } } diff --git a/UI/Web/src/app/cards/cards.module.ts b/UI/Web/src/app/cards/cards.module.ts index 4b86e1fbb..f46500406 100644 --- a/UI/Web/src/app/cards/cards.module.ts +++ b/UI/Web/src/app/cards/cards.module.ts @@ -26,6 +26,7 @@ import { ListItemComponent } from './list-item/list-item.component'; import { VirtualScrollerModule } from '@iharbeck/ngx-virtual-scroller'; import { SeriesInfoCardsComponent } from './series-info-cards/series-info-cards.component'; import { DownloadIndicatorComponent } from './download-indicator/download-indicator.component'; +import { DynamicListPipe } from './dynamic-list.pipe'; @@ -48,6 +49,7 @@ import { DownloadIndicatorComponent } from './download-indicator/download-indica ListItemComponent, SeriesInfoCardsComponent, DownloadIndicatorComponent, + DynamicListPipe, ], imports: [ CommonModule, diff --git a/UI/Web/src/app/cards/cover-image-chooser/cover-image-chooser.component.ts b/UI/Web/src/app/cards/cover-image-chooser/cover-image-chooser.component.ts index 2b3998b21..cba92e33e 100644 --- a/UI/Web/src/app/cards/cover-image-chooser/cover-image-chooser.component.ts +++ b/UI/Web/src/app/cards/cover-image-chooser/cover-image-chooser.component.ts @@ -71,6 +71,11 @@ export class CoverImageChooserComponent implements OnInit, OnDestroy { this.form = this.fb.group({ coverImageUrl: new FormControl('', []) }); + + this.imageUrls.forEach(url => { + + }); + console.log('imageUrls: ', this.imageUrls); this.cdRef.markForCheck(); } @@ -79,6 +84,11 @@ export class CoverImageChooserComponent implements OnInit, OnDestroy { this.onDestroy.complete(); } + /** + * Generates a base64 encoding for an Image. Used in manual file upload flow. + * @param img + * @returns + */ getBase64Image(img: HTMLImageElement) { const canvas = document.createElement("canvas"); canvas.width = img.width; @@ -95,6 +105,25 @@ export class CoverImageChooserComponent implements OnInit, OnDestroy { selectImage(index: number) { if (this.selectedIndex === index) { return; } + + // If we load custom images of series/chapters/covers, then those urls are not properly encoded, so on select we have to clean them up + if (!this.imageUrls[index].startsWith('data:image/')) { + const imgUrl = this.imageUrls[index]; + const img = new Image(); + img.crossOrigin = 'Anonymous'; + img.src = imgUrl; + img.onload = (e) => this.handleUrlImageAdd(img, index); + img.onerror = (e) => { + this.toastr.error('The image could not be fetched due to server refusing request. Please download and upload from file instead.'); + this.form.get('coverImageUrl')?.setValue(''); + this.cdRef.markForCheck(); + }; + this.form.get('coverImageUrl')?.setValue(''); + this.cdRef.markForCheck(); + this.selectedBase64Url.emit(this.imageUrls[this.selectedIndex]); + return; + } + this.selectedIndex = index; this.cdRef.markForCheck(); this.imageSelected.emit(this.selectedIndex); @@ -115,9 +144,9 @@ export class CoverImageChooserComponent implements OnInit, OnDestroy { } } - loadImage() { - const url = this.form.get('coverImageUrl')?.value.trim(); - if (!url && url === '') return; + loadImage(url?: string) { + url = url || this.form.get('coverImageUrl')?.value.trim(); + if (!url || url === '') return; this.uploadService.uploadByUrl(url).subscribe(filename => { const img = new Image(); @@ -134,6 +163,8 @@ export class CoverImageChooserComponent implements OnInit, OnDestroy { }); } + + changeMode(mode: 'url') { this.mode = mode; this.setupEnterHandler(); @@ -161,7 +192,7 @@ export class CoverImageChooserComponent implements OnInit, OnDestroy { handleFileImageAdd(e: any) { if (e.target == null) return; - this.imageUrls.push(e.target.result); + this.imageUrls.push(e.target.result); // This is base64 already this.imageUrlsChange.emit(this.imageUrls); this.selectedIndex += 1; this.imageSelected.emit(this.selectedIndex); // Auto select newly uploaded image @@ -169,9 +200,14 @@ export class CoverImageChooserComponent implements OnInit, OnDestroy { this.cdRef.markForCheck(); } - handleUrlImageAdd(img: HTMLImageElement) { + handleUrlImageAdd(img: HTMLImageElement, index: number = -1) { const url = this.getBase64Image(img); - this.imageUrls.push(url); + if (index >= 0) { + this.imageUrls[index] = url; + } else { + this.imageUrls.push(url); + } + this.imageUrlsChange.emit(this.imageUrls); this.cdRef.markForCheck(); diff --git a/UI/Web/src/app/cards/dynamic-list.pipe.ts b/UI/Web/src/app/cards/dynamic-list.pipe.ts new file mode 100644 index 000000000..4993f10ce --- /dev/null +++ b/UI/Web/src/app/cards/dynamic-list.pipe.ts @@ -0,0 +1,14 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'dynamicList', + pure: true +}) +export class DynamicListPipe implements PipeTransform { + + transform(value: any): Array<{title: string, data: any}> { + if (value === undefined || value === null) return []; + return value as {title: string, data: any}[]; + } + +} diff --git a/UI/Web/src/app/manga-reader/manga-reader.component.html b/UI/Web/src/app/manga-reader/manga-reader.component.html index bbecf422d..d1d6b221d 100644 --- a/UI/Web/src/app/manga-reader/manga-reader.component.html +++ b/UI/Web/src/app/manga-reader/manga-reader.component.html @@ -14,11 +14,11 @@
- - - + +
@@ -108,8 +108,8 @@
- - + +
diff --git a/UI/Web/src/app/manga-reader/manga-reader.component.ts b/UI/Web/src/app/manga-reader/manga-reader.component.ts index 23b6efa62..ad00b0d35 100644 --- a/UI/Web/src/app/manga-reader/manga-reader.component.ts +++ b/UI/Web/src/app/manga-reader/manga-reader.component.ts @@ -1526,6 +1526,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { event.stopPropagation(); event.preventDefault(); } + if (this.bookmarkMode) return; + const pageNum = this.pageNum; const isDouble = this.layoutMode === LayoutMode.Double || this.layoutMode === LayoutMode.DoubleReversed; diff --git a/UI/Web/src/app/metadata-filter/metadata-filter.component.html b/UI/Web/src/app/metadata-filter/metadata-filter.component.html index f0b97d9ef..bc199c1a6 100644 --- a/UI/Web/src/app/metadata-filter/metadata-filter.component.html +++ b/UI/Web/src/app/metadata-filter/metadata-filter.component.html @@ -331,7 +331,7 @@
-
+
diff --git a/UI/Web/src/app/nav/nav-header/nav-header.component.ts b/UI/Web/src/app/nav/nav-header/nav-header.component.ts index 28a293151..aeb8e9466 100644 --- a/UI/Web/src/app/nav/nav-header/nav-header.component.ts +++ b/UI/Web/src/app/nav/nav-header/nav-header.component.ts @@ -1,8 +1,8 @@ import { DOCUMENT } from '@angular/common'; -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, HostListener, Inject, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, Inject, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { NavigationEnd, Router } from '@angular/router'; import { fromEvent, Subject } from 'rxjs'; -import { debounceTime, distinctUntilChanged, filter, takeUntil, takeWhile, tap } from 'rxjs/operators'; +import { debounceTime, distinctUntilChanged, filter, takeUntil, tap } from 'rxjs/operators'; import { Chapter } from 'src/app/_models/chapter'; import { MangaFile } from 'src/app/_models/manga-file'; import { ScrollService } from 'src/app/_services/scroll.service'; @@ -68,6 +68,13 @@ export class NavHeaderComponent implements OnInit, OnDestroy { fromEvent(elem.nativeElement, 'scroll').pipe(debounceTime(20)).subscribe(() => this.checkBackToTopNeeded(elem.nativeElement)); } })).subscribe(); + + // Sometimes the top event emitter can be slow, so let's also check when a navigation occurs and recalculate + this.router.events + .pipe(filter(event => event instanceof NavigationEnd)) + .subscribe(() => { + this.checkBackToTopNeeded(this.scrollElem); + }); } checkBackToTopNeeded(elem: HTMLElement) { diff --git a/UI/Web/src/app/pipe/pipe.module.ts b/UI/Web/src/app/pipe/pipe.module.ts index 1ee60c0bc..979545283 100644 --- a/UI/Web/src/app/pipe/pipe.module.ts +++ b/UI/Web/src/app/pipe/pipe.module.ts @@ -34,7 +34,7 @@ import { DefaultDatePipe } from './default-date.pipe'; MangaFormatIconPipe, LibraryTypePipe, SafeStylePipe, - DefaultDatePipe + DefaultDatePipe, ], imports: [ CommonModule, @@ -54,7 +54,7 @@ import { DefaultDatePipe } from './default-date.pipe'; MangaFormatIconPipe, LibraryTypePipe, SafeStylePipe, - DefaultDatePipe + DefaultDatePipe, ] }) export class PipeModule { } diff --git a/UI/Web/src/app/registration/add-email-to-account-migration-modal/add-email-to-account-migration-modal.component.html b/UI/Web/src/app/registration/add-email-to-account-migration-modal/add-email-to-account-migration-modal.component.html index cc38eb503..948716c53 100644 --- a/UI/Web/src/app/registration/add-email-to-account-migration-modal/add-email-to-account-migration-modal.component.html +++ b/UI/Web/src/app/registration/add-email-to-account-migration-modal/add-email-to-account-migration-modal.component.html @@ -14,7 +14,7 @@
- +
This field is required @@ -24,7 +24,7 @@
- +
This field is required @@ -37,11 +37,18 @@
- +
This field is required
+
+ This field must be at least {{registerForm.get('password')?.errors?.minlength.requiredLength}} characters +
+
+ This field must be no more than {{registerForm.get('password')?.errors?.maxlength.requiredLength}} characters +
diff --git a/UI/Web/src/app/registration/add-email-to-account-migration-modal/add-email-to-account-migration-modal.component.ts b/UI/Web/src/app/registration/add-email-to-account-migration-modal/add-email-to-account-migration-modal.component.ts index ca0292424..ac6fa0a5a 100644 --- a/UI/Web/src/app/registration/add-email-to-account-migration-modal/add-email-to-account-migration-modal.component.ts +++ b/UI/Web/src/app/registration/add-email-to-account-migration-modal/add-email-to-account-migration-modal.component.ts @@ -29,7 +29,7 @@ export class AddEmailToAccountMigrationModalComponent implements OnInit { ngOnInit(): void { this.registerForm.addControl('username', new FormControl(this.username, [Validators.required])); this.registerForm.addControl('email', new FormControl('', [Validators.required, Validators.email])); - this.registerForm.addControl('password', new FormControl(this.password, [Validators.required])); + this.registerForm.addControl('password', new FormControl(this.password, [Validators.required, Validators.minLength(6), Validators.maxLength(32)])); this.cdRef.markForCheck(); } diff --git a/UI/Web/src/app/registration/confirm-email/confirm-email.component.html b/UI/Web/src/app/registration/confirm-email/confirm-email.component.html index 635756b35..fc56639d9 100644 --- a/UI/Web/src/app/registration/confirm-email/confirm-email.component.html +++ b/UI/Web/src/app/registration/confirm-email/confirm-email.component.html @@ -12,7 +12,8 @@
- +
This field is required @@ -22,7 +23,8 @@
- +
This field is required diff --git a/UI/Web/src/app/registration/register/register.component.html b/UI/Web/src/app/registration/register/register.component.html index 2e1817df6..302e0444f 100644 --- a/UI/Web/src/app/registration/register/register.component.html +++ b/UI/Web/src/app/registration/register/register.component.html @@ -5,7 +5,8 @@
- +
This field is required @@ -19,7 +20,8 @@ - +
This field is required @@ -36,7 +38,8 @@ Password must be between 6 and 32 characters in length - +
This field is required diff --git a/UI/Web/src/app/registration/reset-password/reset-password.component.html b/UI/Web/src/app/registration/reset-password/reset-password.component.html index 5d8a34494..2ef13e63e 100644 --- a/UI/Web/src/app/registration/reset-password/reset-password.component.html +++ b/UI/Web/src/app/registration/reset-password/reset-password.component.html @@ -5,7 +5,7 @@
- +
This field is required diff --git a/UI/Web/src/app/series-detail/series-detail.component.html b/UI/Web/src/app/series-detail/series-detail.component.html index 838b88d11..f103dab52 100644 --- a/UI/Web/src/app/series-detail/series-detail.component.html +++ b/UI/Web/src/app/series-detail/series-detail.component.html @@ -58,11 +58,11 @@
-
+
-
+
@@ -287,7 +299,8 @@
- +
This field is required @@ -297,7 +310,8 @@
- +
This field is required @@ -306,7 +320,8 @@
- +
Passwords must match diff --git a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts index 7dfe2ffd8..49ca54f74 100644 --- a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts +++ b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts @@ -134,6 +134,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy { this.settingsForm.addControl('globalPageLayoutMode', new FormControl(this.user.preferences.globalPageLayoutMode, [])); this.settingsForm.addControl('blurUnreadSummaries', new FormControl(this.user.preferences.blurUnreadSummaries, [])); this.settingsForm.addControl('promptForDownloadSize', new FormControl(this.user.preferences.promptForDownloadSize, [])); + this.settingsForm.addControl('noTransitions', new FormControl(this.user.preferences.noTransitions, [])); this.cdRef.markForCheck(); }); @@ -188,6 +189,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy { this.settingsForm.get('globalPageLayoutMode')?.setValue(this.user.preferences.globalPageLayoutMode); this.settingsForm.get('blurUnreadSummaries')?.setValue(this.user.preferences.blurUnreadSummaries); this.settingsForm.get('promptForDownloadSize')?.setValue(this.user.preferences.promptForDownloadSize); + this.settingsForm.get('noTransitions')?.setValue(this.user.preferences.noTransitions); this.cdRef.markForCheck(); this.settingsForm.markAsPristine(); } @@ -225,6 +227,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy { globalPageLayoutMode: parseInt(modelSettings.globalPageLayoutMode, 10), blurUnreadSummaries: modelSettings.blurUnreadSummaries, promptForDownloadSize: modelSettings.promptForDownloadSize, + noTransitions: modelSettings.noTransitions, }; this.observableHandles.push(this.accountService.updatePreferences(data).subscribe((updatedPrefs) => { diff --git a/UI/Web/src/theme/utilities/_global.scss b/UI/Web/src/theme/utilities/_global.scss index 012bd1b20..90e221ef2 100644 --- a/UI/Web/src/theme/utilities/_global.scss +++ b/UI/Web/src/theme/utilities/_global.scss @@ -10,6 +10,10 @@ body { overflow-y: auto; } +.no-transitions, .no-transitions * { + transition: none !important; +} + hr { background-color: var(--hr-color);