diff --git a/API.Tests/Repository/SeriesRepositoryTests.cs b/API.Tests/Repository/SeriesRepositoryTests.cs index afb46bb0b..97c5eba45 100644 --- a/API.Tests/Repository/SeriesRepositoryTests.cs +++ b/API.Tests/Repository/SeriesRepositoryTests.cs @@ -17,109 +17,19 @@ using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; +using Xunit.Abstractions; namespace API.Tests.Repository; #nullable enable -public class SeriesRepositoryTests +public class SeriesRepositoryTests(ITestOutputHelper testOutputHelper): AbstractDbTest(testOutputHelper) { - private readonly IUnitOfWork _unitOfWork; - private readonly DbConnection? _connection; - private readonly DataContext _context; - - private const string CacheDirectory = "C:/kavita/config/cache/"; - private const string CoverImageDirectory = "C:/kavita/config/covers/"; - private const string BackupDirectory = "C:/kavita/config/backups/"; - private const string DataDirectory = "C:/data/"; - - public SeriesRepositoryTests() - { - var contextOptions = new DbContextOptionsBuilder().UseSqlite(CreateInMemoryDatabase()).Options; - _connection = RelationalOptionsExtension.Extract(contextOptions).Connection; - - _context = new DataContext(contextOptions); - Task.Run(SeedDb).GetAwaiter().GetResult(); - - var config = new MapperConfiguration(cfg => cfg.AddProfile()); - var mapper = config.CreateMapper(); - _unitOfWork = new UnitOfWork(_context, mapper, null!); - } - - #region Setup - - private static DbConnection CreateInMemoryDatabase() - { - var connection = new SqliteConnection("Filename=:memory:"); - - connection.Open(); - - return connection; - } - - private async Task SeedDb() - { - await _context.Database.MigrateAsync(); - var filesystem = CreateFileSystem(); - - await Seed.SeedSettings(_context, - new DirectoryService(Substitute.For>(), filesystem)); - - var setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.CacheDirectory).SingleAsync(); - setting.Value = CacheDirectory; - - setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BackupDirectory).SingleAsync(); - setting.Value = BackupDirectory; - - _context.ServerSetting.Update(setting); - - var lib = new LibraryBuilder("Manga") - .WithFolderPath(new FolderPathBuilder("C:/data/").Build()) - .Build(); - - _context.AppUser.Add(new AppUser() - { - UserName = "majora2007", - Libraries = new List() - { - lib - } - }); - - return await _context.SaveChangesAsync() > 0; - } - - private async Task ResetDb() - { - _context.Series.RemoveRange(_context.Series.ToList()); - _context.AppUserRating.RemoveRange(_context.AppUserRating.ToList()); - _context.Genre.RemoveRange(_context.Genre.ToList()); - _context.CollectionTag.RemoveRange(_context.CollectionTag.ToList()); - _context.Person.RemoveRange(_context.Person.ToList()); - - await _context.SaveChangesAsync(); - } - - private static MockFileSystem CreateFileSystem() - { - var fileSystem = new MockFileSystem(); - fileSystem.Directory.SetCurrentDirectory("C:/kavita/"); - fileSystem.AddDirectory("C:/kavita/config/"); - fileSystem.AddDirectory(CacheDirectory); - fileSystem.AddDirectory(CoverImageDirectory); - fileSystem.AddDirectory(BackupDirectory); - fileSystem.AddDirectory(DataDirectory); - - return fileSystem; - } - - #endregion - - private async Task SetupSeriesData() + private async Task SetupSeriesData(IUnitOfWork unitOfWork) { var library = new LibraryBuilder("GetFullSeriesByAnyName Manga", LibraryType.Manga) - .WithFolderPath(new FolderPathBuilder("C:/data/manga/").Build()) + .WithFolderPath(new FolderPathBuilder(DataDirectory+"manga/").Build()) .WithSeries(new SeriesBuilder("The Idaten Deities Know Only Peace") .WithLocalizedName("Heion Sedai no Idaten-tachi") .WithFormat(MangaFormat.Archive) @@ -130,8 +40,8 @@ public class SeriesRepositoryTests .Build()) .Build(); - _unitOfWork.LibraryRepository.Add(library); - await _unitOfWork.CommitAsync(); + unitOfWork.LibraryRepository.Add(library); + await unitOfWork.CommitAsync(); } @@ -142,11 +52,11 @@ public class SeriesRepositoryTests [InlineData("Hitomi-chan wa Hitomishiri", MangaFormat.Archive, "", "Hitomi-chan is Shy With Strangers")] public async Task GetFullSeriesByAnyName_Should(string seriesName, MangaFormat format, string localizedName, string? expected) { - await ResetDb(); - await SetupSeriesData(); + var (unitOfWork, _, _) = await CreateDatabase(); + await SetupSeriesData(unitOfWork); var series = - await _unitOfWork.SeriesRepository.GetFullSeriesByAnyName(seriesName, localizedName, + await unitOfWork.SeriesRepository.GetFullSeriesByAnyName(seriesName, localizedName, 2, format, false); if (expected == null) { @@ -165,8 +75,8 @@ public class SeriesRepositoryTests [InlineData(0, "", null)] // Case 3: Return null if neither exist public async Task GetPlusSeriesDto_Should_PrioritizeAniListId_Correctly(int externalAniListId, string? webLinks, int? expectedAniListId) { - // Arrange - await ResetDb(); + var (unitOfWork, _, _) = await CreateDatabase(); + await SetupSeriesData(unitOfWork); var series = new SeriesBuilder("Test Series") .WithFormat(MangaFormat.Archive) @@ -195,12 +105,12 @@ public class SeriesRepositoryTests ReleaseYear = 2021 }; - _unitOfWork.LibraryRepository.Add(library); - _unitOfWork.SeriesRepository.Add(series); - await _unitOfWork.CommitAsync(); + unitOfWork.LibraryRepository.Add(library); + unitOfWork.SeriesRepository.Add(series); + await unitOfWork.CommitAsync(); // Act - var result = await _unitOfWork.SeriesRepository.GetPlusSeriesDto(series.Id); + var result = await unitOfWork.SeriesRepository.GetPlusSeriesDto(series.Id); // Assert Assert.NotNull(result); diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index 1fd979c10..dd7743541 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -182,18 +182,15 @@ public class LibraryController : BaseApiController /// [Authorize(Policy = "RequireAdminRole")] [HttpPost("has-files-at-root")] - public ActionResult> AnyFilesAtRoot(CheckForFilesInFolderRootsDto dto) + public ActionResult> AnyFilesAtRoot(CheckForFilesInFolderRootsDto dto) { - var results = new Dictionary(); - foreach (var root in dto.Roots) - { - results.TryAdd(root, - _directoryService - .GetFilesWithCertainExtensions(root, Parser.SupportedExtensions, SearchOption.TopDirectoryOnly) - .Any()); - } + var foldersWithFilesAtRoot = dto.Roots + .Where(root => _directoryService + .GetFilesWithCertainExtensions(root, Parser.SupportedExtensions, SearchOption.TopDirectoryOnly) + .Any()) + .ToList(); - return Ok(results); + return Ok(foldersWithFilesAtRoot); } /// @@ -658,6 +655,7 @@ public class LibraryController : BaseApiController library.EnableMetadata = dto.EnableMetadata; library.RemovePrefixForSortName = dto.RemovePrefixForSortName; library.InheritWebLinksFromFirstChapter = dto.InheritWebLinksFromFirstChapter; + library.DefaultLanguage = dto.DefaultLanguage; library.LibraryFileTypes = dto.FileGroupTypes .Select(t => new LibraryFileTypeGroup() {FileTypeGroup = t, LibraryId = library.Id}) diff --git a/API/Controllers/UploadController.cs b/API/Controllers/UploadController.cs index 9652ba494..056d2b8a2 100644 --- a/API/Controllers/UploadController.cs +++ b/API/Controllers/UploadController.cs @@ -14,6 +14,7 @@ using API.SignalR; using Flurl.Http; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.Logging; namespace API.Controllers; @@ -62,9 +63,15 @@ public class UploadController : BaseApiController public async Task> GetImageFromFile(UploadUrlDto dto) { var dateString = $"{DateTime.UtcNow.ToShortDateString()}_{DateTime.UtcNow.ToLongTimeString()}".Replace('/', '_').Replace(':', '_'); - var format = _directoryService.FileSystem.Path.GetExtension(dto.Url.Split('?')[0]).Replace(".", string.Empty); try { + var format = await dto.Url.GetFileFormatAsync(); + if (string.IsNullOrEmpty(format)) + { + // Fallback to unreliable parsing if needed + format = _directoryService.FileSystem.Path.GetExtension(dto.Url.Split('?')[0]).Replace(".", string.Empty); + } + var path = await dto.Url .DownloadFileAsync(_directoryService.TempDirectory, $"coverupload_{dateString}.{format}"); @@ -499,7 +506,7 @@ public class UploadController : BaseApiController var person = await _unitOfWork.PersonRepository.GetPersonById(uploadFileDto.Id); if (person == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-doesnt-exist")); - await _coverDbService.SetPersonCoverByUrl(person, uploadFileDto.Url, true); + await _coverDbService.SetPersonCoverByUrl(person, uploadFileDto.Url, chooseBetterImage: false); return Ok(); } catch (Exception e) diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs index c3e5df2bf..c6919fda7 100644 --- a/API/Controllers/UsersController.cs +++ b/API/Controllers/UsersController.cs @@ -121,6 +121,7 @@ public class UsersController : BaseApiController existingPreferences.ColorScapeEnabled = preferencesDto.ColorScapeEnabled; existingPreferences.BookReaderHighlightSlots = preferencesDto.BookReaderHighlightSlots; existingPreferences.DataSaver = preferencesDto.DataSaver; + existingPreferences.CustomKeyBinds = preferencesDto.CustomKeyBinds; var allLibs = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)) .Select(l => l.Id).ToList(); diff --git a/API/DTOs/Dashboard/UpdateStreamPositionDto.cs b/API/DTOs/Dashboard/UpdateStreamPositionDto.cs index 8de0ffa6f..33b939a39 100644 --- a/API/DTOs/Dashboard/UpdateStreamPositionDto.cs +++ b/API/DTOs/Dashboard/UpdateStreamPositionDto.cs @@ -2,8 +2,12 @@ public sealed record UpdateStreamPositionDto { + public string StreamName { get; set; } + public int Id { get; set; } public int FromPosition { get; set; } public int ToPosition { get; set; } - public int Id { get; set; } - public string StreamName { get; set; } + /// + /// If the has taken into account non-visible items + /// + public bool PositionIncludesInvisible { get; set; } } diff --git a/API/DTOs/LibraryDto.cs b/API/DTOs/LibraryDto.cs index 607d0178f..c038c5d69 100644 --- a/API/DTOs/LibraryDto.cs +++ b/API/DTOs/LibraryDto.cs @@ -77,4 +77,6 @@ public sealed record LibraryDto public bool RemovePrefixForSortName { get; set; } = false; /// public bool InheritWebLinksFromFirstChapter { get; init; } + /// + public string DefaultLanguage { get; init; } } diff --git a/API/DTOs/UpdateLibraryDto.cs b/API/DTOs/UpdateLibraryDto.cs index bcb7ba545..ab7d01c36 100644 --- a/API/DTOs/UpdateLibraryDto.cs +++ b/API/DTOs/UpdateLibraryDto.cs @@ -48,6 +48,8 @@ public sealed record UpdateLibraryDto /// [Required] public bool InheritWebLinksFromFirstChapter { get; init; } + /// + public string DefaultLanguage { get; init; } /// /// What types of files to allow the scanner to pickup /// diff --git a/API/DTOs/UserPreferencesDto.cs b/API/DTOs/UserPreferencesDto.cs index 3e38d3740..ef9c26804 100644 --- a/API/DTOs/UserPreferencesDto.cs +++ b/API/DTOs/UserPreferencesDto.cs @@ -40,6 +40,9 @@ public sealed record UserPreferencesDto /// [Required] public bool DataSaver { get; set; } = false; + /// + [Required] + public Dictionary> CustomKeyBinds { get; set; } = []; /// public bool AniListScrobblingEnabled { get; set; } diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index 7f9113781..b1afa261f 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -155,6 +155,9 @@ public sealed class DataContext : IdentityDbContext() .Property(b => b.EnableMetadata) .HasDefaultValue(true); + builder.Entity() + .Property(l => l.DefaultLanguage) + .HasDefaultValue(string.Empty); builder.Entity() .Property(b => b.WebLinks) @@ -293,6 +296,12 @@ public sealed class DataContext : IdentityDbContext()); + builder.Entity() + .Property(p => p.CustomKeyBinds) + .HasJsonConversion([]) + .HasColumnType("TEXT") + .HasDefaultValue(new Dictionary>()); + builder.Entity() .Property(user => user.IdentityProvider) .HasDefaultValue(IdentityProvider.Kavita); diff --git a/API/Data/Migrations/20251026234845_LibraryDefaultLanguageCustomKeyBinds.Designer.cs b/API/Data/Migrations/20251026234845_LibraryDefaultLanguageCustomKeyBinds.Designer.cs new file mode 100644 index 000000000..7fab60ea3 --- /dev/null +++ b/API/Data/Migrations/20251026234845_LibraryDefaultLanguageCustomKeyBinds.Designer.cs @@ -0,0 +1,3941 @@ +// +using System; +using System.Collections.Generic; +using API.Data; +using API.Entities.MetadataMatching; +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("20251026234845_LibraryDefaultLanguageCustomKeyBinds")] + partial class LibraryDefaultLanguageCustomKeyBinds + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.10"); + + 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("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("HasRunScrobbleEventGeneration") + .HasColumnType("INTEGER"); + + b.Property("IdentityProvider") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("OidcId") + .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("ScrobbleEventGenerationRan") + .HasColumnType("TEXT"); + + 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.AppUserAnnotation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("ChapterTitle") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("CommentHtml") + .HasColumnType("TEXT"); + + b.Property("CommentPlainText") + .HasColumnType("TEXT"); + + b.Property("ContainsSpoiler") + .HasColumnType("INTEGER"); + + b.Property("Context") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingXPath") + .HasColumnType("TEXT"); + + b.Property("HighlightCount") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Likes") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SelectedSlotIndex") + .HasColumnType("INTEGER"); + + b.Property("SelectedText") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("XPath") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserAnnotation"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("ChapterTitle") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("ImageOffset") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("XPath") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserChapterRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + 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("BookReaderHighlightSlots") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + 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("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("ColorScapeEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CustomKeyBinds") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("{}"); + + b.Property("DataSaver") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SocialPreferences") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("{\"ShareReviews\":false,\"ShareAnnotations\":false,\"ViewOtherAnnotations\":false,\"SocialLibraries\":[],\"SocialMaxAgeRating\":-1,\"SocialIncludeUnknowns\":true}"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + 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("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + 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("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + 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("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("DisableWidthOverride") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("LibraryIds") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SeriesIds") + .HasColumnType("TEXT"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("WidthOverride") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserReadingProfiles"); + }); + + 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.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("ChapterTitle") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SelectedText") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AverageExternalRating") + .HasColumnType("REAL"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("KPlusOverrides") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .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("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .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.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("API.Entities.EpubFont", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("EpubFont"); + }); + + 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("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DefaultLanguage") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("EnableMetadata") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("InheritWebLinksFromFirstChapter") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("RemovePrefixForSortName") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("KoreaderHash") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("CbrId") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + 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("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("KPlusOverrides") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .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("ReleaseYearLocked") + .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("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + 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.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableChapterCoverImage") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterPublisher") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterReleaseDate") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterTitle") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableExtendedMetadataProcessing") + .HasColumnType("INTEGER"); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Alias") + .HasColumnType("TEXT"); + + b.Property("NormalizedAlias") + .HasColumnType("TEXT"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PersonId"); + + b.ToTable("PersonAlias"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .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.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + 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("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("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + 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("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.AppUserAnnotation", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Annotations") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + 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.AppUserChapterRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ChapterRatings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Ratings") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + 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.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .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", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingProfiles") + .HasForeignKey("AppUserId") + .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.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + 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.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .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.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .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.ExternalRating", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalRatings") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalReviews") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + 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.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("Aliases") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + 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.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + 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("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("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .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("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("Annotations"); + + b.Navigation("Bookmarks"); + + b.Navigation("ChapterRatings"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ReadingProfiles"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences") + .IsRequired(); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("ExternalRatings"); + + b.Navigation("ExternalReviews"); + + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("Ratings"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Navigation("Aliases"); + + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + 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/20251026234845_LibraryDefaultLanguageCustomKeyBinds.cs b/API/Data/Migrations/20251026234845_LibraryDefaultLanguageCustomKeyBinds.cs new file mode 100644 index 000000000..e1ad21e18 --- /dev/null +++ b/API/Data/Migrations/20251026234845_LibraryDefaultLanguageCustomKeyBinds.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class LibraryDefaultLanguageCustomKeyBinds : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DefaultLanguage", + table: "Library", + type: "TEXT", + nullable: true, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "CustomKeyBinds", + table: "AppUserPreferences", + type: "TEXT", + nullable: true, + defaultValue: "{}"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DefaultLanguage", + table: "Library"); + + migrationBuilder.DropColumn( + name: "CustomKeyBinds", + table: "AppUserPreferences"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index abe19a6ae..dd61e6363 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -571,6 +571,11 @@ namespace API.Data.Migrations .HasColumnType("INTEGER") .HasDefaultValue(true); + b.Property("CustomKeyBinds") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("{}"); + b.Property("DataSaver") .HasColumnType("INTEGER"); @@ -1466,6 +1471,11 @@ namespace API.Data.Migrations b.Property("CreatedUtc") .HasColumnType("TEXT"); + b.Property("DefaultLanguage") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + b.Property("EnableMetadata") .ValueGeneratedOnAdd() .HasColumnType("INTEGER") diff --git a/API/Data/Repositories/MediaErrorRepository.cs b/API/Data/Repositories/MediaErrorRepository.cs index 40501768e..982f8ecdc 100644 --- a/API/Data/Repositories/MediaErrorRepository.cs +++ b/API/Data/Repositories/MediaErrorRepository.cs @@ -16,7 +16,7 @@ public interface IMediaErrorRepository void Attach(MediaError error); void Remove(MediaError error); void Remove(IList errors); - Task Find(string filename); + Task Find(string filename); IEnumerable GetAllErrorDtosAsync(); Task ExistsAsync(MediaError error); Task DeleteAll(); diff --git a/API/Data/Repositories/PersonRepository.cs b/API/Data/Repositories/PersonRepository.cs index 263724163..3ad2f5dee 100644 --- a/API/Data/Repositories/PersonRepository.cs +++ b/API/Data/Repositories/PersonRepository.cs @@ -49,6 +49,7 @@ public interface IPersonRepository Task> GetAllPeopleDtosForLibrariesAsync(int userId, List? libraryIds = null, PersonIncludes includes = PersonIncludes.None); Task GetCoverImageAsync(int personId); + Task> GetAllCoverImagesAsync(); Task GetCoverImageByNameAsync(string name); Task> GetRolesForPersonByName(int personId, int userId); Task> GetBrowsePersonDtos(int userId, BrowsePersonFilterDto filter, UserParams userParams); @@ -167,6 +168,13 @@ public class PersonRepository : IPersonRepository .SingleOrDefaultAsync(); } + public async Task> GetAllCoverImagesAsync() + { + return await _context.Person + .Select(p => p.CoverImage) + .ToListAsync(); + } + public async Task GetCoverImageByNameAsync(string name) { var normalized = name.ToNormalized(); @@ -358,7 +366,8 @@ public class PersonRepository : IPersonRepository .Select(cp => cp.Chapter) .RestrictAgainstAgeRestriction(ageRating) .RestrictByLibrary(userLibs) - .OrderBy(ch => ch.SortOrder) + .OrderBy(ch => ch.Volume.MinNumber) // Group/Sort volumes as well + .ThenBy(ch => ch.SortOrder) .Take(20) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); diff --git a/API/Entities/AppUserPreferences.cs b/API/Entities/AppUserPreferences.cs index 88dd7729e..ffbf5d898 100644 --- a/API/Entities/AppUserPreferences.cs +++ b/API/Entities/AppUserPreferences.cs @@ -1,4 +1,5 @@ -using System; +#nullable enable +using System; using System.Collections.Generic; using API.Data; using API.Entities.Enums; @@ -175,9 +176,15 @@ public class AppUserPreferences /// /// Enable data saver mode across Kavita, limiting information that is pre-fetched /// - /// Currenty only integrated into the PDF reader + /// Currently only integrated into the PDF reader public bool DataSaver { get; set; } = false; + /// + /// JSON dictionary mappings for custom keybinds across the web app. + /// Values are a list of key codes that need to be pressed at the same time for the keybind to be valid + /// + public Dictionary> CustomKeyBinds { get; set; } + #endregion #region KavitaPlus @@ -246,3 +253,13 @@ public class AppUserSocialPreferences /// public bool SocialIncludeUnknowns { get; set; } = true; } + +public sealed record KeyBind +{ + public string Key { get; set; } + public bool Control { get; set; } + public bool Shift { get; set; } + public bool Meta { get; set; } + public bool Alt { get; set; } + public IList? ControllerSequence { get; set; } +} diff --git a/API/Entities/Enums/UserPreferences/KeyBindTarget.cs b/API/Entities/Enums/UserPreferences/KeyBindTarget.cs new file mode 100644 index 000000000..fed522ced --- /dev/null +++ b/API/Entities/Enums/UserPreferences/KeyBindTarget.cs @@ -0,0 +1,39 @@ +using System.ComponentModel; + +namespace API.Entities.Enums.UserPreferences; + +public enum KeyBindTarget +{ + [Description(nameof(NavigateToSettings))] + NavigateToSettings = 0, + + [Description(nameof(OpenSearch))] + OpenSearch = 1, + + [Description(nameof(NavigateToScrobbling))] + NavigateToScrobbling = 2, + + [Description(nameof(ToggleFullScreen))] + ToggleFullScreen = 3, + + [Description(nameof(BookmarkPage))] + BookmarkPage = 4, + + [Description(nameof(OpenHelp))] + OpenHelp = 5, + + [Description(nameof(GoTo))] + GoTo = 6, + + [Description(nameof(ToggleMenu))] + ToggleMenu = 7, + + [Description(nameof(PageLeft))] + PageLeft = 8, + + [Description(nameof(PageRight))] + PageRight = 9, + + [Description(nameof(Escape))] + Escape = 10, +} diff --git a/API/Entities/Library.cs b/API/Entities/Library.cs index b5329d34f..66fd0eda9 100644 --- a/API/Entities/Library.cs +++ b/API/Entities/Library.cs @@ -60,7 +60,10 @@ public class Library : IEntityDate, IHasCoverImage /// Should series inherit web links from the first chapter/volume /// public bool InheritWebLinksFromFirstChapter { get; set; } = false; - + /// + /// Language to assign to series if none is set in the metadata + /// + public string DefaultLanguage { get; set; } = ""; public DateTime Created { get; set; } public DateTime LastModified { get; set; } diff --git a/API/Extensions/FlurlExtensions.cs b/API/Extensions/FlurlExtensions.cs index 62d8543b6..0f5aade4c 100644 --- a/API/Extensions/FlurlExtensions.cs +++ b/API/Extensions/FlurlExtensions.cs @@ -1,13 +1,48 @@ using System; +using System.Linq; +using System.Threading.Tasks; using Flurl.Http; using Kavita.Common; using Kavita.Common.EnvironmentInfo; +using Microsoft.AspNetCore.StaticFiles; namespace API.Extensions; #nullable enable public static class FlurlExtensions { + + private static readonly FileExtensionContentTypeProvider FileTypeProvider = new (); + + /// + /// Makes a head request to the url, and parses the first content type header to determine the content type + /// + /// + /// + public static async Task GetFileFormatAsync(this string url) + { + var headResponse = await url.AllowHttpStatus("2xx").HeadAsync(); + + // TODO: Move to new Headers class after merge with progress branch + var contentTypeHeader = headResponse.Headers.FirstOrDefault("Content-Type"); + if (string.IsNullOrEmpty(contentTypeHeader)) + { + return null; + } + + var contentType = contentTypeHeader.Split(";").FirstOrDefault(); + if (string.IsNullOrEmpty(contentType)) + { + return null; + } + + // The mappings have legacy mappings like .jpe => image/jpeg. We reverse to get the newer stuff first + return FileTypeProvider.Mappings + .Reverse() + .FirstOrDefault(m => m.Value.Equals(contentType, StringComparison.OrdinalIgnoreCase)) + .Key?.TrimStart('.'); + } + public static IFlurlRequest WithKavitaPlusHeaders(this string request, string license, string? anilistToken = null) { return request diff --git a/API/Services/ImageService.cs b/API/Services/ImageService.cs index b4be43ada..f98edea11 100644 --- a/API/Services/ImageService.cs +++ b/API/Services/ImageService.cs @@ -74,6 +74,7 @@ public class ImageService : IImageService public const string SeriesCoverImageRegex = @"series\d+"; public const string CollectionTagCoverImageRegex = @"tag\d+"; public const string ReadingListCoverImageRegex = @"readinglist\d+"; + public const string PersonCoverImageRegex = @"person\d+"; private const double WhiteThreshold = 0.95; // Colors with lightness above this are considered too close to white private const double BlackThreshold = 0.25; // Colors with lightness below this are considered too close to black diff --git a/API/Services/SettingsService.cs b/API/Services/SettingsService.cs index 9160a52f7..93542e826 100644 --- a/API/Services/SettingsService.cs +++ b/API/Services/SettingsService.cs @@ -618,7 +618,8 @@ public class SettingsService : ISettingsService if (currentConfig.Authority != updateSettingsDto.OidcConfig.Authority) { - if (!await IsValidAuthority(updateSettingsDto.OidcConfig.Authority + string.Empty)) + // Only check validity if we're changing into a value that would be used + if (!string.IsNullOrEmpty(updateSettingsDto.OidcConfig.Authority) && !await IsValidAuthority(updateSettingsDto.OidcConfig.Authority + string.Empty)) { throw new KavitaException("oidc-invalid-authority"); } diff --git a/API/Services/StreamService.cs b/API/Services/StreamService.cs index 1f2e55579..068143df6 100644 --- a/API/Services/StreamService.cs +++ b/API/Services/StreamService.cs @@ -277,7 +277,18 @@ public class StreamService : IStreamService if (stream.Order == dto.ToPosition) return; var list = user!.SideNavStreams.OrderBy(s => s.Order).ToList(); - OrderableHelper.ReorderItems(list, stream.Id, dto.ToPosition); + + var wantedPosition = dto.ToPosition; + if (!dto.PositionIncludesInvisible) + { + var visibleItems = list.Where(i => i.Visible).ToList(); + if (dto.ToPosition < 0 || dto.ToPosition >= visibleItems.Count) return; + + var itemAtWantedPosition = visibleItems[dto.ToPosition]; + wantedPosition = list.IndexOf(itemAtWantedPosition); + } + + OrderableHelper.ReorderItems(list, stream.Id, wantedPosition); user.SideNavStreams = list; _unitOfWork.UserRepository.Update(user); diff --git a/API/Services/Tasks/BackupService.cs b/API/Services/Tasks/BackupService.cs index df2921788..c2bdfd69a 100644 --- a/API/Services/Tasks/BackupService.cs +++ b/API/Services/Tasks/BackupService.cs @@ -89,7 +89,7 @@ public class BackupService : IBackupService await SendProgress(0F, "Started backup"); await SendProgress(0.1F, "Copying core files"); - var dateString = $"{DateTime.UtcNow.ToShortDateString()}_{DateTime.UtcNow.ToLongTimeString()}".Replace("/", "_").Replace(":", "_"); + var dateString = $"{DateTime.UtcNow.ToShortDateString()}_{DateTime.UtcNow:s}Z".Replace("/", "_").Replace(":", "_"); var zipPath = _directoryService.FileSystem.Path.Join(backupDirectory, $"kavita_backup_{dateString}_v{BuildInfo.Version}.zip"); if (File.Exists(zipPath)) diff --git a/API/Services/Tasks/CleanupService.cs b/API/Services/Tasks/CleanupService.cs index e39600c3f..63e9c57c7 100644 --- a/API/Services/Tasks/CleanupService.cs +++ b/API/Services/Tasks/CleanupService.cs @@ -21,7 +21,7 @@ public interface ICleanupService { Task Cleanup(); Task CleanupDbEntries(); - void CleanupCacheAndTempDirectories(); + Task CleanupCacheAndTempDirectories(); void CleanupCacheDirectory(); Task DeleteSeriesCoverImages(); Task DeleteChapterCoverImages(); @@ -80,38 +80,34 @@ public class CleanupService : ICleanupService } _logger.LogInformation("Starting Cleanup"); + + var cleanupSteps = new List<(Func, string)> + { + (() => Task.Run(() => _directoryService.ClearDirectory(_directoryService.TempDirectory)), "Cleaning temp directory"), + (CleanupCacheAndTempDirectories, "Cleaning cache and temp directories"), + (CleanupBackups, "Cleaning old database backups"), + (ConsolidateProgress, "Consolidating Progress Events"), + (CleanupMediaErrors, "Consolidating Media Errors"), + (CleanupDbEntries, "Cleaning abandoned database rows"), // Cleanup DB before removing files linked to DB entries + (DeleteSeriesCoverImages, "Cleaning deleted series cover images"), + (DeleteChapterCoverImages, "Cleaning deleted chapter cover images"), + (() => Task.WhenAll(DeleteTagCoverImages(), DeleteReadingListCoverImages(), DeletePersonCoverImages()), "Cleaning deleted cover images"), + (CleanupLogs, "Cleaning old logs"), + (EnsureChapterProgressIsCapped, "Cleaning progress events that exceed 100%") + }; + await SendProgress(0F, "Starting cleanup"); - _logger.LogInformation("Cleaning temp directory"); - _directoryService.ClearDirectory(_directoryService.TempDirectory); + for (var i = 0; i < cleanupSteps.Count; i++) + { + var (method, subtitle) = cleanupSteps[i]; + var progress = (float)(i + 1) / (cleanupSteps.Count + 1); - await SendProgress(0.1F, "Cleaning temp directory"); - CleanupCacheAndTempDirectories(); + _logger.LogInformation("{Message}", subtitle); + await method(); + await SendProgress(progress, subtitle); + } - await SendProgress(0.25F, "Cleaning old database backups"); - _logger.LogInformation("Cleaning old database backups"); - await CleanupBackups(); - - await SendProgress(0.35F, "Consolidating Progress Events"); - await ConsolidateProgress(); - - await SendProgress(0.4F, "Consolidating Media Errors"); - await CleanupMediaErrors(); - - await SendProgress(0.50F, "Cleaning deleted cover images"); - _logger.LogInformation("Cleaning deleted cover images"); - await DeleteSeriesCoverImages(); - await SendProgress(0.6F, "Cleaning deleted cover images"); - await DeleteChapterCoverImages(); - await SendProgress(0.7F, "Cleaning deleted cover images"); - await DeleteTagCoverImages(); - await DeleteReadingListCoverImages(); - await SendProgress(0.8F, "Cleaning old logs"); - await CleanupLogs(); - await SendProgress(0.9F, "Cleaning progress events that exceed 100%"); - await EnsureChapterProgressIsCapped(); - await SendProgress(0.95F, "Cleaning abandoned database rows"); - await CleanupDbEntries(); await SendProgress(1F, "Cleanup finished"); _logger.LogInformation("Cleanup finished"); } @@ -174,10 +170,20 @@ public class CleanupService : ICleanupService _directoryService.DeleteFiles(files.Where(file => !images.Contains(_directoryService.FileSystem.Path.GetFileName(file)))); } + /// + /// Remove all person cover images no longer associated with a person in the database + /// + public async Task DeletePersonCoverImages() + { + var images = await _unitOfWork.PersonRepository.GetAllCoverImagesAsync(); + var files = _directoryService.GetFiles(_directoryService.CoverImageDirectory, ImageService.PersonCoverImageRegex); + _directoryService.DeleteFiles(files.Where(file => !images.Contains(_directoryService.FileSystem.Path.GetFileName(file)))); + } + /// /// Removes all files and directories in the cache and temp directory /// - public void CleanupCacheAndTempDirectories() + public Task CleanupCacheAndTempDirectories() { _logger.LogInformation("Performing cleanup of Cache & Temp directories"); _directoryService.ExistOrCreate(_directoryService.CacheDirectory); @@ -194,6 +200,8 @@ public class CleanupService : ICleanupService } _logger.LogInformation("Cache and temp directory purged"); + + return Task.CompletedTask; } public void CleanupCacheDirectory() diff --git a/API/Services/Tasks/Metadata/CoverDbService.cs b/API/Services/Tasks/Metadata/CoverDbService.cs index 560161d97..6be6275c4 100644 --- a/API/Services/Tasks/Metadata/CoverDbService.cs +++ b/API/Services/Tasks/Metadata/CoverDbService.cs @@ -30,7 +30,7 @@ public interface ICoverDbService Task DownloadPublisherImageAsync(string publisherName, EncodeFormat encodeFormat); Task DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat); Task DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat, string url); - Task SetPersonCoverByUrl(Person person, string url, bool fromBase64 = true, bool checkNoImagePlaceholder = false); + Task SetPersonCoverByUrl(Person person, string url, bool fromBase64 = true, bool checkNoImagePlaceholder = false, bool chooseBetterImage = true); Task SetSeriesCoverByUrl(Series series, string url, bool fromBase64 = true, bool chooseBetterImage = false); Task SetChapterCoverByUrl(Chapter chapter, string url, bool fromBase64 = true, bool chooseBetterImage = false); } @@ -472,7 +472,8 @@ public class CoverDbService : ICoverDbService /// /// /// Will check against all known null image placeholders to avoid writing it - public async Task SetPersonCoverByUrl(Person person, string url, bool fromBase64 = true, bool checkNoImagePlaceholder = false) + /// If we check cross-reference the current cover for the better option + public async Task SetPersonCoverByUrl(Person person, string url, bool fromBase64 = true, bool checkNoImagePlaceholder = false, bool chooseBetterImage = true) { if (!string.IsNullOrEmpty(url)) { @@ -504,7 +505,7 @@ public class CoverDbService : ICoverDbService try { - if (!string.IsNullOrEmpty(person.CoverImage)) + if (!string.IsNullOrEmpty(person.CoverImage) && chooseBetterImage) { var existingPath = Path.Combine(_directoryService.CoverImageDirectory, person.CoverImage); var betterImage = existingPath.GetBetterImage(tempFullPath)!; diff --git a/API/Services/Tasks/Scanner/ProcessSeries.cs b/API/Services/Tasks/Scanner/ProcessSeries.cs index 8aa595178..0bd6174b9 100644 --- a/API/Services/Tasks/Scanner/ProcessSeries.cs +++ b/API/Services/Tasks/Scanner/ProcessSeries.cs @@ -331,9 +331,16 @@ public class ProcessSeries : IProcessSeries series.Metadata.Summary = firstChapter.Summary; } - if (!string.IsNullOrEmpty(firstChapter?.Language) && !series.Metadata.LanguageLocked) + if (!series.Metadata.LanguageLocked) { - series.Metadata.Language = firstChapter.Language; + if (!string.IsNullOrEmpty(firstChapter?.Language)) + { + series.Metadata.Language = firstChapter.Language; + } + else if (!string.IsNullOrEmpty(library.DefaultLanguage)) + { + series.Metadata.Language = library.DefaultLanguage; + } } if (!string.IsNullOrEmpty(firstChapter?.WebLinks) && library.InheritWebLinksFromFirstChapter) diff --git a/API/redo-migration.sh b/API/redo-migration.sh index 76ef8fc7f..a1ef82bc0 100755 --- a/API/redo-migration.sh +++ b/API/redo-migration.sh @@ -8,19 +8,20 @@ if [ ${#migrations[@]} -lt 2 ]; then fi second_last=$(basename "${migrations[1]}" .cs) - last=$(basename "${migrations[0]}" .cs) last_name=$(echo "$last" | sed 's/^[0-9]*_//') +new_name=${1:-$last_name} + echo "Rolling back to: $second_last" -echo "Removing and re-adding: $last_name" +echo "Removing $last_name and re-adding as $new_name" read -p "Continue? (y/N) " -n 1 -r echo "" if [[ $REPLY =~ ^[Yy]$ ]]; then dotnet ef database update "$second_last" && \ dotnet ef migrations remove && \ - dotnet ef migrations add "$last_name" + dotnet ef migrations add "$new_name" else echo "Cancelled" exit 0 diff --git a/Kavita.Common/Configuration.cs b/Kavita.Common/Configuration.cs index 937549bb4..04c7c938c 100644 --- a/Kavita.Common/Configuration.cs +++ b/Kavita.Common/Configuration.cs @@ -19,9 +19,9 @@ public static class Configuration public const string DefaultOidcClientId = "kavita"; private static readonly string AppSettingsFilename = Path.Join("config", GetAppSettingFilename()); - public static readonly string KavitaPlusApiUrl = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development + public static readonly string KavitaPlusApiUrl = Environment.GetEnvironmentVariable("LOCAL_KAVITA_PLUS") == "TRUE" ? "http://localhost:5020" : "https://plus.kavitareader.com"; - public static readonly string StatsApiUrl = "https://stats.kavitareader.com"; + public const string StatsApiUrl = "https://stats.kavitareader.com"; public static int Port { diff --git a/UI/Web/src/app/_models/library/library.ts b/UI/Web/src/app/_models/library/library.ts index c73ca44b7..85e66df6b 100644 --- a/UI/Web/src/app/_models/library/library.ts +++ b/UI/Web/src/app/_models/library/library.ts @@ -35,6 +35,7 @@ export interface Library { removePrefixForSortName: boolean; collapseSeriesRelationships: boolean; inheritWebLinksFromFirstChapter: boolean; + defaultLanguage: string; libraryFileTypes: Array; excludePatterns: Array; } diff --git a/UI/Web/src/app/_models/preferences/preferences.ts b/UI/Web/src/app/_models/preferences/preferences.ts index 5db987355..a10f0bad7 100644 --- a/UI/Web/src/app/_models/preferences/preferences.ts +++ b/UI/Web/src/app/_models/preferences/preferences.ts @@ -2,6 +2,7 @@ import {PageLayoutMode} from '../page-layout-mode'; import {SiteTheme} from './site-theme'; import {HighlightSlot} from "../../book-reader/_models/annotations/highlight-slot"; import {AgeRating} from "../metadata/age-rating"; +import {KeyCode} from "../../_services/key-bind.service"; export interface Preferences { @@ -16,6 +17,7 @@ export interface Preferences { bookReaderHighlightSlots: HighlightSlot[]; colorScapeEnabled: boolean; dataSaver: boolean; + customKeyBinds: Partial>; // Kavita+ aniListScrobblingEnabled: boolean; @@ -34,3 +36,27 @@ export interface SocialPreferences { socialIncludeUnknowns: boolean; } +export interface KeyBind { + meta?: boolean; + control?: boolean; + alt?: boolean; + shift?: boolean; + controllerSequence?: readonly string[]; + key: KeyCode; +} + +export enum KeyBindTarget { + NavigateToSettings = 'NavigateToSettings', + OpenSearch = 'OpenSearch', + NavigateToScrobbling = 'NavigateToScrobbling', + + ToggleFullScreen = 'ToggleFullScreen', + BookmarkPage = 'BookmarkPage', + OpenHelp = 'OpenHelp', + GoTo = "GoTo", + ToggleMenu = 'ToggleMenu', + PageLeft = 'PageLeft', + PageRight = 'PageRight', + Escape = 'Escape', +} + diff --git a/UI/Web/src/app/_pipes/key-bind.pipe.ts b/UI/Web/src/app/_pipes/key-bind.pipe.ts new file mode 100644 index 000000000..f96ead1c7 --- /dev/null +++ b/UI/Web/src/app/_pipes/key-bind.pipe.ts @@ -0,0 +1,40 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import {KeyBind} from "../_models/preferences/preferences"; +import {KeyCode} from "../_services/key-bind.service"; + +@Pipe({ + name: 'keyBind' +}) +export class KeyBindPipe implements PipeTransform { + + private readonly customMappings: Partial> = { + [KeyCode.ArrowDown]: '↓', + [KeyCode.ArrowUp]: '↑', + [KeyCode.ArrowLeft]: '⇽', + [KeyCode.ArrowRight]: '⇾', + [KeyCode.Space]: 'space', + } as const; + + transform(keyBind: KeyBind | undefined): string { + if (!keyBind) return ''; + + if (keyBind.controllerSequence) { + return keyBind.controllerSequence.join('+'); + } + + let keys: string[] = []; + + if (keyBind.control) keys.push('Ctrl'); + if (keyBind.shift) keys.push('Shift'); + if (keyBind.alt) keys.push('Alt'); + + // TODO: Use new device code after progress merge? + const isMac = navigator.platform.includes('Mac'); + if (keyBind.meta) keys.push(isMac ? '⌘' : 'Win'); + + keys.push(this.customMappings[keyBind.key] ?? keyBind.key.toUpperCase()) + + return keys.join('+') + } + +} diff --git a/UI/Web/src/app/_pipes/keybind-setting-description.pipe.ts b/UI/Web/src/app/_pipes/keybind-setting-description.pipe.ts new file mode 100644 index 000000000..ddddd647d --- /dev/null +++ b/UI/Web/src/app/_pipes/keybind-setting-description.pipe.ts @@ -0,0 +1,44 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {KeyBindTarget} from "../_models/preferences/preferences"; +import {translate} from "@jsverse/transloco"; + +@Pipe({ + name: 'keybindSettingDescription' +}) +export class KeybindSettingDescriptionPipe implements PipeTransform { + + prefix = 'keybind-setting-description-pipe'; + transform(value: KeyBindTarget) { + switch (value) { + case KeyBindTarget.NavigateToSettings: + return this.create('key-bind-title-navigate-to-settings', 'key-bind-tooltip-navigate-to-settings'); + case KeyBindTarget.OpenSearch: + return this.create('key-bind-title-open-search', 'key-bind-tooltip-open-search'); + case KeyBindTarget.NavigateToScrobbling: + return this.create('key-bind-title-navigate-to-scrobbling', 'key-bind-tooltip-navigate-to-scrobbling'); + case KeyBindTarget.ToggleFullScreen: + return this.create('key-bind-title-toggle-fullscreen', 'key-bind-tooltip-toggle-fullscreen'); + case KeyBindTarget.BookmarkPage: + return this.create('key-bind-title-bookmark-page', 'key-bind-tooltip-bookmark-page'); + case KeyBindTarget.OpenHelp: + return this.create('key-bind-title-open-help', 'key-bind-tooltip-open-help'); + case KeyBindTarget.GoTo: + return this.create('key-bind-title-go-to', 'key-bind-tooltip-go-to'); + case KeyBindTarget.ToggleMenu: + return this.create('key-bind-title-toggle-menu', 'key-bind-tooltip-toggle-menu'); + case KeyBindTarget.PageLeft: + return this.create('key-bind-title-page-left', 'key-bind-tooltip-page-left'); + case KeyBindTarget.PageRight: + return this.create('key-bind-title-page-right', 'key-bind-tooltip-page-right'); + case KeyBindTarget.Escape: + return this.create('key-bind-title-escape', 'key-bind-tooltip-escape'); + + } + } + + private create(titleKey: string, tooltipKey: string) { + + return {title: translate(`${this.prefix}.${titleKey}`), tooltip: translate(`${this.prefix}.${tooltipKey}`)} +} + +} diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index 22fae2aee..8e4d029f1 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -69,6 +69,7 @@ export class AccountService { public readonly currentUserSignal = toSignal(this.currentUser$); public readonly userId = computed(() => this.currentUserSignal()?.id); + public readonly isReadOnly = computed(() => this.currentUserSignal()?.roles.includes(Role.ReadOnly) ?? true); /** * SetTimeout handler for keeping track of refresh token call diff --git a/UI/Web/src/app/_services/game-pad.service.ts b/UI/Web/src/app/_services/game-pad.service.ts new file mode 100644 index 000000000..33bd6a06d --- /dev/null +++ b/UI/Web/src/app/_services/game-pad.service.ts @@ -0,0 +1,146 @@ +import {Injectable, signal} from '@angular/core'; +import {Subject} from "rxjs"; + +interface GamePadKeyEvent { + /** + * Buttons currently pressed + */ + pressedButtons: readonly GamePadButtonKey[]; + /** + * If the event is keydown, all newly added buttons + */ + newButtons?: readonly GamePadButtonKey[]; + /** + * If the event is keyup, all removed buttons + */ + removedButtons?: readonly GamePadButtonKey[]; +} + +export enum GamePadButtonKey { + A = 'A', + B = 'B', + X = 'X', + Y = 'Y', + LB = 'LB', + RB = 'RB', + LT = 'LT', + RT = 'RT', + Back = 'Back', + Start = 'Start', + AxisLeft = 'Axis-Left', // Left Stick Button + AxisRight = 'Axis-Right', // Right Stick Button + DPadUp = 'DPad-Up', + DPadDown = 'DPad-Down', + DPadLeft = 'DPad-Left', + DPadRight = 'DPad-Right', + Power = 'Power', // Guide / Home / Xbox Button +} + +/** + * Button order follows W3C standard mapping: + * https://www.w3.org/TR/gamepad/#remapping + */ +export const GAMEPAD_BUTTON_KEYS: readonly GamePadButtonKey[] = [ + GamePadButtonKey.A, + GamePadButtonKey.B, + GamePadButtonKey.X, + GamePadButtonKey.Y, + GamePadButtonKey.LB, + GamePadButtonKey.RB, + GamePadButtonKey.LT, + GamePadButtonKey.RT, + GamePadButtonKey.Back, + GamePadButtonKey.Start, + GamePadButtonKey.AxisLeft, + GamePadButtonKey.AxisRight, + GamePadButtonKey.DPadUp, + GamePadButtonKey.DPadDown, + GamePadButtonKey.DPadLeft, + GamePadButtonKey.DPadRight, + GamePadButtonKey.Power, +]; + + +/** + * GamePadService provides a wrapper around the native GamePad browser API as it has a bad DX + */ +@Injectable({ + providedIn: 'root' +}) +export class GamePadService { + + protected readonly _gamePads = signal>(new Set()); + public readonly gamePads = this._gamePads.asReadonly(); + + private readonly keyUpEvents = new Subject(); + public readonly keyUpEvents$ = this.keyUpEvents.asObservable(); + private readonly keyDownEvents = new Subject(); + public readonly keyDownEvents$ = this.keyDownEvents.asObservable(); + + private lastState = new Map(); + private pollId?: number; + + constructor() { + window.addEventListener('gamepadconnected', (e: GamepadEvent) => { + const startLoop = this.gamePads().size === 0; + + this._gamePads.update(s => new Set(s).add(e.gamepad)); + + if (startLoop) { + this.poll(); + } + }); + + window.addEventListener('gamepaddisconnected', (e: GamepadEvent) => { + this._gamePads.update(s => { + const newSet = new Set(s); + newSet.delete(e.gamepad); + return newSet; + }); + + this.lastState.delete(e.gamepad.index); + if (this.gamePads().size == 0 && this.pollId) { + cancelAnimationFrame(this.pollId); + } + }); + } + + private poll() { + if (this.gamePads().size === 0) { + return; + } + + for (const gamePad of this.gamePads()) { + const pressed: GamePadButtonKey[] = []; + + for (let idx = 0; idx < gamePad.buttons.length; idx++) { + if (gamePad.buttons[idx].pressed) { + pressed.push(GAMEPAD_BUTTON_KEYS[idx]); + } + } + + const last = this.lastState.get(gamePad.index) ?? []; + const newButtons = pressed.filter(btn => !last.includes(btn)); + const removedButtons = last.filter(btn => !pressed.includes(btn)); + + if (newButtons.length > 0) { + this.keyDownEvents.next({ + pressedButtons: [...pressed], + newButtons: [...newButtons], + }); + } + + if (removedButtons.length > 0) { + this.keyUpEvents.next({ + pressedButtons: [...pressed], + removedButtons: [...removedButtons], + }); + } + + this.lastState.set(gamePad.index, [...pressed]); + } + + this.pollId = requestAnimationFrame(() => this.poll()); + } + +} diff --git a/UI/Web/src/app/_services/key-bind.service.ts b/UI/Web/src/app/_services/key-bind.service.ts new file mode 100644 index 000000000..84d0c00b1 --- /dev/null +++ b/UI/Web/src/app/_services/key-bind.service.ts @@ -0,0 +1,441 @@ +import {computed, DestroyRef, inject, Injectable, signal} from '@angular/core'; +import {AccountService, Role} from "./account.service"; +import {KeyBind, KeyBindTarget} from "../_models/preferences/preferences"; +import {DOCUMENT} from "@angular/common"; +import {filter, finalize, Observable, of, Subject, tap, withLatestFrom} from "rxjs"; +import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {map} from "rxjs/operators"; +import {GamePadService} from "./game-pad.service"; + +/** + * Codes as returned by KeyBoardEvent.key.toLowerCase() + */ +export enum KeyCode { + KeyA = "a", + KeyB = "b", + KeyC = "c", + KeyD = "d", + KeyE = "e", + KeyF = "f", + KeyG = "g", + KeyH = "h", + KeyI = "i", + KeyJ = "j", + KeyK = "k", + KeyL = "l", + KeyM = "m", + KeyN = "n", + KeyO = "o", + KeyP = "p", + KeyQ = "q", + KeyR = "r", + KeyS = "s", + KeyT = "t", + KeyU = "u", + KeyV = "v", + KeyW = "w", + KeyX = "x", + KeyY = "y", + KeyZ = "z", + + + Digit0 = "0", + Digit1 = "1", + Digit2 = "2", + Digit3 = "3", + Digit4 = "4", + Digit5 = "5", + Digit6 = "6", + Digit7 = "7", + Digit8 = "8", + Digit9 = "9", + + ArrowUp = "arrowup", + ArrowDown = "arrowdown", + ArrowLeft = "arrowleft", + ArrowRight = "arrowright", + + Comma = ',', + Space = ' ', + Escape = 'escape', + + Control = "control", + Alt = "alt", + Shift = "shift", + Meta = "meta", + + Empty = '', +} + +/** + * KeyCodes we consider modifiers + */ +export const ModifierKeyCodes: KeyCode[] = [ + KeyCode.Control, + KeyCode.Alt, + KeyCode.Shift, + KeyCode.Meta, +]; + +/** + * Emitted if a keybind has been recorded + */ +export interface KeyBindEvent { + /** + * Target of the event + */ + target: KeyBindTarget; + /** + * Overriding this value must be done in the sync callback of your + * observable. When true after all observables have completed, will cancel the event that triggered it + * + * @default true + */ + triggered: boolean; + /** + * If the original event's target was editable. This is only relevant for KeyBoard events, GamePad events do not + * contain this information + */ + inEditableElement: boolean; +} + +/** + * Add any keybinds in this array which cannot be used users ever + * Example: Page refresh + */ +const ReservedKeyBinds: KeyBind[] = [ + {control: true, key: KeyCode.KeyR}, + {meta: true, key: KeyCode.KeyR}, +]; + +/** + * This record should hold all KeyBinds Kavita has to offer, with their default combination(s). + * To add a new keybind to the system, add it here and in the backend enum. Add it to the KeyBindGroups + * array to be displayed on the settings page + */ +export const DefaultKeyBinds: Readonly> = { + [KeyBindTarget.NavigateToSettings]: [{meta: true, key: KeyCode.Comma}], + [KeyBindTarget.OpenSearch]: [{control: true, key: KeyCode.KeyK}, {meta: true, key: KeyCode.KeyK}], + [KeyBindTarget.NavigateToScrobbling]: [], + [KeyBindTarget.ToggleFullScreen]: [{key: KeyCode.KeyF}], + [KeyBindTarget.BookmarkPage]: [{key: KeyCode.KeyB, control: true}], + [KeyBindTarget.OpenHelp]: [{key: KeyCode.KeyH}], + [KeyBindTarget.GoTo]: [{key: KeyCode.KeyG}], + [KeyBindTarget.ToggleMenu]: [{key: KeyCode.Space}], + [KeyBindTarget.PageLeft]: [{key: KeyCode.ArrowLeft}, {key: KeyCode.ArrowUp}], + [KeyBindTarget.PageRight]: [{key: KeyCode.ArrowRight}, {key: KeyCode.ArrowDown}], + [KeyBindTarget.Escape]: [{key: KeyCode.Escape}] +} as const; + +type KeyBindGroup = { + title: string, + elements: { + target: KeyBindTarget, + roles?: Role[]; + restrictedRoles?: Role[], + kavitaPlus?: boolean; + }[]; +} + +export const KeyBindGroups: KeyBindGroup[] = [ + { + title: 'global-header', + elements: [ + {target: KeyBindTarget.NavigateToSettings}, + {target: KeyBindTarget.OpenSearch}, + {target: KeyBindTarget.NavigateToScrobbling, kavitaPlus: true}, + {target: KeyBindTarget.Escape}, + ] + }, + { + title: 'readers-header', + elements: [ + {target: KeyBindTarget.ToggleFullScreen}, + {target: KeyBindTarget.BookmarkPage}, + {target: KeyBindTarget.OpenHelp}, + {target: KeyBindTarget.GoTo}, + {target: KeyBindTarget.ToggleMenu}, + {target: KeyBindTarget.PageRight}, + {target: KeyBindTarget.PageLeft}, + ], + } +]; + +interface RegisterListenerOptions { + /** + * @default false + */ + fireInEditable?: boolean; + /** + * @default of(true) + */ + condition$?: Observable; + /** + * @default true + */ + markAsTriggered?: boolean; +} + +@Injectable({ + providedIn: 'root' +}) +export class KeyBindService { + + private readonly accountService = inject(AccountService); + private readonly gamePadService = inject(GamePadService); + private readonly document = inject(DOCUMENT); + + /** + * Global disable switch for the keybind listener. Make sure you enable again after using + * so keybinds don't stop working across the app. + */ + public readonly disabled = signal(false); + + /** + * Valid custom keybinds as configured by the authenticated user + * @private + */ + private readonly customKeyBinds = computed(() => { + const customKeyBinds = this.accountService.currentUserSignal()?.preferences.customKeyBinds ?? {}; + return Object.fromEntries(Object.entries(customKeyBinds).filter(([target, _]) => { + return DefaultKeyBinds[target as KeyBindTarget] !== undefined; // Filter out unused or old targets + })) + }); + + /** + * All key binds for which the target is currently active + * @private + */ + private readonly activeKeyBinds = computed>(() => { + const customKeyBindsRaw = this.customKeyBinds(); + const activeTargets = this.activeTargetsSet(); + + const customKeyBinds: Partial> = {}; + for (const [target, combos] of Object.entries(customKeyBindsRaw) as [KeyBindTarget, KeyBind[]][]) { + if (activeTargets.has(target)) { + customKeyBinds[target] = combos.filter(combo => !this.isReservedKeyBind(combo)); + } + } + + return { + ...DefaultKeyBinds, + ...customKeyBinds, + } satisfies Record; + }); + + /** + * A record of all possible keybinds in Kavita, as configured by the user + */ + public readonly allKeyBinds = computed>(() => { + const customKeyBinds = this.customKeyBinds(); + + return { + ...DefaultKeyBinds, + ...customKeyBinds, + } satisfies Record; + }); + + /** + * A set of all keys used in all keybinds, other keys should not be tracked + * @private + */ + private readonly listenedKeys = computed(() => { + const keyBinds = this.activeKeyBinds(); + const combos = Object.values(keyBinds); + const allKeys = combos.flatMap(c => c).flatMap(c => c).map(kb => kb.key); + return new Set(allKeys); + }); + + private readonly activeTargets = signal([]); + private readonly activeTargetsSet = computed(() => new Set(this.activeTargets())); + + /** + * We do not allow subscribing to the events$ directly, as there is some extra state management for performance + * reasons. See registerListener for details + * @private + */ + private readonly eventsSubject = new Subject(); + private readonly events$ = this.eventsSubject.asObservable(); + + constructor() { + // We use keydown as to intercept before native browser keybinds, in case we want to cancel the event + this.document.addEventListener('keydown', e => this.handleKeyEvent(e)); + + this.gamePadService.keyDownEvents$.pipe( + map(e => { + return { + key: KeyCode.Empty, + controllerSequence: e.pressedButtons, + } as KeyBind; + }), + tap(kb => this.checkForKeyBind(kb)), + ).subscribe(); + } + + private handleKeyEvent(event: KeyboardEvent) { + if (this.disabled()) return; + + const eventKey = event.key.toLowerCase() as KeyCode; + + if (!this.listenedKeys().has(eventKey)) return; + + const activeKeyBind: KeyBind = { + key: eventKey, + control: event.ctrlKey, + meta: event.metaKey, + shift: event.shiftKey, + alt: event.altKey, + }; + + this.checkForKeyBind(activeKeyBind, event); + } + + private checkForKeyBind(activeKeyBind: KeyBind, event?: KeyboardEvent) { + const activeKeyBinds = this.activeKeyBinds(); + for (const [target, keybinds] of Object.entries(activeKeyBinds)) { + for (const keybind of keybinds) { + + if (!this.areKeyBindsEqual(activeKeyBind, keybind)) continue; + + const keyBindEvent: KeyBindEvent = { + target: target as KeyBindTarget, + triggered: false, + inEditableElement: event ? this.isEditableTarget(event.target) : false, + }; + + this.eventsSubject.next(keyBindEvent); + + if (event && keyBindEvent.triggered) { + event.preventDefault(); + event.stopPropagation(); + } + } + } + } + + /** + * Key events while in this target should be ignored + * @param target + * @private + */ + private isEditableTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + + if (target instanceof HTMLInputElement) return true; + if (target instanceof HTMLTextAreaElement) return true; + + return target.isContentEditable; + } + + /** + * Register a listener for targets. When a match is found will set KeyBindEvent#triggered to true + * @param destroyRef$ destroy ref used for lifetime management + * @param callback + * @param targetFilter + * @param options + */ + public registerListener( + destroyRef$: DestroyRef, + callback: (e: KeyBindEvent) => void, + targetFilter: KeyBindTarget[], + options?: RegisterListenerOptions, + ) { + const { + fireInEditable = false, + condition$ = of(true), + markAsTriggered = true, + } = options ?? {}; + + this.activeTargets.update(s => [...s, ...targetFilter]); + + this.events$.pipe( + takeUntilDestroyed(destroyRef$), + filter(e => !e.inEditableElement || fireInEditable), + filter(e => targetFilter.includes(e.target)), + withLatestFrom(condition$), + filter(([_, ok]) => ok), + map(([e, _]) => e), + tap(e => { + if (markAsTriggered) { + e.triggered = true; // Set before callback so consumers may override + } + + callback(e); + }), + finalize(() => { // Remove all targets when the consumer has finished + this.activeTargets.update(targets => { + const updated = [...targets]; + // Remove only once in case others have registered the same target + targetFilter.forEach(target => this.removeOnce(updated, target)); + return updated; + }); + }), + ).subscribe(); + } + + /** + * Remove the first occurrence of element in the array + * @param array + * @param element + * @private + */ + private removeOnce(array: T[], element: T) { + const index = array.indexOf(element); + if (index !== -1) { + array.splice(index, 1); + } + } + + /** + * Returns true if the keybinds are semantically equal + * @param k1 + * @param k2 + */ + public areKeyBindsEqual(k1: KeyBind, k2: KeyBind) { + // If a controller sequence is present on either, it takes full and the only priority + if (k1.controllerSequence || k2.controllerSequence) { + return k1.controllerSequence?.every(k => k2.controllerSequence?.includes(k)) || false; + } + + return ( + (k1.alt ?? false) === (k2.alt ?? false) && + (k1.shift ?? false) === (k2.shift ?? false) && + (k1.control ?? false) === (k2.control ?? false) && + (k1.meta ?? false) === (k2.meta ?? false) && + k1.key === k2.key + ); + } + + /** + * Checks the given keybind against the ReservedKeyBinds list. If true, keybind should be considered invalid and unusable + * @param keyBind + */ + public isReservedKeyBind(keyBind: KeyBind) { + for (let reservedKeyBind of ReservedKeyBinds) { + if (this.areKeyBindsEqual(reservedKeyBind, keyBind)) { + return true; + } + } + + return false; + } + + /** + * Returns true if the given keyBinds are equal to the default ones for the target, and can be skipped when saving to user preferences + * @param target + * @param keyBinds + */ + public isDefaultKeyBinds(target: KeyBindTarget, keyBinds: KeyBind[]) { + const defaultKeyBinds = DefaultKeyBinds[target]; + if (!defaultKeyBinds) { + throw Error("Could not find default keybinds for " + target) + } + + if (defaultKeyBinds.length !== keyBinds.length) return false; + + return keyBinds.every(keyBind => + defaultKeyBinds.some(defaultKeyBind => this.areKeyBindsEqual(defaultKeyBind, keyBind)) + ); + } + +} diff --git a/UI/Web/src/app/_services/library.service.ts b/UI/Web/src/app/_services/library.service.ts index 7a9d93ed3..1296effdf 100644 --- a/UI/Web/src/app/_services/library.service.ts +++ b/UI/Web/src/app/_services/library.service.ts @@ -78,7 +78,7 @@ export class LibraryService { } hasFilesAtRoot(roots: Array) { - return this.httpClient.post<{[key: string]: boolean}>(this.baseUrl + 'library/has-files-at-root', {roots}); + return this.httpClient.post>(this.baseUrl + 'library/has-files-at-root', {roots}); } getJumpBar(libraryId: number) { diff --git a/UI/Web/src/app/_services/nav.service.ts b/UI/Web/src/app/_services/nav.service.ts index f4a3ca4c2..52d2af8a6 100644 --- a/UI/Web/src/app/_services/nav.service.ts +++ b/UI/Web/src/app/_services/nav.service.ts @@ -1,6 +1,6 @@ import {DOCUMENT} from '@angular/common'; import {DestroyRef, inject, Injectable, Renderer2, RendererFactory2, RendererStyleFlags2} from '@angular/core'; -import {filter, ReplaySubject, take} from 'rxjs'; +import {filter, ReplaySubject, take, tap} from 'rxjs'; import {HttpClient} from "@angular/common/http"; import {environment} from "../../environments/environment"; import {SideNavStream} from "../_models/sidenav/sidenav-stream"; @@ -129,8 +129,8 @@ export class NavService { return this.httpClient.get>(this.baseUrl + 'stream/sidenav?visibleOnly=' + visibleOnly); } - updateSideNavStreamPosition(streamName: string, sideNavStreamId: number, fromPosition: number, toPosition: number) { - return this.httpClient.post(this.baseUrl + 'stream/update-sidenav-position', {streamName, id: sideNavStreamId, fromPosition, toPosition}, TextResonse); + updateSideNavStreamPosition(streamName: string, sideNavStreamId: number, fromPosition: number, toPosition: number, positionIncludesInvisible: boolean = true) { + return this.httpClient.post(this.baseUrl + 'stream/update-sidenav-position', {streamName, id: sideNavStreamId, fromPosition, toPosition, positionIncludesInvisible}, TextResonse); } updateSideNavStream(stream: SideNavStream) { diff --git a/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.ts b/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.ts index 26087cdc9..0ea186e2d 100644 --- a/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.ts +++ b/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.ts @@ -7,7 +7,7 @@ import {TranslocoDirective} from "@jsverse/transloco"; import {AccountService} from "../../_services/account.service"; import {Chapter} from "../../_models/chapter"; import {LibraryType} from "../../_models/library/library"; -import {TypeaheadSettings} from "../../typeahead/_models/typeahead-settings"; +import {setupLanguageSettings, TypeaheadSettings} from "../../typeahead/_models/typeahead-settings"; import {Tag} from "../../_models/tag"; import {Language} from "../../_models/metadata/language"; import {Person, PersonRole} from "../../_models/metadata/person"; @@ -125,14 +125,13 @@ export class EditChapterModalComponent implements OnInit { coverImageReset = false; tagsSettings: TypeaheadSettings = new TypeaheadSettings(); - languageSettings: TypeaheadSettings = new TypeaheadSettings(); + languageSettings: TypeaheadSettings | null = null; peopleSettings: {[PersonRole: string]: TypeaheadSettings} = {}; genreSettings: TypeaheadSettings = new TypeaheadSettings(); tags: Tag[] = []; genres: Genre[] = []; ageRatings: Array = []; - validLanguages: Array = []; tasks = this.actionFactoryService.getActionablesForSettingsPage(this.actionFactoryService.getChapterActions(this.runTask.bind(this)), blackList); /** @@ -189,10 +188,9 @@ export class EditChapterModalComponent implements OnInit { this.metadataService.getAllValidLanguages().pipe( tap(validLanguages => { - this.validLanguages = validLanguages; + this.languageSettings = setupLanguageSettings(true, this.utilityService, validLanguages, this.chapter.language); this.cdRef.markForCheck(); }), - switchMap(_ => this.setupLanguageTypeahead()) ).subscribe(); this.metadataService.getAllAgeRatings().subscribe(ratings => { @@ -313,7 +311,6 @@ export class EditChapterModalComponent implements OnInit { this.setupTagSettings(), this.setupGenreTypeahead(), this.setupPersonTypeahead(), - this.setupLanguageTypeahead() ]).subscribe(results => { this.cdRef.markForCheck(); }); @@ -383,34 +380,6 @@ export class EditChapterModalComponent implements OnInit { return of(true); } - setupLanguageTypeahead() { - this.languageSettings.minCharacters = 0; - this.languageSettings.multiple = false; - this.languageSettings.id = 'language'; - this.languageSettings.unique = true; - this.languageSettings.showLocked = true; - this.languageSettings.addIfNonExisting = false; - this.languageSettings.compareFn = (options: Language[], filter: string) => { - return options.filter(m => this.utilityService.filter(m.title, filter)); - } - this.languageSettings.compareFnForAdd = (options: Language[], filter: string) => { - return options.filter(m => this.utilityService.filterMatches(m.title, filter)); - } - this.languageSettings.fetchFn = (filter: string) => of(this.validLanguages) - .pipe(map(items => this.languageSettings.compareFn(items, filter))); - - this.languageSettings.selectionCompareFn = (a: Language, b: Language) => { - return a.isoCode == b.isoCode; - } - this.languageSettings.trackByIdentityFn = (index, value) => value.isoCode; - - const l = this.validLanguages.find(l => l.isoCode === this.chapter.language); - if (l !== undefined) { - this.languageSettings.savedData = l; - } - return of(true); - } - updateFromPreset(id: string, presetField: Array | undefined, role: PersonRole) { const personSettings = this.createBlankPersonSettings(id, role) diff --git a/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.html b/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.html index a65ad4067..61ebacca8 100644 --- a/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.html +++ b/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.html @@ -55,6 +55,9 @@ @if (formControl.errors && formControl.errors.requiredIf) {
{{t('other-field-required', {name: 'clientId', other: formControl.errors.requiredIf.other})}}
} + @if (formControl.errors && formControl.errors.requiredIfOtherInvalid) { +
{{t('other-field-invalid', {other: formControl.errors.requiredIfOtherInvalid.other})}}
+ } } @@ -67,7 +70,7 @@ @if (settingsForm.get('secret'); as formControl) { - {{formControl.value | defaultValue}} + {{formControl.value | slice:0:40 | defaultValue}} {{t('other-field-required', {name: 'secret', other: formControl.errors.requiredIf.other})}} } + @if (formControl.errors && formControl.errors.requiredIfOtherInvalid) { +
{{t('other-field-invalid', {other: formControl.errors.requiredIfOtherInvalid.other})}}
+ } } diff --git a/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.ts b/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.ts index e21c542ed..aff1e3555 100644 --- a/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.ts +++ b/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.ts @@ -44,6 +44,7 @@ import { SettingMultiTextFieldComponent } from "../../settings/_components/setting-multi-text-field/setting-multi-text-field.component"; import {environment} from "../../../environments/environment"; +import {SlicePipe} from "@angular/common"; type OidcFormGroup = FormGroup<{ autoLogin: FormControl; @@ -75,7 +76,8 @@ type OidcFormGroup = FormGroup<{ SafeHtmlPipe, DefaultValuePipe, SettingMultiCheckBox, - SettingMultiTextFieldComponent + SettingMultiTextFieldComponent, + SlicePipe ], templateUrl: './manage-open-idconnect.component.html', styleUrl: './manage-open-idconnect.component.scss', @@ -166,8 +168,16 @@ export class ManageOpenIDConnectComponent implements OnInit { return newSettings; } - save(showConfirmation: boolean = false) { - if (!this.settingsForm.valid || !this.serverSettings || !this.oidcSettings()) return; + save(showToasts: boolean = false) { + if (!this.settingsForm.valid) { + if (showToasts) { + this.toastr.error(translate('errors.invalid-form')); + } + + return; + } + + if (!this.serverSettings || !this.oidcSettings()) return; const newSettings = this.packData(); this.settingsService.updateServerSettings(newSettings).subscribe({ @@ -176,7 +186,7 @@ export class ManageOpenIDConnectComponent implements OnInit { this.oidcSettings.set(data.oidcConfig); this.cdRef.markForCheck(); - if (showConfirmation) { + if (showToasts) { this.toastr.success(translate('manage-oidc-connect.save-success')) } }, @@ -219,7 +229,9 @@ export class ManageOpenIDConnectComponent implements OnInit { const otherControl = this.settingsForm.get(other); if (!otherControl) return null; - if (otherControl.invalid) return null; + if (otherControl.invalid) { + return { 'requiredIfOtherInvalid': { 'other': other, 'errors': otherControl.errors } } + } const v = otherControl.value; if (!v || v.length === 0) return null; diff --git a/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.ts b/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.ts index 4eb02a73e..e590f1451 100644 --- a/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.ts +++ b/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.ts @@ -5,7 +5,6 @@ import { DestroyRef, ElementRef, EventEmitter, - HostListener, inject, Input, model, @@ -18,11 +17,12 @@ import {FormControl, FormGroup, ReactiveFormsModule, Validators} from "@angular/ import {ReaderService} from "../../../_services/reader.service"; import {ToastrService} from "ngx-toastr"; import {translate, TranslocoDirective} from "@jsverse/transloco"; -import {KEY_CODES} from "../../../shared/_services/utility.service"; import {EpubReaderMenuService} from "../../../_services/epub-reader-menu.service"; import {Annotation} from "../../_models/annotations/annotation"; import {isMobileChromium} from "../../../_helpers/browser"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {KeyBindService} from "../../../_services/key-bind.service"; +import {KeyBindTarget} from "../../../_models/preferences/preferences"; enum BookLineOverlayMode { None = 0, @@ -64,18 +64,7 @@ export class BookLineOverlayComponent implements OnInit { private readonly toastr = inject(ToastrService); private readonly elementRef = inject(ElementRef); private readonly epubMenuService = inject(EpubReaderMenuService); - - - @HostListener('window:keydown', ['$event']) - handleKeyPress(event: KeyboardEvent) { - if (event.key === KEY_CODES.ESC_KEY) { - this.reset(); - this.cdRef.markForCheck(); - event.stopPropagation(); - event.preventDefault(); - return; - } - } + private readonly keyBindService = inject(KeyBindService); ngOnInit() { @@ -90,6 +79,12 @@ export class BookLineOverlayComponent implements OnInit { // Fallback to mouse/touch events this.setupLegacyEventListeners(); } + + this.keyBindService.registerListener( + this.destroyRef, + () => this.reset(), + [KeyBindTarget.Escape], + ); } private setupPointerEventListener(): void { diff --git a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts index e5b5dc868..da259507a 100644 --- a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts +++ b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts @@ -8,7 +8,6 @@ import { effect, ElementRef, EventEmitter, - HostListener, inject, model, OnDestroy, @@ -31,7 +30,7 @@ import {CHAPTER_ID_DOESNT_EXIST, CHAPTER_ID_NOT_FETCHED, ReaderService} from 'sr import {SeriesService} from 'src/app/_services/series.service'; import {DomSanitizer, SafeHtml, Title} from '@angular/platform-browser'; import {BookService} from '../../_services/book.service'; -import {Breakpoint, KEY_CODES, UtilityService} from 'src/app/shared/_services/utility.service'; +import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service'; import {BookChapterItem} from '../../_models/book-chapter-item'; import {animate, state, style, transition, trigger} from '@angular/animations'; import {Stack} from 'src/app/shared/data-structures/stack'; @@ -69,6 +68,8 @@ import {environment} from "../../../../environments/environment"; import {LoadPageEvent} from "../_drawers/view-bookmarks-drawer/view-bookmark-drawer.component"; import {FontService} from "../../../_services/font.service"; import afterFrame from "afterframe"; +import {KeyBindService} from "../../../_services/key-bind.service"; +import {KeyBindTarget} from "../../../_models/preferences/preferences"; interface HistoryPoint { @@ -158,6 +159,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { private readonly layoutService = inject(LayoutMeasurementService); private readonly colorscapeService = inject(ColorscapeService); private readonly fontService = inject(FontService); + private readonly keyBindService = inject(KeyBindService); libraryId!: number; seriesId!: number; @@ -652,6 +654,56 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } } }); + + this.keyBindService.registerListener( + this.destroyRef, + async (e) => { + const activeElement = this.document.activeElement as HTMLElement; + const isInputFocused = activeElement.tagName === 'INPUT' + || activeElement.tagName === 'TEXTAREA' || + activeElement.contentEditable === 'true' || + activeElement.closest('.ql-editor'); // Quill editor class + + if (isInputFocused) { + e.triggered = false; + return; + } + + switch (e.target) { + case KeyBindTarget.PageLeft: + this.movePage(this.readingDirection() === ReadingDirection.LeftToRight ? PAGING_DIRECTION.BACKWARDS : PAGING_DIRECTION.FORWARD); + break; + case KeyBindTarget.PageRight: + this.movePage(this.readingDirection() === ReadingDirection.LeftToRight ? PAGING_DIRECTION.FORWARD : PAGING_DIRECTION.BACKWARDS); + break; + case KeyBindTarget.Escape: + const isHighlighting = window.getSelection()?.toString() != ''; + if (isHighlighting && this.isLineOverlayOpen()) return; + + this.closeReader(); + break; + case KeyBindTarget.GoTo: + await this.goToPage(); + break; + case KeyBindTarget.ToggleFullScreen: + this.applyFullscreen(); + break; + case KeyBindTarget.ToggleMenu: + this.actionBarVisible.update(x => !x); + break; + } + }, + [KeyBindTarget.PageLeft, KeyBindTarget.PageRight, KeyBindTarget.Escape, KeyBindTarget.GoTo, + KeyBindTarget.ToggleFullScreen, KeyBindTarget.ToggleMenu], + ); + + this.keyBindService.registerListener( + this.destroyRef, + () => { + this.toggleDrawer(); + }, + [KeyBindTarget.NavigateToSettings] + ); } /** @@ -996,41 +1048,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } } - @HostListener('window:keydown', ['$event']) - async handleKeyPress(event: KeyboardEvent) { - const activeElement = document.activeElement as HTMLElement; - const isInputFocused = activeElement.tagName === 'INPUT' - || activeElement.tagName === 'TEXTAREA' || - activeElement.contentEditable === 'true' || - activeElement.closest('.ql-editor'); // Quill editor class - - if (isInputFocused) return; - - switch (event.key) { - case KEY_CODES.RIGHT_ARROW: - this.movePage(this.readingDirection() === ReadingDirection.LeftToRight ? PAGING_DIRECTION.FORWARD : PAGING_DIRECTION.BACKWARDS); - break; - case KEY_CODES.LEFT_ARROW: - this.movePage(this.readingDirection() === ReadingDirection.LeftToRight ? PAGING_DIRECTION.BACKWARDS : PAGING_DIRECTION.FORWARD); - break; - case KEY_CODES.ESC_KEY: - const isHighlighting = window.getSelection()?.toString() != ''; - if (isHighlighting || this.isLineOverlayOpen()) return; - - this.closeReader(); - break; - case KEY_CODES.G: - await this.goToPage(); - break; - case KEY_CODES.F: - this.applyFullscreen(); - break; - case KEY_CODES.SPACE: - this.actionBarVisible.update(x => !x); - break; - } - } - onWheel(event: WheelEvent) { // This allows the user to scroll the page horizontally without holding shift if (this.layoutMode() !== BookPageLayoutMode.Default || this.writingStyle() !== WritingStyle.Vertical) { 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 6861695d1..be995be41 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 @@ -21,7 +21,7 @@ import { import {concat, forkJoin, Observable, of, tap} from 'rxjs'; import {map, switchMap} from 'rxjs/operators'; import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service'; -import {TypeaheadSettings} from 'src/app/typeahead/_models/typeahead-settings'; +import {setupLanguageSettings, TypeaheadSettings} from 'src/app/typeahead/_models/typeahead-settings'; import {Chapter, LooseLeafOrDefaultNumber, SpecialVolumeNumber} from 'src/app/_models/chapter'; import {Genre} from 'src/app/_models/metadata/genre'; import {AgeRatingDto} from 'src/app/_models/metadata/age-rating-dto'; @@ -165,7 +165,7 @@ export class EditSeriesModalComponent implements OnInit { // Typeaheads tagsSettings: TypeaheadSettings = new TypeaheadSettings(); - languageSettings: TypeaheadSettings = new TypeaheadSettings(); + languageSettings: TypeaheadSettings | null = null; peopleSettings: {[PersonRole: string]: TypeaheadSettings} = {}; genreSettings: TypeaheadSettings = new TypeaheadSettings(); @@ -173,7 +173,6 @@ export class EditSeriesModalComponent implements OnInit { genres: Genre[] = []; ageRatings: Array = []; publicationStatuses: Array = []; - validLanguages: Array = []; metadata!: SeriesMetadata; imageUrls: Array = []; @@ -439,33 +438,7 @@ export class EditSeriesModalComponent implements OnInit { return this.metadataService.getAllValidLanguages() .pipe( tap(validLanguages => { - this.validLanguages = validLanguages; - - this.languageSettings.minCharacters = 0; - this.languageSettings.multiple = false; - this.languageSettings.id = 'language'; - this.languageSettings.unique = true; - this.languageSettings.showLocked = true; - this.languageSettings.addIfNonExisting = false; - this.languageSettings.compareFn = (options: Language[], filter: string) => { - return options.filter(m => this.utilityService.filter(m.title, filter)); - } - this.languageSettings.compareFnForAdd = (options: Language[], filter: string) => { - return options.filter(m => this.utilityService.filterMatches(m.title, filter)); - } - this.languageSettings.fetchFn = (filter: string) => of(this.validLanguages) - .pipe(map(items => this.languageSettings.compareFn(items, filter))); - - this.languageSettings.selectionCompareFn = (a: Language, b: Language) => { - return a.isoCode == b.isoCode; - } - - const l = this.validLanguages.find(l => l.isoCode === this.metadata.language); - if (l !== undefined) { - this.languageSettings.savedData = l; - } - this.languageSettings.trackByIdentityFn = (index, value) => value.isoCode; - + this.languageSettings = setupLanguageSettings(true, this.utilityService, validLanguages, this.metadata.language); this.cdRef.markForCheck(); }), switchMap(_ => of(true)) @@ -596,7 +569,6 @@ export class EditSeriesModalComponent implements OnInit { updatePerson(persons: Person[], role: PersonRole) { this.metadataService.updatePerson(this.metadata, persons, role); - this.metadata.locationLocked = true; this.cdRef.markForCheck(); } diff --git a/UI/Web/src/app/chapter-detail/chapter-detail.component.html b/UI/Web/src/app/chapter-detail/chapter-detail.component.html index cc6546a36..3898ed275 100644 --- a/UI/Web/src/app/chapter-detail/chapter-detail.component.html +++ b/UI/Web/src/app/chapter-detail/chapter-detail.component.html @@ -24,6 +24,7 @@ [libraryType]="libraryType" [mangaFormat]="series.format" [totalBytes]="size" + [releaseYear]="(chapter.releaseDate | utcToLocaleDate)?.getFullYear()" /> @@ -105,7 +106,7 @@ @if (chapter.releaseDate !== '0001-01-01T00:00:00' && (libraryType === LibraryType.ComicVine || libraryType === LibraryType.Comic)) { {{t('release-date-title')}} } @else { {{t('cover-artists-title')}} diff --git a/UI/Web/src/app/chapter-detail/chapter-detail.component.ts b/UI/Web/src/app/chapter-detail/chapter-detail.component.ts index b1be20041..5afa85c6d 100644 --- a/UI/Web/src/app/chapter-detail/chapter-detail.component.ts +++ b/UI/Web/src/app/chapter-detail/chapter-detail.component.ts @@ -9,7 +9,7 @@ import { OnInit, ViewChild } from '@angular/core'; -import {AsyncPipe, DatePipe, DOCUMENT, Location, NgClass, NgStyle} from "@angular/common"; +import {AsyncPipe, DOCUMENT, Location, NgClass, NgStyle} from "@angular/common"; import {CardActionablesComponent} from "../_single-module/card-actionables/card-actionables.component"; import {LoadingComponent} from "../shared/loading/loading.component"; import { @@ -61,7 +61,7 @@ import { } from "../series-detail/_components/metadata-detail-row/metadata-detail-row.component"; import {DownloadButtonComponent} from "../series-detail/_components/download-button/download-button.component"; import {hasAnyCast} from "../_models/common/i-has-cast"; -import {Breakpoint, UserBreakpoint, UtilityService} from "../shared/_services/utility.service"; +import {UserBreakpoint, UtilityService} from "../shared/_services/utility.service"; import {EVENTS, MessageHubService} from "../_services/message-hub.service"; import {CoverUpdateEvent} from "../_models/events/cover-update-event"; import {ChapterRemovedEvent} from "../_models/events/chapter-removed-event"; @@ -79,6 +79,8 @@ import {Rating} from "../_models/rating"; import {AnnotationService} from "../_services/annotation.service"; import {Annotation} from "../book-reader/_models/annotations/annotation"; import {AnnotationsTabComponent} from "../_single-module/annotations-tab/annotations-tab.component"; +import {UtcToLocalTimePipe} from "../_pipes/utc-to-local-time.pipe"; +import {UtcToLocaleDatePipe} from "../_pipes/utc-to-locale-date.pipe"; enum TabID { Related = 'related-tab', @@ -115,12 +117,13 @@ enum TabID { BadgeExpanderComponent, MetadataDetailRowComponent, DownloadButtonComponent, - DatePipe, DefaultDatePipe, CoverImageComponent, ReviewsComponent, ExternalRatingComponent, - AnnotationsTabComponent + AnnotationsTabComponent, + UtcToLocalTimePipe, + UtcToLocaleDatePipe ], templateUrl: './chapter-detail.component.html', styleUrl: './chapter-detail.component.scss', diff --git a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts index fd48d6f18..77d258f91 100644 --- a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts +++ b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts @@ -12,6 +12,7 @@ import { model, OnDestroy, OnInit, + signal, Signal, ViewChild } from '@angular/core'; @@ -35,11 +36,11 @@ import { import {ChangeContext, LabelType, NgxSliderModule, Options} from '@angular-slider/ngx-slider'; import {animate, state, style, transition, trigger} from '@angular/animations'; import {FormBuilder, FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms'; -import {NgbModal, NgbTooltip} from '@ng-bootstrap/ng-bootstrap'; +import {NgbModal, NgbModalRef, NgbTooltip} from '@ng-bootstrap/ng-bootstrap'; import {ToastrService} from 'ngx-toastr'; import {ShortcutsModalComponent} from 'src/app/reader-shared/_modals/shortcuts-modal/shortcuts-modal.component'; import {Stack} from 'src/app/shared/data-structures/stack'; -import {Breakpoint, KEY_CODES, UtilityService} from 'src/app/shared/_services/utility.service'; +import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service'; import {LibraryType} from 'src/app/_models/library/library'; import {MangaFormat} from 'src/app/_models/manga-format'; import {PageSplitOption} from 'src/app/_models/preferences/page-split-option'; @@ -81,6 +82,8 @@ import { import {ReadingProfileService} from "../../../_services/reading-profile.service"; import {ConfirmService} from "../../../shared/confirm.service"; import {PageBookmark} from "../../../_models/readers/page-bookmark"; +import {KeyBindService} from "../../../_services/key-bind.service"; +import {KeyBindTarget} from "../../../_models/preferences/preferences"; const PREFETCH_PAGES = 10; @@ -166,6 +169,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { protected readonly readerService = inject(ReaderService); protected readonly utilityService = inject(UtilityService); protected readonly mangaReaderService = inject(MangaReaderService); + private readonly keyBindService = inject(KeyBindService); protected readonly KeyDirection = KeyDirection; @@ -504,8 +508,59 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { return chapterInfo?.chapterTitle || chapterInfo?.subtitle || ''; }); + this.keyBindService.registerListener( + this.destroyRef, + async (e) => { + switch (e.target) { + case KeyBindTarget.Escape: + if (this.menuOpen) { + this.toggleMenu(); + return; + } + if (this.shortCutModalOpen()){ + this.closeShortCutModal(); + return; + } + this.closeReader(); + break; + case KeyBindTarget.PageLeft: + this.handlePageLeft(); + break; + case KeyBindTarget.PageRight: + this.handlePageRight(); + break; + case KeyBindTarget.ToggleFullScreen: + this.toggleFullscreen(); + break; + case KeyBindTarget.BookmarkPage: + this.bookmarkPage(); + break; + case KeyBindTarget.GoTo: + const goToPageNum = await this.promptForPage(); + if (goToPageNum === null) { return; } + this.goToPage(parseInt(goToPageNum.trim(), 10)); + break; + case KeyBindTarget.ToggleMenu: + this.toggleMenu(); + break; + case KeyBindTarget.OpenHelp: + this.openShortcutModal(); + break; + } + }, + [KeyBindTarget.ToggleFullScreen, KeyBindTarget.BookmarkPage, KeyBindTarget.OpenHelp, KeyBindTarget.GoTo, + KeyBindTarget.ToggleMenu, KeyBindTarget.PageRight, KeyBindTarget.PageLeft, KeyBindTarget.Escape], + ); - + this.keyBindService.registerListener( + this.destroyRef, + () => { + this.toggleMenu(); + this.settingsOpen = !this.settingsOpen; + this.cdRef.markForCheck(); + }, + [KeyBindTarget.NavigateToSettings] + ); } ngOnInit(): void { @@ -615,6 +670,33 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.readerService.disableWakeLock(); } + private handlePageLeft() { + switch (this.readerMode) { + case ReaderMode.LeftRight: + if (this.checkIfPaginationAllowed(KeyDirection.Left)) { + this.readingDirection === ReadingDirection.LeftToRight ? this.prevPage() : this.nextPage(); + } + break + case ReaderMode.UpDown: + if (this.checkIfPaginationAllowed(KeyDirection.Down)) { + this.nextPage(); + } + } + } + + private handlePageRight() { + switch (this.readerMode) { + case ReaderMode.LeftRight: + if (this.checkIfPaginationAllowed(KeyDirection.Left)) { + this.readingDirection === ReadingDirection.LeftToRight ? this.nextPage() : this.prevPage(); + } + break + case ReaderMode.UpDown: + if (this.checkIfPaginationAllowed(KeyDirection.Down)) { + this.prevPage(); + } + } + } @HostListener('window:resize', ['$event']) @HostListener('window:orientationchange', ['$event']) @@ -622,54 +704,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.disableDoubleRendererIfScreenTooSmall(); } - @HostListener('window:keyup', ['$event']) - async handleKeyPress(event: KeyboardEvent) { - switch (this.readerMode) { - case ReaderMode.LeftRight: - if (event.key === KEY_CODES.RIGHT_ARROW) { - if (!this.checkIfPaginationAllowed(KeyDirection.Right)) return; - this.readingDirection === ReadingDirection.LeftToRight ? this.nextPage() : this.prevPage(); - } else if (event.key === KEY_CODES.LEFT_ARROW) { - if (!this.checkIfPaginationAllowed(KeyDirection.Left)) return; - this.readingDirection === ReadingDirection.LeftToRight ? this.prevPage() : this.nextPage(); - } - break; - case ReaderMode.UpDown: - if (event.key === KEY_CODES.UP_ARROW) { - if (!this.checkIfPaginationAllowed(KeyDirection.Up)) return; - this.prevPage(); - } else if (event.key === KEY_CODES.DOWN_ARROW) { - if (!this.checkIfPaginationAllowed(KeyDirection.Down)) return; - this.nextPage(); - } - break; - case ReaderMode.Webtoon: - break; - } - - if (event.key === KEY_CODES.ESC_KEY) { - if (this.menuOpen) { - this.toggleMenu(); - event.stopPropagation(); - event.preventDefault(); - return; - } - this.closeReader(); - } else if (event.key === KEY_CODES.SPACE) { - this.toggleMenu(); - } else if (event.key === KEY_CODES.G) { - const goToPageNum = await this.promptForPage(); - if (goToPageNum === null) { return; } - this.goToPage(parseInt(goToPageNum.trim(), 10)); - } else if (event.key === KEY_CODES.B) { - this.bookmarkPage(); - } else if (event.key === KEY_CODES.F) { - this.toggleFullscreen(); - } else if (event.key === KEY_CODES.H) { - this.openShortcutModal(); - } - } - setupReaderSettings() { if (this.readingProfile.kind === ReadingProfileKind.Implicit) { @@ -1824,20 +1858,34 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } } + shortCutModalOpen = signal(false); + shortCutModalRef: NgbModalRef | undefined; + + private closeShortCutModal() { + if (this.shortCutModalRef) { + this.shortCutModalRef.dismiss(); + this.shortCutModalRef = undefined; + } + } + // This is menu only code openShortcutModal() { - const ref = this.modalService.open(ShortcutsModalComponent, { scrollable: true, size: 'md' }); - ref.componentInstance.shortcuts = [ - {key: '⇽', description: 'prev-page'}, - {key: '⇾', description: 'next-page'}, - {key: '↑', description: 'prev-page'}, - {key: '↓', description: 'next-page'}, - {key: 'G', description: 'go-to'}, - {key: 'B', description: 'bookmark'}, + if (this.shortCutModalOpen()) return; + + this.shortCutModalOpen.set(true); + this.shortCutModalRef = this.modalService.open(ShortcutsModalComponent, { scrollable: true, size: 'md' }); + this.shortCutModalRef.componentInstance.shortcuts = [ + {keyBindTarget: KeyBindTarget.PageLeft, description: 'prev-page'}, + {keyBindTarget: KeyBindTarget.PageRight, description: 'next-page'}, + {keyBindTarget: KeyBindTarget.GoTo, description: 'go-to'}, + {keyBindTarget: KeyBindTarget.ToggleFullScreen}, + {keyBindTarget: KeyBindTarget.ToggleMenu}, + {keyBindTarget: KeyBindTarget.OpenHelp}, + {keyBindTarget: KeyBindTarget.BookmarkPage, description: 'bookmark'}, {key: translate('shortcuts-modal.double-click'), description: 'bookmark'}, - {key: 'ESC', description: 'close-reader'}, - {key: 'SPACE', description: 'toggle-menu'}, ]; + + merge(this.shortCutModalRef.closed, this.shortCutModalRef.dismissed).subscribe(() => this.shortCutModalOpen.set(false)); } // menu only code diff --git a/UI/Web/src/app/nav/_components/grouped-typeahead/grouped-typeahead.component.html b/UI/Web/src/app/nav/_components/grouped-typeahead/grouped-typeahead.component.html index 95c656d26..207a0b74c 100644 --- a/UI/Web/src/app/nav/_components/grouped-typeahead/grouped-typeahead.component.html +++ b/UI/Web/src/app/nav/_components/grouped-typeahead/grouped-typeahead.component.html @@ -17,7 +17,7 @@ } } @else {
- Ctrl+K + {{keyBindService.allKeyBinds().OpenSearch.at(0) | keyBind}}
} diff --git a/UI/Web/src/app/nav/_components/grouped-typeahead/grouped-typeahead.component.ts b/UI/Web/src/app/nav/_components/grouped-typeahead/grouped-typeahead.component.ts index 49658b135..6a20fb498 100644 --- a/UI/Web/src/app/nav/_components/grouped-typeahead/grouped-typeahead.component.ts +++ b/UI/Web/src/app/nav/_components/grouped-typeahead/grouped-typeahead.component.ts @@ -23,6 +23,9 @@ import {NgClass, NgTemplateOutlet} from '@angular/common'; import {TranslocoDirective} from "@jsverse/transloco"; import {map, startWith, tap} from "rxjs"; import {AccountService} from "../../../_services/account.service"; +import {KeyBindEvent, KeyBindService} from "../../../_services/key-bind.service"; +import {KeyBindTarget} from "../../../_models/preferences/preferences"; +import {KeyBindPipe} from "../../../_pipes/key-bind.pipe"; export interface SearchEvent { value: string; @@ -34,12 +37,13 @@ export interface SearchEvent { templateUrl: './grouped-typeahead.component.html', styleUrls: ['./grouped-typeahead.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ReactiveFormsModule, NgClass, NgTemplateOutlet, TranslocoDirective] + imports: [ReactiveFormsModule, NgClass, NgTemplateOutlet, TranslocoDirective, KeyBindPipe] }) export class GroupedTypeaheadComponent implements OnInit { private readonly destroyRef = inject(DestroyRef); private readonly cdRef = inject(ChangeDetectorRef); private readonly accountService = inject(AccountService); + protected readonly keyBindService = inject(KeyBindService); /** * Unique id to tie with a label element @@ -126,37 +130,36 @@ export class GroupedTypeaheadComponent implements OnInit { @HostListener('document:keydown', ['$event']) handleKeyPress(event: KeyboardEvent) { - - const isCtrlOrMeta = event.ctrlKey || event.metaKey; - - switch(event.key) { case KEY_CODES.ESC_KEY: if (!this.hasFocus) { return; } this.close(); event.stopPropagation(); break; - - case KEY_CODES.K: - if (isCtrlOrMeta) { - if (this.inputElem.nativeElement) { - event.preventDefault(); - event.stopPropagation(); - - this.inputElem.nativeElement.focus(); - this.inputElem.nativeElement.click(); - } - } - break; default: break; } } + private focusElement(e: KeyBindEvent) { + if (this.inputElem.nativeElement) { + e.triggered = true; + this.inputElem.nativeElement.focus(); + this.inputElem.nativeElement.click(); + } + } + ngOnInit(): void { this.typeaheadForm.get('typeahead')?.setValue(this.initialValue); this.cdRef.markForCheck(); + this.keyBindService.registerListener( + this.destroyRef, + (e) => this.focusElement(e), + [KeyBindTarget.OpenSearch], + {fireInEditable: true}, + ); + this.searchSettingsForm.get('includeExtras')!.valueChanges.pipe( startWith(false), map(val => { diff --git a/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.ts b/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.ts index 882094c37..c9688a543 100644 --- a/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.ts +++ b/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.ts @@ -7,11 +7,18 @@ import { HostListener, inject, OnDestroy, - OnInit, signal, + OnInit, + signal, ViewChild } from '@angular/core'; import {ActivatedRoute, Router} from '@angular/router'; -import {NgxExtendedPdfViewerModule, pdfDefaultOptions, PageViewModeType, ProgressBarEvent, ScrollModeType} from 'ngx-extended-pdf-viewer'; +import { + NgxExtendedPdfViewerModule, + PageViewModeType, + pdfDefaultOptions, + ProgressBarEvent, + ScrollModeType +} from 'ngx-extended-pdf-viewer'; import {ToastrService} from 'ngx-toastr'; import {take} from 'rxjs'; import {BookService} from 'src/app/book-reader/_services/book.service'; @@ -36,6 +43,8 @@ import {PdfSpreadTypePipe} from "../../_pipe/pdf-spread-mode.pipe"; import {ReadingProfileService} from "../../../_services/reading-profile.service"; import {ReadingProfile} from "../../../_models/preferences/reading-profiles"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {KeyBindService} from "../../../_services/key-bind.service"; +import {KeyBindTarget} from "../../../_models/preferences/preferences"; @Component({ selector: 'app-pdf-reader', @@ -61,6 +70,7 @@ export class PdfReaderComponent implements OnInit, OnDestroy { public readonly utilityService = inject(UtilityService); public readonly destroyRef = inject(DestroyRef); public readonly document = inject(DOCUMENT); + private readonly keyBindService = inject(KeyBindService); protected readonly ScrollModeType = ScrollModeType; protected readonly Breakpoint = Breakpoint; @@ -130,13 +140,12 @@ export class PdfReaderComponent implements OnInit, OnDestroy { this.navService.hideNavBar(); this.themeService.clearThemes(); this.navService.hideSideNav(); - } - @HostListener('window:keyup', ['$event']) - handleKeyPress(event: KeyboardEvent) { - if (event.key === KEY_CODES.ESC_KEY) { - this.closeReader(); - } + this.keyBindService.registerListener( + this.destroyRef, + () => this.closeReader(), + [KeyBindTarget.Escape], + ); } @HostListener('window:resize', ['$event']) diff --git a/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.html b/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.html index a9e6ea53e..c9cb71138 100644 --- a/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.html +++ b/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.html @@ -34,7 +34,7 @@
@if (editForm.get('malId'); as formControl) { - + @@ -45,7 +45,7 @@
@if (editForm.get('aniListId'); as formControl) { - + diff --git a/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.ts b/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.ts index 735f995d6..7a02bf7ee 100644 --- a/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.ts +++ b/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.ts @@ -9,7 +9,7 @@ import { ValidationErrors, Validators } from "@angular/forms"; -import {Person} from "../../../_models/metadata/person"; +import {Person, PersonRole} from "../../../_models/metadata/person"; import { NgbActiveModal, NgbNav, @@ -22,7 +22,7 @@ import { import {PersonService} from "../../../_services/person.service"; import {translate, TranslocoDirective} from '@jsverse/transloco'; import {CoverImageChooserComponent} from "../../../cards/cover-image-chooser/cover-image-chooser.component"; -import {concat, forkJoin, map, of} from "rxjs"; +import {concat, map, of} from "rxjs"; import {UploadService} from "../../../_services/upload.service"; import {SettingItemComponent} from "../../../settings/_components/setting-item/setting-item.component"; import {AccountService} from "../../../_services/account.service"; @@ -84,6 +84,11 @@ export class EditPersonModalComponent implements OnInit { coverImageReset = false; touchedCoverImage = false; fetchDisabled: boolean = false; + /** + * Suffix to include in the tooltip for external ids if they support characters + */ + tooltip: string = ''; + ngOnInit() { if (this.person) { @@ -97,6 +102,11 @@ export class EditPersonModalComponent implements OnInit { this.editForm.addControl('coverImageIndex', new FormControl(0, [])); this.editForm.addControl('coverImageLocked', new FormControl(this.person.coverImageLocked, [])); + const roles = (this.person.roles ?? []); + if (roles.length === 1 && roles.includes(PersonRole.Character)) { + this.tooltip = '-character'; + } + this.cdRef.markForCheck(); } else { alert('no person') diff --git a/UI/Web/src/app/reader-shared/_modals/shortcuts-modal/shortcuts-modal.component.html b/UI/Web/src/app/reader-shared/_modals/shortcuts-modal/shortcuts-modal.component.html index 362bce76a..d948fcdbe 100644 --- a/UI/Web/src/app/reader-shared/_modals/shortcuts-modal/shortcuts-modal.component.html +++ b/UI/Web/src/app/reader-shared/_modals/shortcuts-modal/shortcuts-modal.component.html @@ -1,18 +1,42 @@ diff --git a/UI/Web/src/app/reader-shared/_modals/shortcuts-modal/shortcuts-modal.component.ts b/UI/Web/src/app/reader-shared/_modals/shortcuts-modal/shortcuts-modal.component.ts index a4d37ceea..684532a4c 100644 --- a/UI/Web/src/app/reader-shared/_modals/shortcuts-modal/shortcuts-modal.component.ts +++ b/UI/Web/src/app/reader-shared/_modals/shortcuts-modal/shortcuts-modal.component.ts @@ -1,27 +1,36 @@ import {ChangeDetectionStrategy, Component, inject, Input} from '@angular/core'; import {NgbActiveModal, NgbModalModule} from '@ng-bootstrap/ng-bootstrap'; import {TranslocoDirective} from "@jsverse/transloco"; +import {KeyBindTarget} from "../../../_models/preferences/preferences"; +import {KeyBindService} from "../../../_services/key-bind.service"; +import {KeyBindPipe} from "../../../_pipes/key-bind.pipe"; +import {KeybindSettingDescriptionPipe} from "../../../_pipes/keybind-setting-description.pipe"; export interface KeyboardShortcut { /** * String representing key or key combo. Should use + for combos. Will render as upper case */ - key: string; + key?: string; /** * Description of how it works */ - description: string; + description?: string; + /** + * Keybind target, will display the first configured keybind instead of the given key + */ + keyBindTarget?: KeyBindTarget; } @Component({ - selector: 'app-shortcuts-modal', - imports: [NgbModalModule, TranslocoDirective], - templateUrl: './shortcuts-modal.component.html', - styleUrls: ['./shortcuts-modal.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush + selector: 'app-shortcuts-modal', + imports: [NgbModalModule, TranslocoDirective, KeyBindPipe, KeybindSettingDescriptionPipe], + templateUrl: './shortcuts-modal.component.html', + styleUrls: ['./shortcuts-modal.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush }) export class ShortcutsModalComponent { + protected readonly keyBindService = inject(KeyBindService); protected readonly modal = inject(NgbActiveModal); @Input() shortcuts: Array = []; diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html index 69c7e1214..926862077 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html @@ -133,7 +133,10 @@ @if (seriesMetadata.publicationStatus | publicationStatus; as pubStatus) { + [ngbTooltip]="t('publication-status-tooltip') + + ((seriesMetadata.totalCount === 0 || seriesMetadata.maxCount === LooseLeafOrSpecialNumber) + ? '' : ' (' + seriesMetadata.maxCount + ' / ' + seriesMetadata.totalCount + ')')" + > {{pubStatus}} } diff --git a/UI/Web/src/app/settings/_components/setting-key-bind-picker/setting-key-bind-picker.component.html b/UI/Web/src/app/settings/_components/setting-key-bind-picker/setting-key-bind-picker.component.html new file mode 100644 index 000000000..066a7f2df --- /dev/null +++ b/UI/Web/src/app/settings/_components/setting-key-bind-picker/setting-key-bind-picker.component.html @@ -0,0 +1,28 @@ + + + +
+ + @let keybind = selectedKeyBind() | keyBind; + @if (!this.isListening()) { + {{keybind | defaultValue}} + } @else { + {{keybind}} + } + + @if (this.isListening()) { + + } + + @if (duplicated()) { + + } + + @if (!control().valid) { + + } + +
+
diff --git a/UI/Web/src/app/settings/_components/setting-key-bind-picker/setting-key-bind-picker.component.scss b/UI/Web/src/app/settings/_components/setting-key-bind-picker/setting-key-bind-picker.component.scss new file mode 100644 index 000000000..cc826bc9c --- /dev/null +++ b/UI/Web/src/app/settings/_components/setting-key-bind-picker/setting-key-bind-picker.component.scss @@ -0,0 +1,18 @@ +.typing-cursor { + display: inline-block; + width: 1px; + height: 1em; + background-color: currentColor; + margin-left: 2px; + vertical-align: text-bottom; + animation: blink 1s step-end infinite; +} + +@keyframes blink { + 0%, 49% { + opacity: 1; + } + 50%, 100% { + opacity: 0; + } +} diff --git a/UI/Web/src/app/settings/_components/setting-key-bind-picker/setting-key-bind-picker.component.ts b/UI/Web/src/app/settings/_components/setting-key-bind-picker/setting-key-bind-picker.component.ts new file mode 100644 index 000000000..35ba2dbcd --- /dev/null +++ b/UI/Web/src/app/settings/_components/setting-key-bind-picker/setting-key-bind-picker.component.ts @@ -0,0 +1,161 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + effect, + ElementRef, + forwardRef, + inject, + input, + OnDestroy, + signal +} from '@angular/core'; +import {ControlValueAccessor, FormControl, NG_VALUE_ACCESSOR} from "@angular/forms"; +import {KeyBindService, KeyCode, ModifierKeyCodes} from "../../../_services/key-bind.service"; +import {KeyBind, KeyBindTarget} from "../../../_models/preferences/preferences"; +import {KeyBindPipe} from "../../../_pipes/key-bind.pipe"; +import {DOCUMENT} from "@angular/common"; +import {GamePadService} from "../../../_services/game-pad.service"; +import {filter, fromEvent, merge, Subscription, tap} from "rxjs"; +import {TagBadgeComponent, TagBadgeCursor} from "../../../shared/tag-badge/tag-badge.component"; +import {TranslocoDirective} from "@jsverse/transloco"; +import {DefaultValuePipe} from "../../../_pipes/default-value.pipe"; +import {AccountService} from "../../../_services/account.service"; +import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {debounceTime, take} from "rxjs/operators"; +import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; + +@Component({ + selector: 'app-setting-key-bind-picker', + imports: [ + KeyBindPipe, + TagBadgeComponent, + TranslocoDirective, + DefaultValuePipe, + NgbTooltip + ], + templateUrl: './setting-key-bind-picker.component.html', + styleUrl: './setting-key-bind-picker.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => SettingKeyBindPickerComponent), + multi: true, + } + ] +}) +export class SettingKeyBindPickerComponent implements ControlValueAccessor, OnDestroy { + + private readonly destroyRef = inject(DestroyRef); + protected readonly keyBindService = inject(KeyBindService); + private readonly gamePadService = inject(GamePadService); + private readonly accountService = inject(AccountService); + private readonly document = inject(DOCUMENT); + private readonly elementRef = inject(ElementRef); + + control = input.required>(); + target = input.required(); + index = input.required(); + duplicated = input.required(); + + selectedKeyBind = signal({key: KeyCode.Empty}); + disabled = signal(false); + + private _onChange: (value: KeyBind) => void = () => {}; + private _onTouched: () => void = () => {}; + protected readonly subscriptions = signal([]); + protected readonly isListening = computed(() => this.subscriptions().length > 0); + protected readonly tagBadgeCursor = computed(() => + this.accountService.isReadOnly() ? TagBadgeCursor.NotAllowed : TagBadgeCursor.Clickable); + + constructor() { + effect(() => { + const selectedKeys = this.selectedKeyBind(); + this._onChange(selectedKeys); + this._onTouched(); + }); + + fromEvent(this.document, 'click') + .pipe( + takeUntilDestroyed(this.destroyRef), + filter((event: Event) => { + return !this.elementRef.nativeElement.contains(event.target); + }), + filter(() => this.isListening()), + tap(() => this.stopListening()), + ).subscribe(); + + } + + writeValue(keyBind: KeyBind): void { + this.selectedKeyBind.set(keyBind) + } + + registerOnChange(fn: (_: KeyBind) => void): void { + this._onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this._onTouched = fn; + } + + setDisabledState?(isDisabled: boolean): void { + this.disabled.set(isDisabled); + } + + ngOnDestroy() { + this.keyBindService.disabled.set(false); + this.subscriptions().forEach(s => s.unsubscribe()); + } + + startListening() { + if (this.isListening() || this.accountService.isReadOnly()) return; + + this.keyBindService.disabled.set(true); + this.document.addEventListener('keydown', this.onKeyDown); + + const keydown$ = fromEvent(this.document, 'keydown').pipe( + tap((e) => this.onKeyDown(e as KeyboardEvent)), + ); + + const gamePad$ = this.gamePadService.keyDownEvents$.pipe( + tap(e => this.selectedKeyBind.set({ + key: KeyCode.Empty, + controllerSequence: e.pressedButtons, + })), + ); + + const sub = merge(keydown$, gamePad$).pipe( + takeUntilDestroyed(this.destroyRef), + debounceTime(700), + filter(() => this.control().valid), + take(1), + tap(() => this.stopListening()), + ).subscribe(); + + this.subscriptions.update(s => [sub, ...s]); + } + + stopListening() { + this.keyBindService.disabled.set(false); + this.subscriptions().forEach(s => s.unsubscribe()); + this.subscriptions.set([]); + } + + private onKeyDown = (event: KeyboardEvent) => { + const eventKey = event.key.toLowerCase() as KeyCode; + + this.selectedKeyBind.set({ + key: ModifierKeyCodes.includes(eventKey) ? KeyCode.Empty : eventKey, + meta: event.metaKey, + alt: event.altKey, + control: event.ctrlKey, + shift: event.shiftKey, + }); + + event.preventDefault(); + event.stopPropagation(); + }; +} diff --git a/UI/Web/src/app/settings/_components/settings/settings.component.html b/UI/Web/src/app/settings/_components/settings/settings.component.html index 659846fc5..0a19acf71 100644 --- a/UI/Web/src/app/settings/_components/settings/settings.component.html +++ b/UI/Web/src/app/settings/_components/settings/settings.component.html @@ -177,6 +177,14 @@ } } + @defer (when fragment === SettingsTabId.CustomKeyBinds; prefetch on idle) { + @if (fragment === SettingsTabId.CustomKeyBinds) { +
+ +
+ } + } + @defer (when fragment === SettingsTabId.Customize; prefetch on idle) { @if (fragment === SettingsTabId.Customize) {
diff --git a/UI/Web/src/app/settings/_components/settings/settings.component.ts b/UI/Web/src/app/settings/_components/settings/settings.component.ts index 6d1d605bd..ce820ca12 100644 --- a/UI/Web/src/app/settings/_components/settings/settings.component.ts +++ b/UI/Web/src/app/settings/_components/settings/settings.component.ts @@ -61,6 +61,7 @@ import { import {ImportMappingsComponent} from "../../../admin/import-mappings/import-mappings.component"; import {ManageOpenIDConnectComponent} from "../../../admin/manage-open-idconnect/manage-open-idconnect.component"; import {FontManagerComponent} from "../../../user-settings/font-manager/font-manager/font-manager.component"; +import {ManageCustomKeyBindsComponent} from "../../../user-settings/custom-key-binds/manage-custom-key-binds.component"; @Component({ selector: 'app-settings', @@ -101,7 +102,8 @@ import {FontManagerComponent} from "../../../user-settings/font-manager/font-man ManageOpenIDConnectComponent, ManagePublicMetadataSettingsComponent, ImportMappingsComponent, - FontManagerComponent + FontManagerComponent, + ManageCustomKeyBindsComponent ], templateUrl: './settings.component.html', styleUrl: './settings.component.scss', diff --git a/UI/Web/src/app/shared/_services/utility.service.ts b/UI/Web/src/app/shared/_services/utility.service.ts index 48545931e..65353853e 100644 --- a/UI/Web/src/app/shared/_services/utility.service.ts +++ b/UI/Web/src/app/shared/_services/utility.service.ts @@ -26,7 +26,10 @@ export enum KEY_CODES { K = 'k', BACKSPACE = 'Backspace', DELETE = 'Delete', - SHIFT = 'Shift' + SHIFT = 'Shift', + CONTROL = 'Control', + META = 'Meta', + ALT = 'Alt', } /** diff --git a/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts b/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts index 1dc24b6cc..a8a8456ad 100644 --- a/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts +++ b/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts @@ -1,4 +1,4 @@ -import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core'; +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, effect, inject, OnInit} from '@angular/core'; import {NavigationEnd, Router} from '@angular/router'; import {NgbTooltip} from '@ng-bootstrap/ng-bootstrap'; import {distinctUntilChanged, filter, map, take, tap} from 'rxjs/operators'; @@ -26,6 +26,8 @@ import {LicenseService} from "../../../_services/license.service"; import {CdkDrag, CdkDragDrop, CdkDropList} from "@angular/cdk/drag-drop"; import {ToastrService} from "ngx-toastr"; import {ReadingProfileService} from "../../../_services/reading-profile.service"; +import {KeyBindService} from "../../../_services/key-bind.service"; +import {KeyBindTarget} from "../../../_models/preferences/preferences"; @Component({ selector: 'app-side-nav', @@ -57,6 +59,7 @@ export class SideNavComponent implements OnInit { private readonly toastr = inject(ToastrService); private readonly readingProfilesService = inject(ReadingProfileService); private readonly translocoService = inject(TranslocoService); + private readonly keyBindService = inject(KeyBindService); cachedData: SideNavStream[] | null = null; @@ -146,6 +149,21 @@ export class SideNavComponent implements OnInit { this.navService.collapseSideNav(false); this.cdRef.markForCheck(); }); + + this.keyBindService.registerListener( + this.destroyRef, + (e) => this.router.navigate(['/settings'], { fragment: SettingsTabId.Account}), + [KeyBindTarget.NavigateToSettings], + {condition$: this.navService.sideNavVisibility$}, + ); + + this.keyBindService.registerListener( + this.destroyRef, + (e) => this.router.navigate(['/settings'], { fragment: SettingsTabId.Scrobbling}), + [KeyBindTarget.NavigateToScrobbling], + {condition$: this.licenseService.hasValidLicense$}, + ); + } ngOnInit(): void { @@ -257,7 +275,7 @@ export class SideNavComponent implements OnInit { const stream = $event.item.data; // Offset the home, back, and customize button - this.navService.updateSideNavStreamPosition(stream.name, stream.id, stream.order, $event.currentIndex - 3).subscribe({ + this.navService.updateSideNavStreamPosition(stream.name, stream.id, stream.order, $event.currentIndex - 3, false).subscribe({ next: () => { this.showAllSubject.next(this.showAll); this.cdRef.markForCheck(); diff --git a/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.html b/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.html index cf971e9ef..59cf538a3 100644 --- a/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.html +++ b/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.html @@ -74,7 +74,7 @@ {{t(TabID.Folder)}} - @if (filesAtRoot()) { + @if (filesAtRoot().length > 0) {

{{t('files-at-root-warning')}}

} @@ -83,7 +83,15 @@ @for(folder of selectedFolders; track folder) {
  • {{folder}} - + +
    + @if (filesAtRoot().includes(folder)) { + + } + + +
    +
  • } @@ -236,6 +244,23 @@
    +
    + + + @if (languageSettings) { + + + {{item.title}} + + + {{item.title}} ({{item.isoCode}}) + + + } + + +
    +
    diff --git a/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts b/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts index 6d0e0c0f8..10a7d8309 100644 --- a/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts +++ b/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts @@ -21,7 +21,7 @@ import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; import {ToastrService} from 'ngx-toastr'; -import {debounceTime, distinctUntilChanged, switchMap, tap} from 'rxjs'; +import {debounceTime, distinctUntilChanged, of, switchMap, tap} from 'rxjs'; import { DirectoryPickerComponent, DirectoryPickerResult @@ -54,6 +54,11 @@ import {Action, ActionFactoryService, ActionItem} from "../../../_services/actio import {ActionService} from "../../../_services/action.service"; import {LibraryTypePipe} from "../../../_pipes/library-type.pipe"; import {LibraryTypeSubtitlePipe} from "../../../_pipes/library-type-subtitle.pipe"; +import {TypeaheadComponent} from "../../../typeahead/_components/typeahead.component"; +import {setupLanguageSettings, TypeaheadSettings} from "../../../typeahead/_models/typeahead-settings"; +import {Language} from "../../../_models/metadata/language"; +import {map} from "rxjs/operators"; +import {MetadataService} from "../../../_services/metadata.service"; enum TabID { General = 'general-tab', @@ -74,7 +79,7 @@ enum StepID { selector: 'app-library-settings-modal', imports: [NgbModalModule, NgbNavLink, NgbNavItem, NgbNavContent, ReactiveFormsModule, NgbTooltip, SentenceCasePipe, NgbNav, NgbNavOutlet, CoverImageChooserComponent, TranslocoModule, DefaultDatePipe, - FileTypeGroupPipe, EditListComponent, SettingItemComponent, SettingSwitchComponent, SettingButtonComponent, LibraryTypeSubtitlePipe, NgTemplateOutlet, DatePipe], + FileTypeGroupPipe, EditListComponent, SettingItemComponent, SettingSwitchComponent, SettingButtonComponent, LibraryTypeSubtitlePipe, NgTemplateOutlet, DatePipe, TypeaheadComponent], templateUrl: './library-settings-modal.component.html', styleUrls: ['./library-settings-modal.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush @@ -93,6 +98,7 @@ export class LibrarySettingsModalComponent implements OnInit { private readonly imageService = inject(ImageService); private readonly actionFactoryService = inject(ActionFactoryService); private readonly actionService = inject(ActionService); + private readonly metadataService = inject(MetadataService); protected readonly LibraryType = LibraryType; protected readonly Breakpoint = Breakpoint; @@ -124,6 +130,7 @@ export class LibrarySettingsModalComponent implements OnInit { enableMetadata: new FormControl(true, { nonNullable: true, validators: [] }), // required validator doesn't check value, just if true removePrefixForSortName: new FormControl(false, { nonNullable: true, validators: [] }), inheritWebLinksFromFirstChapter: new FormControl(false, { nonNullable: true, validators: []}), + defaultLanguage: new FormControl('', {nonNullable: true, validators: []}), // TODO: Missing excludePatterns }); @@ -133,11 +140,13 @@ export class LibrarySettingsModalComponent implements OnInit { return {title: this.libraryTypePipe.transform(f), value: f}; }).sort((a, b) => a.title.localeCompare(b.title)); + languageSettings: TypeaheadSettings | null = null; + isAddLibrary = false; setupStep = StepID.General; fileTypeGroups = allFileTypeGroup; excludePatterns: Array = ['']; - filesAtRoot = model(false); + filesAtRoot = model>([]); tasks: ActionItem[] = this.getTasks(); @@ -159,8 +168,6 @@ export class LibrarySettingsModalComponent implements OnInit { if (this.library === undefined) { this.isAddLibrary = true; this.cdRef.markForCheck(); - } else { - this.checkForFilesAtRoot(); } if (this.library?.coverImage != null && this.library?.coverImage !== '') { @@ -201,6 +208,7 @@ export class LibrarySettingsModalComponent implements OnInit { this.setValues(); + this.setupLanguageTypeahead().subscribe(); // Turn on/off manage collections/rl this.libraryForm.get('enableMetadata')?.valueChanges.pipe( @@ -293,7 +301,9 @@ export class LibrarySettingsModalComponent implements OnInit { this.libraryForm.get('enableMetadata')?.setValue(this.library.enableMetadata); this.libraryForm.get('removePrefixForSortName')?.setValue(this.library.removePrefixForSortName); this.libraryForm.get('inheritWebLinksFromFirstChapter')?.setValue(this.library.inheritWebLinksFromFirstChapter); + this.libraryForm.get('defaultLanguage')?.setValue(this.library.defaultLanguage); this.selectedFolders = this.library.folders; + this.checkForFilesAtRoot(); // check after selectedFolders has been set this.madeChanges = false; @@ -321,6 +331,21 @@ export class LibrarySettingsModalComponent implements OnInit { this.cdRef.markForCheck(); } + setupLanguageTypeahead() { + return this.metadataService.getAllValidLanguages() + .pipe( + tap(validLanguages => { + this.languageSettings = setupLanguageSettings(false, this.utilityService, validLanguages, this.library?.defaultLanguage) + this.cdRef.markForCheck(); + }), + switchMap(_ => of(true)) + ); + } + + updateLanguage(languages: Array) { + this.libraryForm.get("defaultLanguage")!.setValue(languages.at(0)?.isoCode ?? ''); + } + updateGlobs(items: Array) { this.excludePatterns = items; this.cdRef.markForCheck(); @@ -427,7 +452,7 @@ export class LibrarySettingsModalComponent implements OnInit { if (!this.selectedFolders.includes(closeResult.folderPath)) { this.selectedFolders.push(closeResult.folderPath); this.madeChanges = true; - this.checkForFilesAtRoot(); + this.checkForFilesAtRoot(true); this.cdRef.markForCheck(); } } @@ -478,17 +503,14 @@ export class LibrarySettingsModalComponent implements OnInit { } } - checkForFilesAtRoot() { + checkForFilesAtRoot(showToast: boolean = false) { this.libraryService.hasFilesAtRoot(this.selectedFolders).subscribe(results => { - let containsMultipleFiles = false; - Object.keys(results).forEach(key => { - if (results[key]) { - containsMultipleFiles = true; - return; - } - }); + const newValues = results.filter(item => !this.filesAtRoot().includes(item)); + if (showToast && newValues.length > 0) { + this.toastr.error(translate('library-settings-modal.files-at-root-warning')) + } - this.filesAtRoot.set(containsMultipleFiles); + this.filesAtRoot.set(results); }) } } diff --git a/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.ts b/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.ts index 997e233b8..ca010a33a 100644 --- a/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.ts +++ b/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.ts @@ -24,6 +24,8 @@ import {Breakpoint, UtilityService} from "../../shared/_services/utility.service import {LicenseService} from "../../_services/license.service"; import {ManageService} from "../../_services/manage.service"; import {MatchStateOption} from "../../_models/kavitaplus/match-state-option"; +import {KeyBindService} from "../../_services/key-bind.service"; +import {KeyBindTarget} from "../../_models/preferences/preferences"; export enum SettingsTabId { @@ -52,6 +54,7 @@ export enum SettingsTabId { // Non-Admin Account = 'account', Preferences = 'preferences', + CustomKeyBinds = 'custom-key-binds', ReadingProfiles = 'reading-profiles', Font = 'font', Clients = 'clients', @@ -135,6 +138,7 @@ export class PreferenceNavComponent implements AfterViewInit { protected readonly utilityService = inject(UtilityService); private readonly manageService = inject(ManageService); private readonly document = inject(DOCUMENT); + private readonly keyBindService = inject(KeyBindService); /** * This links to settings.component.html which has triggers on what underlying component to render out. @@ -219,8 +223,9 @@ export class PreferenceNavComponent implements AfterViewInit { { title: SettingSectionId.AccountSection, children: [ - new SideNavItem(SettingsTabId.Account, []), + new SideNavItem(SettingsTabId.Account), new SideNavItem(SettingsTabId.Preferences), + new SideNavItem(SettingsTabId.CustomKeyBinds), new SideNavItem(SettingsTabId.ReadingProfiles), new SideNavItem(SettingsTabId.Customize, [], undefined, [Role.ReadOnly]), new SideNavItem(SettingsTabId.Clients), @@ -281,6 +286,14 @@ export class PreferenceNavComponent implements AfterViewInit { this.licenseService.hasValidLicenseSignal(); this.cdRef.markForCheck(); }); + + this.keyBindService.registerListener( + this.destroyRef, + () => this.router.navigate(['/settings'], { fragment: SettingsTabId.Scrobbling}) + .then(() => this.scrollToActiveItem()), + [KeyBindTarget.NavigateToScrobbling], + {condition$: this.licenseService.hasValidLicense$}, + ); } ngAfterViewInit() { diff --git a/UI/Web/src/app/typeahead/_components/typeahead.component.ts b/UI/Web/src/app/typeahead/_components/typeahead.component.ts index 9d6a26ae1..c4667becb 100644 --- a/UI/Web/src/app/typeahead/_components/typeahead.component.ts +++ b/UI/Web/src/app/typeahead/_components/typeahead.component.ts @@ -172,21 +172,22 @@ export class TypeaheadComponent implements OnInit { ); - if (this.settings.savedData) { - if (this.settings.multiple) { - this.optionSelection = new SelectionModel(true, this.settings.savedData); - } - else { - const isArray = this.settings.savedData.hasOwnProperty('length'); - if (isArray) { - this.optionSelection = new SelectionModel(true, this.settings.savedData); - } else { - this.optionSelection = new SelectionModel(true, [this.settings.savedData]); - } - } - } else { + if (!this.settings.savedData) { this.optionSelection = new SelectionModel(); + return; } + + if (this.settings.multiple) { + this.optionSelection = new SelectionModel(true, this.settings.savedData); + return; + } + + if (Array.isArray(this.settings.savedData)) { + this.optionSelection = new SelectionModel(true, this.settings.savedData); + return; + } + + this.optionSelection = new SelectionModel(true, [this.settings.savedData]); } diff --git a/UI/Web/src/app/typeahead/_models/typeahead-settings.ts b/UI/Web/src/app/typeahead/_models/typeahead-settings.ts index c7508574e..f21468c7c 100644 --- a/UI/Web/src/app/typeahead/_models/typeahead-settings.ts +++ b/UI/Web/src/app/typeahead/_models/typeahead-settings.ts @@ -1,5 +1,8 @@ -import {Observable} from 'rxjs'; +import {Observable, of} from 'rxjs'; import {FormControl} from '@angular/forms'; +import {Language} from "../../_models/metadata/language"; +import {map} from "rxjs/operators"; +import {UtilityService} from "../../shared/_services/utility.service"; export type SelectionCompareFn = (a: T, b: T) => boolean; @@ -70,3 +73,48 @@ export class TypeaheadSettings { */ trackByIdentityFn!: (index: number, value: T) => string; } + +/** + * Configure a new TypeaheadSettings as a language type ahead + * @param showLocked + * @param utilityService + * @param allLanguages + * @param currentSelectedLanguage + * @returns settings + */ +export function setupLanguageSettings( + showLocked: boolean, + utilityService: UtilityService, + allLanguages: Array, + currentSelectedLanguage: string | Array | undefined, +): TypeaheadSettings { + const settings = new TypeaheadSettings(); + + settings.minCharacters = 0; + settings.multiple = false; + settings.id = 'language'; + settings.unique = true; + settings.showLocked = showLocked; + settings.addIfNonExisting = false; + settings.compareFn = (options: Language[], filter: string) => { + return options.filter(m => utilityService.filter(m.title, filter)); + } + settings.compareFnForAdd = (options: Language[], filter: string) => { + return options.filter(m => utilityService.filterMatches(m.title, filter)); + } + settings.fetchFn = (filter: string) => of(allLanguages) + .pipe(map(items => settings.compareFn(items, filter))); + + settings.selectionCompareFn = (a: Language, b: Language) => { + return a.isoCode === b.isoCode; + } + + settings.trackByIdentityFn = (_, value) => value.isoCode; + + const l = allLanguages.find(l => l.isoCode === currentSelectedLanguage); + if (l !== undefined) { + settings.savedData = l; + } + + return settings; +} diff --git a/UI/Web/src/app/user-settings/custom-key-binds/manage-custom-key-binds.component.html b/UI/Web/src/app/user-settings/custom-key-binds/manage-custom-key-binds.component.html new file mode 100644 index 000000000..c6bdffc35 --- /dev/null +++ b/UI/Web/src/app/user-settings/custom-key-binds/manage-custom-key-binds.component.html @@ -0,0 +1,74 @@ + +

    + +

    + +
    + + @for (keyBindGroup of filteredKeyBindGroups(); track keyBindGroup.title) { + @if (!$first) { +
    + } + +

    {{t(keyBindGroup.title)}}

    + + @for (element of keyBindGroup.elements; track element.target) { + @if (getFormArray(element.target); as array) { +
    + @let settingDesc = element.target | keybindSettingDescription; + + +
    +
    + @for (keyBindControl of array.controls; track $index;) { + + } @empty { + {{null | defaultValue}} + } +
    + +
    + @if (array.controls.length < MAX_KEYBINDS_PER_TARGET) { + + } + + + + @if (!array.valid) { + + + + } +
    +
    +
    +
    +
    + } + } + } +
    + +
    diff --git a/UI/Web/src/app/user-settings/custom-key-binds/manage-custom-key-binds.component.scss b/UI/Web/src/app/user-settings/custom-key-binds/manage-custom-key-binds.component.scss new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/UI/Web/src/app/user-settings/custom-key-binds/manage-custom-key-binds.component.scss @@ -0,0 +1 @@ + diff --git a/UI/Web/src/app/user-settings/custom-key-binds/manage-custom-key-binds.component.ts b/UI/Web/src/app/user-settings/custom-key-binds/manage-custom-key-binds.component.ts new file mode 100644 index 000000000..a1b1c0cc3 --- /dev/null +++ b/UI/Web/src/app/user-settings/custom-key-binds/manage-custom-key-binds.component.ts @@ -0,0 +1,277 @@ +import {ChangeDetectionStrategy, Component, computed, DestroyRef, inject, OnInit, signal} from '@angular/core'; +import {DefaultKeyBinds, KeyBindGroups, KeyBindService, KeyCode,} from "../../_services/key-bind.service"; +import { + FormArray, + FormControl, + FormGroup, + NonNullableFormBuilder, + ReactiveFormsModule, + ValidationErrors, + ValidatorFn +} from "@angular/forms"; +import {KeyBind, KeyBindTarget, Preferences} from "../../_models/preferences/preferences"; +import {TranslocoDirective, TranslocoService} from "@jsverse/transloco"; +import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component"; +import { + SettingKeyBindPickerComponent +} from "../../settings/_components/setting-key-bind-picker/setting-key-bind-picker.component"; +import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {catchError, debounceTime, distinctUntilChanged, filter, of, switchMap, tap} from "rxjs"; +import {map} from "rxjs/operators"; +import {AccountService} from "../../_services/account.service"; +import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; +import {LongClickDirective} from "../../_directives/long-click.directive"; +import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; +import {ToastrService} from "ngx-toastr"; +import {LicenseService} from "../../_services/license.service"; +import {KeybindSettingDescriptionPipe} from "../../_pipes/keybind-setting-description.pipe"; +import {DOCUMENT} from "@angular/common"; +import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe"; + +type KeyBindFormGroup = FormGroup<{ + [K in KeyBindTarget]: FormArray> +}>; + +const MAX_KEYBINDS_PER_TARGET = 5; + +@Component({ + selector: 'app-manage-custom-key-binds', + imports: [ + ReactiveFormsModule, + SettingItemComponent, + SettingKeyBindPickerComponent, + DefaultValuePipe, + NgbTooltip, + KeybindSettingDescriptionPipe, + TranslocoDirective, + LongClickDirective, + SafeHtmlPipe + ], + templateUrl: './manage-custom-key-binds.component.html', + styleUrl: './manage-custom-key-binds.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ManageCustomKeyBindsComponent implements OnInit { + + private readonly accountService = inject(AccountService); + protected readonly keyBindService = inject(KeyBindService); + private readonly transLoco = inject(TranslocoService); + private readonly fb = inject(NonNullableFormBuilder); + private readonly toastr = inject(ToastrService); + private readonly destroyRef = inject(DestroyRef); + private readonly licenseService = inject(LicenseService); + private readonly document = inject(DOCUMENT); + + protected keyBindForm!: KeyBindFormGroup; + + protected duplicatedKeyBinds = signal>>({}); + protected filteredKeyBindGroups = computed(() => { + const roles = this.accountService.currentUserSignal()!.roles; + const hasKPlus = this.licenseService.hasValidLicenseSignal(); + + return KeyBindGroups.map(g => { + g.elements = g.elements.filter(e => { + if (e.roles && !e.roles.some(r => roles.includes(r))) return false; + if (e.restrictedRoles && e.restrictedRoles.some(r => roles.includes(r))) return false; + + return hasKPlus || !e.kavitaPlus; + }) + return g; + }).filter(g => g.elements.length > 0); + }); + + ngOnInit() { + const keyBinds = this.keyBindService.allKeyBinds(); + const groupConfig = Object.entries(keyBinds).reduce((acc, [key, value]) => { + acc[key as KeyBindTarget] = this.fb.array(this.toFormControls(value), this.keyBindArrayValidator()); + return acc; + }, {} as Record>>); + + this.keyBindForm = this.fb.group(groupConfig); + this.duplicatedKeyBinds.set(this.extractDuplicated(keyBinds)); // Set initial + + this.keyBindForm.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef), + debounceTime(250), + distinctUntilChanged(), + map(formValue => this.extractDuplicated(formValue)), + tap(d => this.duplicatedKeyBinds.set(d)), + ).subscribe(); + + this.keyBindForm.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef), + debounceTime(500), + distinctUntilChanged(), + filter(() => this.keyBindForm.valid), + map(formValue => this.extractCustomKeyBinds(formValue)), + map(customKeyBinds => this.combinePreferences(customKeyBinds)), + switchMap(p => this.accountService.updatePreferences(p)), + catchError(err => { + console.error(err); + this.toastr.error(err); + + return of(null); + }), + ).subscribe(); + } + + private extractDuplicated(formValue: Partial>): Partial> { + const entries = Object.entries(formValue); + + return Object.fromEntries(entries + .map(([target, keyBinds]) => { + const duplicatedIndices = keyBinds.map((keyBind, index) => { + const isDuplicated = entries.some(([otherTarget, otherKeyBinds]) => { + if (otherTarget === target) return false; + + return otherKeyBinds.some(kb => this.keyBindService.areKeyBindsEqual(keyBind, kb)); + }); + + return isDuplicated ? index : -1; + }) + .filter(index => index !== -1) ?? []; + + return [target, duplicatedIndices]; + }) + .filter(([_, indices]) => (indices as number[]).length > 0) + ) as Partial>; + } + + private extractCustomKeyBinds(formValue: Partial>): Partial> { + return Object.fromEntries( + Object.entries(formValue).filter(([target, keybinds]) => + !this.keyBindService.isDefaultKeyBinds(target as KeyBindTarget, keybinds) + ) + ) as Partial>; + } + + private combinePreferences(customKeyBinds: Partial>): Preferences { + return { + ...this.accountService.currentUserSignal()!.preferences, + customKeyBinds, + }; + } + + private toFormControls(keybinds: KeyBind[]): FormControl[] { + return keybinds.map(keyBind => this.fb.control(keyBind, this.keyBindValidator())); + } + + /** + * Typed getter for the FormArray of a given target + * @param key + */ + getFormArray(key: KeyBindTarget): FormArray> | null { + return this.keyBindForm.get(key) as FormArray> | null; + } + + /** + * Reset keybinds to default configured values + * @param key + */ + resetKeybindsToDefaults(key: KeyBindTarget) { + if (this.accountService.isReadOnly()) return; + + this.keyBindForm.setControl(key, this.fb.array(this.toFormControls(DefaultKeyBinds[key]), this.keyBindArrayValidator())); + } + + /** + * Add a new keybind option to the array, NOP if MAX_KEYBINDS_PER_TARGET has been reached + * @param key + */ + addKeyBind(key: KeyBindTarget) { + if (this.accountService.isReadOnly()) return; + + const array = this.getFormArray(key); + if (!array) return; + + if (array.controls.length < MAX_KEYBINDS_PER_TARGET) { + array.push(this.fb.control({key: KeyCode.Empty}, this.keyBindValidator())); + } + + setTimeout(() => { + const id = `key-bind-${key}-${array.length-1}`; + const newElement = this.document.getElementById(id); + if (newElement) { + newElement.focus(); + } + + }, 100); + } + + /** + * Remove a keybind from the array, if this is the last keybind. Resets to default + * @param key + * @param index + */ + removeKeyBind(key: KeyBindTarget, index: number) { + if (this.accountService.isReadOnly()) return; + + const array = this.getFormArray(key); + if (!array) return; + + if (array.controls.length === 1) { + this.resetKeybindsToDefaults(key); + } else { + array.removeAt(index) + } + } + + /** + * Custom validator for FormControl + * @private + */ + private keyBindValidator(): ValidatorFn { + return (control) => { + const keyBind = (control as FormControl).value; + if (keyBind.key.length === 0 && !keyBind.controllerSequence) return { 'need-at-least-one-key': {'length': 0} } as ValidationErrors; + + if (this.keyBindService.isReservedKeyBind(keyBind)) { + return { 'reserved-key-bind': { 'keyBind': keyBind }} as ValidationErrors + } + + return null; + } + } + + private keyBindArrayValidator(): ValidatorFn { + return (control) => { + const controls = (control as FormArray>).controls; + + const anyOverlap = controls.some((c, i) => controls.some((c2, i2)=> { + return i !== i2 && this.keyBindService.areKeyBindsEqual(c.value, c2.value); + })) + + if (anyOverlap) { + return { 'overlap-in-target': { '': '' } } + } + + return null; + + } + } + + /** + * Combined tooltip for FormControl errors + * @param target + * @param index + * @param errors + * @protected + */ + protected errorToolTip(target: KeyBindTarget, index: number, errors: ValidationErrors | null): string | null { + if (errors) { + return Object.keys(errors) + .map(key => this.transLoco.translate(`manage-custom-key-binds.key-bind-error-${key}`)) + .join(' ') + .trim() || null; + } + + if (this.duplicatedKeyBinds()[target]?.includes(index)) { + return this.transLoco.translate('manage-custom-key-binds.warning-duplicate-key-bind'); + } + + return null; + } + + protected readonly Object = Object; + protected readonly MAX_KEYBINDS_PER_TARGET = MAX_KEYBINDS_PER_TARGET; +} diff --git a/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.ts b/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.ts index 22da8f0c6..64299eb2b 100644 --- a/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.ts +++ b/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.ts @@ -223,6 +223,10 @@ export class ManageUserPreferencesComponent implements OnInit { } packSettings(): Preferences { - return this.settingsForm.getRawValue(); + const customKeyBinds = this.accountService.currentUserSignal()!.preferences.customKeyBinds; + return { + customKeyBinds, + ...this.settingsForm.getRawValue(), + }; } } diff --git a/UI/Web/src/app/volume-detail/volume-detail.component.html b/UI/Web/src/app/volume-detail/volume-detail.component.html index b3c01f625..c5224cf16 100644 --- a/UI/Web/src/app/volume-detail/volume-detail.component.html +++ b/UI/Web/src/app/volume-detail/volume-detail.component.html @@ -28,6 +28,7 @@ [libraryType]="libraryType" [mangaFormat]="series.format" [totalBytes]="size" + [releaseYear]="(volume.chapters.at(0)?.releaseDate | utcToLocaleDate)?.getFullYear()" /> @if (libraryType !== null && series && volume.chapters.length === 1) { diff --git a/UI/Web/src/app/volume-detail/volume-detail.component.ts b/UI/Web/src/app/volume-detail/volume-detail.component.ts index 77009362c..87b8bd81a 100644 --- a/UI/Web/src/app/volume-detail/volume-detail.component.ts +++ b/UI/Web/src/app/volume-detail/volume-detail.component.ts @@ -85,6 +85,7 @@ import {User} from "../_models/user"; import {AnnotationService} from "../_services/annotation.service"; import {Annotation} from "../book-reader/_models/annotations/annotation"; import {AnnotationsTabComponent} from "../_single-module/annotations-tab/annotations-tab.component"; +import {UtcToLocaleDatePipe} from "../_pipes/utc-to-locale-date.pipe"; enum TabID { @@ -158,7 +159,8 @@ interface VolumeCast extends IHasCast { CoverImageComponent, ReviewsComponent, ExternalRatingComponent, - AnnotationsTabComponent + AnnotationsTabComponent, + UtcToLocaleDatePipe ], templateUrl: './volume-detail.component.html', styleUrl: './volume-detail.component.scss', diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index bdf910aa7..4620eeafd 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -25,6 +25,7 @@ "provider-tooltip": "Provider settings require you to manually click Save. Kavita must be configured as a confidential client and needs a redirect URL. See the wiki for more details.", "behavior-title": "Behavior", "other-field-required": "{{validation.other-field-required}}", + "other-field-invalid": "{{validation.other-field-invalid}}", "invalid-uri": "{{validation.invalid-uri}}", "tls-required": "The OIDC provider must use tls (https)", "manual-save-label": "Changing provider settings requires a manual save", @@ -1370,6 +1371,8 @@ "remove-prefix-for-sortname-tooltip": "Kavita will remove common prefixes like 'The', 'A', 'An' from titles for sort name. Does not override set metadata.", "inherit-web-links-label": "Inherit web links from first chapter", "inherit-web-links-tooltip": "Should series inherit web links from their first chapter", + "default-language-label": "Default language", + "default-language-tooltip": "Language to assign to series, if none of the chapters have any language set in their metadata", "force-scan": "Force Scan", "force-scan-tooltip": "This will force a scan on the library, treating like a fresh scan", "reset": "{{common.reset}}", @@ -1958,6 +1961,7 @@ "scrobble-holds": "Scrobble Holds", "account": "Account", "preferences": "Preferences", + "custom-key-binds": "Key Binds", "reading-profiles": "Reading Profiles", "clients": "API Key / OPDS", "devices": "Devices", @@ -1971,6 +1975,49 @@ "admin-public-metadata": "Manage Metadata" }, + "manage-custom-key-binds": { + "description": "Sometimes clicking and dragging your mouse is just too slow. Use your preferred keybinds to speed things up. Each option allows for up to {{max}} different keybinds. Long press a keybind to remove it. ", + "key-bind-error-reserved-key-bind": "This keybind is reserved and cannot be used", + "key-bind-error-need-at-least-one-key": "Your keybind must contain at least one key", + "key-bind-error-overlap-in-target": "One or more keybinds are the same", + "add": "Add alternative key", + "reset": "Reset {{target}}", + "errors-array": "Keybinds for {{target}} contain errors", + "errors-control": "Keybind {{index}} for {{target}} contains errors", + "warning-duplicated-control": "Keybind {{index}} for {{target}} has been used for an other target as well", + "warning-duplicate-key-bind": "This keybind has also been used elsewhere", + "key-bind-tooltip": "Long press a keybind to remove it.", + + "global-header": "Global", + "readers-header": "Readers" + }, + + "keybind-setting-description-pipe": { + "key-bind-title-navigate-to-settings": "Open settings", + "key-bind-tooltip-navigate-to-settings": "Open settings while not in a reader", + "key-bind-title-open-search": "Open search", + "key-bind-tooltip-open-search": "Open the top search bar", + "key-bind-title-navigate-to-scrobbling": "Open Scrobbling", + "key-bind-tooltip-navigate-to-scrobbling": "Open settings page on the Scrobbling tab", + "key-bind-title-escape": "Escape", + "key-bind-tooltip-escape": "Close the currently open context", + + "key-bind-title-toggle-fullscreen": "Toggle full screen", + "key-bind-tooltip-toggle-fullscreen": "Alternative to F11", + "key-bind-title-bookmark-page": "Bookmark current page", + "key-bind-tooltip-bookmark-page": "Saves the current image as a bookmark", + "key-bind-title-open-help": "Open help menu", + "key-bind-tooltip-open-help": "Opens a help modal with all relevant keybinds", + "key-bind-title-go-to": "Goto page", + "key-bind-tooltip-go-to": "Open a prompt to switch pages", + "key-bind-title-toggle-menu": "Toggle menu", + "key-bind-tooltip-toggle-menu": "Toggles to reader menu", + "key-bind-title-page-left": "Page left", + "key-bind-tooltip-page-left": "Move one page to the left", + "key-bind-title-page-right": "Page right", + "key-bind-tooltip-page-right": "Move one page to the right" + }, + "collection-detail": { "no-data": "There are no items. Try adding a series.", "no-data-filtered": "No items match your current filter.", @@ -2559,8 +2606,10 @@ "role-label": "Role", "mal-id-label": "MAL Id", "mal-tooltip": "https://myanimelist.net/people/{MalId}/", + "mal-tooltip-character": "https://myanimelist.net/character/{MalId}/", "anilist-id-label": "AniList Id", "anilist-tooltip": "https://anilist.co/staff/{AniListId}/", + "anilist-tooltip-character": "https://anilist.co/character/{AniListId}/", "hardcover-id-label": "Hardcover Id", "hardcover-tooltip": "https://hardcover.app/authors/{HardcoverId}", "asin-label": "ASIN", @@ -2720,6 +2769,7 @@ "font-manual-upload": "There was an issue creating Font from manual upload", "font-already-in-use": "Font already exists by that name", "upload-too-large": "The file is too large for upload, select a smaller image and try again.", + "invalid-form": "The form you're trying to submit contains errors", "import-fields": { "non-unique-age-ratings": "Age rating mapping keys aren't unique, please correct your import file", "non-unique-fields": "Field mappings do not have a unique id, please correct your import file" @@ -3321,6 +3371,7 @@ "validation": { "required-field": "This field is required", "other-field-required": "{{name}} is required when {{other}} is set", + "other-field-invalid": "Cannot check validity, {{other}} is invalid", "valid-email": "This must be a valid email", "password-validation": "Password must be between 6 and 256 characters in length", "year-validation": "This must be a valid year greater than 1000 and 4 characters long",