From d04b8a09a14006c69d9e63441ee977d1bfd66c38 Mon Sep 17 00:00:00 2001 From: Joe Milazzo Date: Sun, 21 Sep 2025 11:35:28 -0500 Subject: [PATCH] Epub Font Manager (#4037) Co-authored-by: Amelia <77553571+Fesaa@users.noreply.github.com> --- .gitignore | 1 + API/API.csproj | 1 + API/Controllers/FontController.cs | 158 + API/Controllers/ThemeController.cs | 2 +- API/DTOs/Font/EpubFontDto.cs | 13 + API/Data/DataContext.cs | 2 + ...20250920212509_CustomEpubFonts.Designer.cs | 3889 +++++++++++++++++ .../20250920212509_CustomEpubFonts.cs | 42 + .../Migrations/DataContextModelSnapshot.cs | 35 + API/Data/Repositories/EpubFontRepository.cs | 102 + API/Data/Repositories/UserRepository.cs | 9 + API/Data/Seed.cs | 102 +- API/Data/UnitOfWork.cs | 3 + API/Entities/AppUserPreferences.cs | 3 +- API/Entities/AppUserReadingProfile.cs | 3 +- API/Entities/Enums/Font/FontProvider.cs | 13 + API/Entities/EpubFont.cs | 37 + .../ApplicationServiceExtensions.cs | 1 + API/Helpers/AutoMapperProfiles.cs | 3 + API/I18N/en.json | 4 +- API/Program.cs | 1 + API/Services/BookService.cs | 6 +- API/Services/DirectoryService.cs | 5 + API/Services/FontService.cs | 264 ++ API/Services/{Tasks => }/SiteThemeService.cs | 0 API/Services/Tasks/BackupService.cs | 23 + API/Services/Tasks/Scanner/Parser/Parser.cs | 9 + .../src/app/_models/preferences/epub-font.ts | 17 + UI/Web/src/app/_models/wiki.ts | 1 + .../_services/epub-reader-settings.service.ts | 28 +- UI/Web/src/app/_services/font.service.ts | 66 + .../book-reader/book-reader.component.scss | 56 - .../book-reader/book-reader.component.ts | 10 + .../reader-settings.component.html | 4 +- .../reader-settings.component.ts | 15 +- .../app/book-reader/_services/book.service.ts | 18 - .../settings/settings.component.html | 8 + .../settings/settings.component.ts | 4 +- .../preference-nav.component.ts | 2 + .../font-manager/font-manager.component.html | 154 + .../font-manager/font-manager.component.scss | 48 + .../font-manager/font-manager.component.ts | 214 + .../manage-reading-profiles.component.html | 4 +- .../manage-reading-profiles.component.ts | 47 +- .../manage-user-preferences.component.ts | 4 - .../EBGaramond-Italic-VariableFont_wght.ttf | Bin .../EBGaramond-Italic-VariableFont_wght.woff2 | Bin .../EBGaramond-VariableFont_wght.ttf | Bin .../EBGaramond-VariableFont_wght.woff2 | Bin .../fonts/{EBGarmond => EB Garamond}/OFL.txt | 0 .../Fast_Sans.woff2 | Bin .../Fast_Serif.woff2 | Bin .../FiraSans-Black.ttf | Bin .../FiraSans-Black.woff2 | Bin .../FiraSans-BlackItalic.ttf | Bin .../FiraSans-BlackItalic.woff2 | Bin .../FiraSans-Bold.ttf | Bin .../FiraSans-Bold.woff2 | Bin .../FiraSans-BoldItalic.ttf | Bin .../FiraSans-BoldItalic.woff2 | Bin .../FiraSans-ExtraBold.ttf | Bin .../FiraSans-ExtraBold.woff2 | Bin .../FiraSans-ExtraBoldItalic.ttf | Bin .../FiraSans-ExtraBoldItalic.woff2 | Bin .../FiraSans-ExtraLight.ttf | Bin .../FiraSans-ExtraLight.woff2 | Bin .../FiraSans-ExtraLightItalic.ttf | Bin .../FiraSans-ExtraLightItalic.woff2 | Bin .../FiraSans-Italic.ttf | Bin .../FiraSans-Italic.woff2 | Bin .../FiraSans-Light.ttf | Bin .../FiraSans-Light.woff2 | Bin .../FiraSans-LightItalic.ttf | Bin .../FiraSans-LightItalic.woff2 | Bin .../FiraSans-Medium.ttf | Bin .../FiraSans-Medium.woff2 | Bin .../FiraSans-MediumItalic.ttf | Bin .../FiraSans-MediumItalic.woff2 | Bin .../FiraSans-Regular.ttf | Bin .../FiraSans-Regular.woff2 | Bin .../FiraSans-SemiBold.ttf | Bin .../FiraSans-SemiBold.woff2 | Bin .../FiraSans-SemiBoldItalic.ttf | Bin .../FiraSans-SemiBoldItalic.woff2 | Bin .../FiraSans-Thin.ttf | Bin .../FiraSans-Thin.woff2 | Bin .../FiraSans-ThinItalic.ttf | Bin .../FiraSans-ThinItalic.woff2 | Bin .../fonts/{Fira_Sans => Fira Sans}/OFL.txt | 0 .../LibreBaskerville-Bold.ttf | Bin .../LibreBaskerville-Bold.woff2 | Bin .../LibreBaskerville-Italic.ttf | Bin .../LibreBaskerville-Italic.woff2 | Bin .../LibreBaskerville-Regular.ttf | Bin .../LibreBaskerville-Regular.woff2 | Bin .../OFL.txt | 0 .../LibreCaslonText-Bold.ttf | Bin .../LibreCaslonText-Bold.woff2 | Bin .../LibreCaslonText-Italic.ttf | Bin .../LibreCaslonText-Italic.woff2 | Bin .../LibreCaslonText-Regular.ttf | Bin .../LibreCaslonText-Regular.woff2 | Bin .../{Libre_Caslon => Libre Caslon}/OFL.txt | 0 .../NanumGothic-Bold.ttf | Bin .../NanumGothic-Bold.woff2 | Bin .../NanumGothic-ExtraBold.ttf | Bin .../NanumGothic-ExtraBold.woff2 | Bin .../NanumGothic-Regular.ttf | Bin .../NanumGothic-Regular.woff2 | Bin .../{Nanum_Gothic => Nanum Gothic}/OFL.txt | 0 .../OpenDyslexic-Bold.otf | Bin .../OpenDyslexic-Bold.woff2 | Bin .../OpenDyslexic-BoldItalic.otf | Bin .../OpenDyslexic-BoldItalic.woff2 | Bin .../OpenDyslexic-Italic.otf | Bin .../OpenDyslexic-Italic.woff2 | Bin .../OpenDyslexic-Regular.otf | Bin .../OpenDyslexic-Regular.woff2 | Bin .../{RocknRoll_One => RocknRoll One}/OFL.txt | 0 .../RocknRollOne-Regular.ttf | Bin .../RocknRollOne-Regular.woff2 | Bin UI/Web/src/assets/langs/en.json | 30 +- UI/Web/src/styles.scss | 2 +- openapi.json | 2 +- 124 files changed, 5329 insertions(+), 136 deletions(-) create mode 100644 API/Controllers/FontController.cs create mode 100644 API/DTOs/Font/EpubFontDto.cs create mode 100644 API/Data/Migrations/20250920212509_CustomEpubFonts.Designer.cs create mode 100644 API/Data/Migrations/20250920212509_CustomEpubFonts.cs create mode 100644 API/Data/Repositories/EpubFontRepository.cs create mode 100644 API/Entities/Enums/Font/FontProvider.cs create mode 100644 API/Entities/EpubFont.cs create mode 100644 API/Services/FontService.cs rename API/Services/{Tasks => }/SiteThemeService.cs (100%) create mode 100644 UI/Web/src/app/_models/preferences/epub-font.ts create mode 100644 UI/Web/src/app/_services/font.service.ts create mode 100644 UI/Web/src/app/user-settings/font-manager/font-manager/font-manager.component.html create mode 100644 UI/Web/src/app/user-settings/font-manager/font-manager/font-manager.component.scss create mode 100644 UI/Web/src/app/user-settings/font-manager/font-manager/font-manager.component.ts rename UI/Web/src/assets/fonts/{EBGarmond => EB Garamond}/EBGaramond-Italic-VariableFont_wght.ttf (100%) rename UI/Web/src/assets/fonts/{EBGarmond => EB Garamond}/EBGaramond-Italic-VariableFont_wght.woff2 (100%) rename UI/Web/src/assets/fonts/{EBGarmond => EB Garamond}/EBGaramond-VariableFont_wght.ttf (100%) rename UI/Web/src/assets/fonts/{EBGarmond => EB Garamond}/EBGaramond-VariableFont_wght.woff2 (100%) rename UI/Web/src/assets/fonts/{EBGarmond => EB Garamond}/OFL.txt (100%) rename UI/Web/src/assets/fonts/{Fast_Font => Fast Font Sans}/Fast_Sans.woff2 (100%) rename UI/Web/src/assets/fonts/{Fast_Font => Fast Font Serif}/Fast_Serif.woff2 (100%) rename UI/Web/src/assets/fonts/{Fira_Sans => Fira Sans}/FiraSans-Black.ttf (100%) rename UI/Web/src/assets/fonts/{Fira_Sans => Fira Sans}/FiraSans-Black.woff2 (100%) rename UI/Web/src/assets/fonts/{Fira_Sans => Fira Sans}/FiraSans-BlackItalic.ttf (100%) rename UI/Web/src/assets/fonts/{Fira_Sans => Fira Sans}/FiraSans-BlackItalic.woff2 (100%) rename UI/Web/src/assets/fonts/{Fira_Sans => Fira Sans}/FiraSans-Bold.ttf (100%) rename UI/Web/src/assets/fonts/{Fira_Sans => Fira Sans}/FiraSans-Bold.woff2 (100%) rename UI/Web/src/assets/fonts/{Fira_Sans => Fira Sans}/FiraSans-BoldItalic.ttf (100%) rename UI/Web/src/assets/fonts/{Fira_Sans => Fira Sans}/FiraSans-BoldItalic.woff2 (100%) rename UI/Web/src/assets/fonts/{Fira_Sans => Fira Sans}/FiraSans-ExtraBold.ttf (100%) rename UI/Web/src/assets/fonts/{Fira_Sans => Fira Sans}/FiraSans-ExtraBold.woff2 (100%) rename UI/Web/src/assets/fonts/{Fira_Sans => Fira Sans}/FiraSans-ExtraBoldItalic.ttf (100%) rename UI/Web/src/assets/fonts/{Fira_Sans => Fira Sans}/FiraSans-ExtraBoldItalic.woff2 (100%) rename UI/Web/src/assets/fonts/{Fira_Sans => Fira Sans}/FiraSans-ExtraLight.ttf (100%) rename UI/Web/src/assets/fonts/{Fira_Sans => Fira Sans}/FiraSans-ExtraLight.woff2 (100%) rename UI/Web/src/assets/fonts/{Fira_Sans => Fira Sans}/FiraSans-ExtraLightItalic.ttf (100%) rename UI/Web/src/assets/fonts/{Fira_Sans => Fira Sans}/FiraSans-ExtraLightItalic.woff2 (100%) rename UI/Web/src/assets/fonts/{Fira_Sans => Fira Sans}/FiraSans-Italic.ttf (100%) rename UI/Web/src/assets/fonts/{Fira_Sans => Fira Sans}/FiraSans-Italic.woff2 (100%) rename UI/Web/src/assets/fonts/{Fira_Sans => Fira Sans}/FiraSans-Light.ttf (100%) rename UI/Web/src/assets/fonts/{Fira_Sans => Fira Sans}/FiraSans-Light.woff2 (100%) rename UI/Web/src/assets/fonts/{Fira_Sans => Fira Sans}/FiraSans-LightItalic.ttf (100%) rename UI/Web/src/assets/fonts/{Fira_Sans => Fira Sans}/FiraSans-LightItalic.woff2 (100%) rename UI/Web/src/assets/fonts/{Fira_Sans => Fira Sans}/FiraSans-Medium.ttf (100%) rename UI/Web/src/assets/fonts/{Fira_Sans => Fira Sans}/FiraSans-Medium.woff2 (100%) rename UI/Web/src/assets/fonts/{Fira_Sans => Fira Sans}/FiraSans-MediumItalic.ttf (100%) rename UI/Web/src/assets/fonts/{Fira_Sans => Fira Sans}/FiraSans-MediumItalic.woff2 (100%) rename UI/Web/src/assets/fonts/{Fira_Sans => Fira Sans}/FiraSans-Regular.ttf (100%) rename UI/Web/src/assets/fonts/{Fira_Sans => Fira Sans}/FiraSans-Regular.woff2 (100%) rename UI/Web/src/assets/fonts/{Fira_Sans => Fira Sans}/FiraSans-SemiBold.ttf (100%) rename UI/Web/src/assets/fonts/{Fira_Sans => Fira Sans}/FiraSans-SemiBold.woff2 (100%) rename UI/Web/src/assets/fonts/{Fira_Sans => Fira Sans}/FiraSans-SemiBoldItalic.ttf (100%) rename UI/Web/src/assets/fonts/{Fira_Sans => Fira Sans}/FiraSans-SemiBoldItalic.woff2 (100%) rename UI/Web/src/assets/fonts/{Fira_Sans => Fira Sans}/FiraSans-Thin.ttf (100%) rename UI/Web/src/assets/fonts/{Fira_Sans => Fira Sans}/FiraSans-Thin.woff2 (100%) rename UI/Web/src/assets/fonts/{Fira_Sans => Fira Sans}/FiraSans-ThinItalic.ttf (100%) rename UI/Web/src/assets/fonts/{Fira_Sans => Fira Sans}/FiraSans-ThinItalic.woff2 (100%) rename UI/Web/src/assets/fonts/{Fira_Sans => Fira Sans}/OFL.txt (100%) rename UI/Web/src/assets/fonts/{Libre_Baskerville => Libre Baskerville}/LibreBaskerville-Bold.ttf (100%) rename UI/Web/src/assets/fonts/{Libre_Baskerville => Libre Baskerville}/LibreBaskerville-Bold.woff2 (100%) rename UI/Web/src/assets/fonts/{Libre_Baskerville => Libre Baskerville}/LibreBaskerville-Italic.ttf (100%) rename UI/Web/src/assets/fonts/{Libre_Baskerville => Libre Baskerville}/LibreBaskerville-Italic.woff2 (100%) rename UI/Web/src/assets/fonts/{Libre_Baskerville => Libre Baskerville}/LibreBaskerville-Regular.ttf (100%) rename UI/Web/src/assets/fonts/{Libre_Baskerville => Libre Baskerville}/LibreBaskerville-Regular.woff2 (100%) rename UI/Web/src/assets/fonts/{Libre_Baskerville => Libre Baskerville}/OFL.txt (100%) rename UI/Web/src/assets/fonts/{Libre_Caslon => Libre Caslon}/LibreCaslonText-Bold.ttf (100%) rename UI/Web/src/assets/fonts/{Libre_Caslon => Libre Caslon}/LibreCaslonText-Bold.woff2 (100%) rename UI/Web/src/assets/fonts/{Libre_Caslon => Libre Caslon}/LibreCaslonText-Italic.ttf (100%) rename UI/Web/src/assets/fonts/{Libre_Caslon => Libre Caslon}/LibreCaslonText-Italic.woff2 (100%) rename UI/Web/src/assets/fonts/{Libre_Caslon => Libre Caslon}/LibreCaslonText-Regular.ttf (100%) rename UI/Web/src/assets/fonts/{Libre_Caslon => Libre Caslon}/LibreCaslonText-Regular.woff2 (100%) rename UI/Web/src/assets/fonts/{Libre_Caslon => Libre Caslon}/OFL.txt (100%) rename UI/Web/src/assets/fonts/{Nanum_Gothic => Nanum Gothic}/NanumGothic-Bold.ttf (100%) rename UI/Web/src/assets/fonts/{Nanum_Gothic => Nanum Gothic}/NanumGothic-Bold.woff2 (100%) rename UI/Web/src/assets/fonts/{Nanum_Gothic => Nanum Gothic}/NanumGothic-ExtraBold.ttf (100%) rename UI/Web/src/assets/fonts/{Nanum_Gothic => Nanum Gothic}/NanumGothic-ExtraBold.woff2 (100%) rename UI/Web/src/assets/fonts/{Nanum_Gothic => Nanum Gothic}/NanumGothic-Regular.ttf (100%) rename UI/Web/src/assets/fonts/{Nanum_Gothic => Nanum Gothic}/NanumGothic-Regular.woff2 (100%) rename UI/Web/src/assets/fonts/{Nanum_Gothic => Nanum Gothic}/OFL.txt (100%) rename UI/Web/src/assets/fonts/{OpenDyslexic2 => Open Dyslexic}/OpenDyslexic-Bold.otf (100%) rename UI/Web/src/assets/fonts/{OpenDyslexic2 => Open Dyslexic}/OpenDyslexic-Bold.woff2 (100%) rename UI/Web/src/assets/fonts/{OpenDyslexic2 => Open Dyslexic}/OpenDyslexic-BoldItalic.otf (100%) rename UI/Web/src/assets/fonts/{OpenDyslexic2 => Open Dyslexic}/OpenDyslexic-BoldItalic.woff2 (100%) rename UI/Web/src/assets/fonts/{OpenDyslexic2 => Open Dyslexic}/OpenDyslexic-Italic.otf (100%) rename UI/Web/src/assets/fonts/{OpenDyslexic2 => Open Dyslexic}/OpenDyslexic-Italic.woff2 (100%) rename UI/Web/src/assets/fonts/{OpenDyslexic2 => Open Dyslexic}/OpenDyslexic-Regular.otf (100%) rename UI/Web/src/assets/fonts/{OpenDyslexic2 => Open Dyslexic}/OpenDyslexic-Regular.woff2 (100%) rename UI/Web/src/assets/fonts/{RocknRoll_One => RocknRoll One}/OFL.txt (100%) rename UI/Web/src/assets/fonts/{RocknRoll_One => RocknRoll One}/RocknRollOne-Regular.ttf (100%) rename UI/Web/src/assets/fonts/{RocknRoll_One => RocknRoll One}/RocknRollOne-Regular.woff2 (100%) diff --git a/.gitignore b/.gitignore index 1cffb441d..4bc9c1605 100644 --- a/.gitignore +++ b/.gitignore @@ -508,6 +508,7 @@ UI/Web/dist/ /API/config/logs/ /API/config/backups/ /API/config/cache/ +/API/config/fonts/ /API/config/temp/ /API/config/themes/ /API/config/stats/ diff --git a/API/API.csproj b/API/API.csproj index 71bd0aa1f..f6be7c9ae 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -190,6 +190,7 @@ + Always diff --git a/API/Controllers/FontController.cs b/API/Controllers/FontController.cs new file mode 100644 index 000000000..63a2c361f --- /dev/null +++ b/API/Controllers/FontController.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using API.Constants; +using API.Data; +using API.DTOs.Font; +using API.Entities.Enums.Font; +using API.Extensions; +using API.Services; +using API.Services.Tasks; +using API.Services.Tasks.Scanner.Parser; +using AutoMapper; +using Kavita.Common; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using MimeTypes; + +namespace API.Controllers; + +[Authorize] +public class FontController : BaseApiController +{ + private readonly IUnitOfWork _unitOfWork; + private readonly IDirectoryService _directoryService; + private readonly IFontService _fontService; + private readonly IMapper _mapper; + private readonly ILocalizationService _localizationService; + + private readonly Regex _fontFileExtensionRegex = new(Parser.FontFileExtensions, RegexOptions.IgnoreCase, Parser.RegexTimeout); + + public FontController(IUnitOfWork unitOfWork, IDirectoryService directoryService, + IFontService fontService, IMapper mapper, ILocalizationService localizationService) + { + _unitOfWork = unitOfWork; + _directoryService = directoryService; + _fontService = fontService; + _mapper = mapper; + _localizationService = localizationService; + } + + /// + /// List out the fonts + /// + /// + [ResponseCache(CacheProfileName = ResponseCacheProfiles.TenMinute)] + [HttpGet("all")] + public async Task>> GetFonts() + { + return Ok(await _unitOfWork.EpubFontRepository.GetFontDtosAsync()); + } + + /// + /// Returns a font file + /// + /// + /// + /// + [HttpGet] + [AllowAnonymous] + public async Task GetFont(int fontId, string apiKey) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); + if (userId == 0) return BadRequest(); + + var font = await _unitOfWork.EpubFontRepository.GetFontAsync(fontId); + if (font == null) return NotFound(); + + if (font.Provider == FontProvider.System) return BadRequest("System provided fonts are not loaded by API"); + + + var contentType = MimeTypeMap.GetMimeType(Path.GetExtension(font.FileName)); + var path = Path.Join(_directoryService.EpubFontDirectory, font.FileName); + + return PhysicalFile(path, contentType, true); + } + + /// + /// Removes a font from the system + /// + /// + /// If the font is in use by other users and an admin wants it deleted, they must confirm to force delete it. This is prompted in the UI. + /// + [HttpDelete] + public async Task DeleteFont(int fontId, bool force = false) + { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "denied")); + + var forceDelete = User.IsInRole(PolicyConstants.AdminRole) && force; + var fontInUse = await _fontService.IsFontInUse(fontId); + if (!fontInUse || forceDelete) + { + await _fontService.Delete(fontId); + } + + return Ok(); + } + + /// + /// Returns if the given font is in use by any other user. System provided fonts will always return true. + /// + /// + /// + [HttpGet("in-use")] + public async Task> IsFontInUse(int fontId) + { + return Ok(await _fontService.IsFontInUse(fontId)); + } + + /// + /// Manual upload + /// + /// + /// + [HttpPost("upload")] + public async Task> UploadFont(IFormFile formFile) + { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "denied")); + + if (!_fontFileExtensionRegex.IsMatch(Path.GetExtension(formFile.FileName))) return BadRequest("Invalid file"); + + if (formFile.FileName.Contains("..")) return BadRequest("Invalid file"); + + + var tempFile = await UploadToTemp(formFile); + var font = await _fontService.CreateFontFromFileAsync(tempFile); + return Ok(_mapper.Map(font)); + } + + [HttpPost("upload-by-url")] + public async Task UploadFontByUrl([FromQuery]string url) + { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "denied")); + // Validate url + try + { + var font = await _fontService.CreateFontFromUrl(url); + return Ok(_mapper.Map(font)); + } + catch (KavitaException ex) + { + return BadRequest(_localizationService.Translate(User.GetUserId(), ex.Message)); + } + } + + private async Task UploadToTemp(IFormFile file) + { + var outputFile = Path.Join(_directoryService.TempDirectory, file.FileName); + + await using var stream = System.IO.File.Create(outputFile); + await file.CopyToAsync(stream); + stream.Close(); + + return outputFile; + } +} diff --git a/API/Controllers/ThemeController.cs b/API/Controllers/ThemeController.cs index 9e4cee20c..f16530f6e 100644 --- a/API/Controllers/ThemeController.cs +++ b/API/Controllers/ThemeController.cs @@ -40,7 +40,7 @@ public class ThemeController : BaseApiController _mapper = mapper; } - [ResponseCache(CacheProfileName = "10Minute")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.TenMinute)] [AllowAnonymous] [HttpGet] public async Task>> GetThemes() diff --git a/API/DTOs/Font/EpubFontDto.cs b/API/DTOs/Font/EpubFontDto.cs new file mode 100644 index 000000000..5b6d131ea --- /dev/null +++ b/API/DTOs/Font/EpubFontDto.cs @@ -0,0 +1,13 @@ +using System; +using API.Entities.Enums.Font; + +namespace API.DTOs.Font; + +public sealed record EpubFontDto +{ + public int Id { get; set; } + public string Name { get; set; } + public FontProvider Provider { get; set; } + public string FileName { get; set; } + +} diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index 36a4526af..dacf18920 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -81,6 +81,8 @@ public sealed class DataContext : IdentityDbContext AppUserChapterRating { get; set; } = null!; public DbSet AppUserReadingProfiles { get; set; } = null!; public DbSet AppUserAnnotation { get; set; } = null!; + public DbSet EpubFont { get; set; } = null!; + protected override void OnModelCreating(ModelBuilder builder) { diff --git a/API/Data/Migrations/20250920212509_CustomEpubFonts.Designer.cs b/API/Data/Migrations/20250920212509_CustomEpubFonts.Designer.cs new file mode 100644 index 000000000..5ce969a9a --- /dev/null +++ b/API/Data/Migrations/20250920212509_CustomEpubFonts.Designer.cs @@ -0,0 +1,3889 @@ +// +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("20250920212509_CustomEpubFonts")] + partial class CustomEpubFonts + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.7"); + + 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("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("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.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("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("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("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("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.Navigation("AppUser"); + + b.Navigation("Chapter"); + }); + + 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/20250920212509_CustomEpubFonts.cs b/API/Data/Migrations/20250920212509_CustomEpubFonts.cs new file mode 100644 index 000000000..1a8505b52 --- /dev/null +++ b/API/Data/Migrations/20250920212509_CustomEpubFonts.cs @@ -0,0 +1,42 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class CustomEpubFonts : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "EpubFont", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", nullable: true), + NormalizedName = table.Column(type: "TEXT", nullable: true), + FileName = table.Column(type: "TEXT", nullable: true), + Provider = table.Column(type: "INTEGER", nullable: false), + Created = table.Column(type: "TEXT", nullable: false), + CreatedUtc = table.Column(type: "TEXT", nullable: false), + LastModified = table.Column(type: "TEXT", nullable: false), + LastModifiedUtc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_EpubFont", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "EpubFont"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 31188f691..bd982dd0d 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -1321,6 +1321,41 @@ namespace API.Data.Migrations 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") diff --git a/API/Data/Repositories/EpubFontRepository.cs b/API/Data/Repositories/EpubFontRepository.cs new file mode 100644 index 000000000..cea0d068a --- /dev/null +++ b/API/Data/Repositories/EpubFontRepository.cs @@ -0,0 +1,102 @@ +#nullable enable +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs.Font; +using API.Entities; +using API.Extensions; +using API.Services.Tasks; +using AutoMapper; +using AutoMapper.QueryableExtensions; +using Microsoft.EntityFrameworkCore; + +namespace API.Data.Repositories; + +public interface IEpubFontRepository +{ + void Add(EpubFont font); + void Remove(EpubFont font); + void Update(EpubFont font); + Task> GetFontDtosAsync(); + Task GetFontDtoAsync(int fontId); + Task GetFontDtoByNameAsync(string name); + Task> GetFontsAsync(); + Task GetFontAsync(int fontId); + Task IsFontInUseAsync(int fontId); +} + +public class EpubFontRepository: IEpubFontRepository +{ + private readonly DataContext _context; + private readonly IMapper _mapper; + + public EpubFontRepository(DataContext context, IMapper mapper) + { + _context = context; + _mapper = mapper; + } + + public void Add(EpubFont font) + { + _context.Add(font); + } + + public void Remove(EpubFont font) + { + _context.Remove(font); + } + + public void Update(EpubFont font) + { + _context.Entry(font).State = EntityState.Modified; + } + + public async Task> GetFontDtosAsync() + { + return await _context.EpubFont + .OrderBy(s => s.Name == FontService.DefaultFont ? -1 : 0) + .ThenBy(s => s) + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } + + public async Task GetFontDtoAsync(int fontId) + { + return await _context.EpubFont + .Where(f => f.Id == fontId) + .ProjectTo(_mapper.ConfigurationProvider) + .FirstOrDefaultAsync(); + } + + public async Task GetFontDtoByNameAsync(string name) + { + return await _context.EpubFont + .Where(f => f.NormalizedName.Equals(name.ToNormalized())) + .ProjectTo(_mapper.ConfigurationProvider) + .FirstOrDefaultAsync(); + } + + public async Task> GetFontsAsync() + { + return await _context.EpubFont + .ToListAsync(); + } + + public async Task GetFontAsync(int fontId) + { + return await _context.EpubFont + .Where(f => f.Id == fontId) + .FirstOrDefaultAsync(); + } + + public async Task IsFontInUseAsync(int fontId) + { + return await _context.AppUserReadingProfiles + .Join(_context.EpubFont, + preference => preference.BookReaderFontFamily, + font => font.Name, + (preference, font) => new { preference, font }) + .AnyAsync(joined => joined.font.Id == fontId); + } + +} diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index 0a446179a..29a6cdb32 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -84,6 +84,7 @@ public interface IUserRepository Task> GetAllBookmarksByIds(IList bookmarkIds); Task GetUserByEmailAsync(string email, AppUserIncludes includes = AppUserIncludes.None); Task> GetAllPreferencesByThemeAsync(int themeId); + Task> GetAllPreferencesByFontAsync(string fontName); Task HasAccessToLibrary(int libraryId, int userId); Task HasAccessToSeries(int userId, int seriesId); Task> GetAllUsersAsync(AppUserIncludes includeFlags = AppUserIncludes.None, bool track = true); @@ -293,6 +294,14 @@ public class UserRepository : IUserRepository .ToListAsync(); } + public async Task> GetAllPreferencesByFontAsync(string fontName) + { + return await _context.AppUserPreferences + .Where(p => p.BookReaderFontFamily == fontName) + .AsSplitQuery() + .ToListAsync(); + } + public async Task HasAccessToLibrary(int libraryId, int userId) { return await _context.Library diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index 20a34bd8a..167178dac 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -12,10 +12,13 @@ using API.Data.Repositories; using API.DTOs.Settings; using API.Entities; using API.Entities.Enums; +using API.Entities.Enums.Font; using API.Entities.Enums.Theme; using API.Entities.MetadataMatching; using API.Extensions; using API.Services; +using API.Services.Tasks; +using API.Services.Tasks.Scanner.Parser; using Kavita.Common; using Kavita.Common.EnvironmentInfo; using Microsoft.AspNetCore.Identity; @@ -69,6 +72,87 @@ public static class Seed } ]; + public static readonly ImmutableArray DefaultFonts = + [ + new () + { + Name = FontService.DefaultFont, + NormalizedName = Parser.Normalize(FontService.DefaultFont), + Provider = FontProvider.System, + FileName = string.Empty, + }, + new () + { + Name = "Merriweather", + NormalizedName = Parser.Normalize("Merriweather"), + Provider = FontProvider.System, + FileName = "Merriweather-Regular.woff2", + }, + new () + { + Name = "EB Garamond", + NormalizedName = Parser.Normalize("EB Garamond"), + Provider = FontProvider.System, + FileName = "EBGaramond-VariableFont_wght.woff2", + }, + new () + { + Name = "Fira Sans", + NormalizedName = Parser.Normalize("Fira Sans"), + Provider = FontProvider.System, + FileName = "FiraSans-Regular.woff2", + }, + new () + { + Name = "Lato", + NormalizedName = Parser.Normalize("Lato"), + Provider = FontProvider.System, + FileName = "Lato-Regular.woff2", + }, + new () + { + Name = "Libre Baskerville", + NormalizedName = Parser.Normalize("Libre Baskerville"), + Provider = FontProvider.System, + FileName = "LibreBaskerville-Regular.woff2", + }, + new () + { + Name = "Nanum Gothic", + NormalizedName = Parser.Normalize("Nanum Gothic"), + Provider = FontProvider.System, + FileName = "NanumGothic-Regular.woff2", + }, + new () + { + Name = "Open Dyslexic", + NormalizedName = Parser.Normalize("Open Dyslexic"), + Provider = FontProvider.System, + FileName = "OpenDyslexic-Regular.woff2", + }, + new () + { + Name = "RocknRoll One", + NormalizedName = Parser.Normalize("RocknRoll One"), + Provider = FontProvider.System, + FileName = "RocknRollOne-Regular.woff2", + }, + new () + { + Name = "Fast Font Serif", + NormalizedName = Parser.Normalize("Fast Font Serif"), + Provider = FontProvider.System, + FileName = "Fast_Serif.woff2", + }, + new () + { + Name = "Fast Font Sans", + NormalizedName = Parser.Normalize("Fast Font Sans"), + Provider = FontProvider.System, + FileName = "Fast_Sans.woff2", + } + ]; + public static readonly ImmutableArray DefaultThemes = [ ..new List { @@ -197,7 +281,7 @@ public static class Seed foreach (var theme in DefaultThemes) { - var existing = context.SiteTheme.FirstOrDefault(s => s.Name.Equals(theme.Name)); + var existing = await context.SiteTheme.FirstOrDefaultAsync(s => s.Name.Equals(theme.Name)); if (existing == null) { await context.SiteTheme.AddAsync(theme); @@ -207,6 +291,22 @@ public static class Seed await context.SaveChangesAsync(); } + public static async Task SeedFonts(DataContext context) + { + await context.Database.EnsureCreatedAsync(); + + foreach (var font in DefaultFonts) + { + var existing = await context.EpubFont.FirstOrDefaultAsync(f => f.Name.Equals(font.Name)); + if (existing == null) + { + await context.EpubFont.AddAsync(font); + } + } + + await context.SaveChangesAsync(); + } + public static async Task SeedDefaultStreams(IUnitOfWork unitOfWork) { var allUsers = await unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.DashboardStreams); diff --git a/API/Data/UnitOfWork.cs b/API/Data/UnitOfWork.cs index cb8641efe..f387c21e2 100644 --- a/API/Data/UnitOfWork.cs +++ b/API/Data/UnitOfWork.cs @@ -35,6 +35,7 @@ public interface IUnitOfWork IEmailHistoryRepository EmailHistoryRepository { get; } IAppUserReadingProfileRepository AppUserReadingProfileRepository { get; } IAnnotationRepository AnnotationRepository { get; } + IEpubFontRepository EpubFontRepository { get; } bool Commit(); Task CommitAsync(); bool HasChanges(); @@ -78,6 +79,7 @@ public class UnitOfWork : IUnitOfWork EmailHistoryRepository = new EmailHistoryRepository(_context, _mapper); AppUserReadingProfileRepository = new AppUserReadingProfileRepository(_context, _mapper); AnnotationRepository = new AnnotationRepository(_context, _mapper); + EpubFontRepository = new EpubFontRepository(_context, _mapper); } /// @@ -109,6 +111,7 @@ public class UnitOfWork : IUnitOfWork public IEmailHistoryRepository EmailHistoryRepository { get; } public IAppUserReadingProfileRepository AppUserReadingProfileRepository { get; } public IAnnotationRepository AnnotationRepository { get; } + public IEpubFontRepository EpubFontRepository { get; } /// /// Commits changes to the DB. Completes the open transaction. diff --git a/API/Entities/AppUserPreferences.cs b/API/Entities/AppUserPreferences.cs index 86a68afd0..e125e14e9 100644 --- a/API/Entities/AppUserPreferences.cs +++ b/API/Entities/AppUserPreferences.cs @@ -2,6 +2,7 @@ using API.Data; using API.Entities.Enums; using API.Entities.Enums.UserPreferences; +using API.Services.Tasks; namespace API.Entities; @@ -79,7 +80,7 @@ public class AppUserPreferences /// /// Book Reader Option: Maps to the default Kavita font-family (inherit) or an override /// - public string BookReaderFontFamily { get; set; } = "default"; + public string BookReaderFontFamily { get; set; } = FontService.DefaultFont; /// /// Book Reader Option: Allows tapping on side of screens to paginate /// diff --git a/API/Entities/AppUserReadingProfile.cs b/API/Entities/AppUserReadingProfile.cs index 9b238b4f5..18382a614 100644 --- a/API/Entities/AppUserReadingProfile.cs +++ b/API/Entities/AppUserReadingProfile.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.ComponentModel; using API.Entities.Enums; using API.Entities.Enums.UserPreferences; +using API.Services.Tasks; namespace API.Entities; @@ -109,7 +110,7 @@ public class AppUserReadingProfile /// /// Book Reader Option: Maps to the default Kavita font-family (inherit) or an override /// - public string BookReaderFontFamily { get; set; } = "default"; + public string BookReaderFontFamily { get; set; } = FontService.DefaultFont; /// /// Book Reader Option: Allows tapping on side of screens to paginate /// diff --git a/API/Entities/Enums/Font/FontProvider.cs b/API/Entities/Enums/Font/FontProvider.cs new file mode 100644 index 000000000..ee944844a --- /dev/null +++ b/API/Entities/Enums/Font/FontProvider.cs @@ -0,0 +1,13 @@ +namespace API.Entities.Enums.Font; + +public enum FontProvider +{ + /// + /// Font is provider by System, always avaible + /// + System = 1, + /// + /// Font provider by the User + /// + User = 2, +} diff --git a/API/Entities/EpubFont.cs b/API/Entities/EpubFont.cs new file mode 100644 index 000000000..0cf745db6 --- /dev/null +++ b/API/Entities/EpubFont.cs @@ -0,0 +1,37 @@ +using System; +using API.Entities.Enums.Font; +using API.Entities.Interfaces; +using API.Services; + +namespace API.Entities; + +/// +/// Represents a user provider font to be used in the epub reader +/// +public class EpubFont: IEntityDate +{ + public int Id { get; set; } + + /// + /// Name of the font + /// + public required string Name { get; set; } + /// + /// Normalized name for lookups + /// + public required string NormalizedName { get; set; } + /// + /// Filename of the font, stored under + /// + /// System provided fonts use an alternative location as they are packaged with the app + public required string FileName { get; set; } + /// + /// Where the font came from + /// + public FontProvider Provider { get; set; } + + public DateTime Created { get; set; } + public DateTime CreatedUtc { get; set; } + public DateTime LastModified { get; set; } + public DateTime LastModifiedUtc { get; set; } +} diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index eacb9f51c..a60053e4e 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -59,6 +59,7 @@ public static class ApplicationServiceExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index 2452c38a7..6c5807aad 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -11,6 +11,7 @@ using API.DTOs.Device; using API.DTOs.Email; using API.DTOs.Filtering; using API.DTOs.Filtering.v2; +using API.DTOs.Font; using API.DTOs.KavitaPlus.Manage; using API.DTOs.KavitaPlus.Metadata; using API.DTOs.MediaErrors; @@ -282,6 +283,8 @@ public class AutoMapperProfiles : Profile opt => opt.MapFrom(src => src.BookThemeName)); + CreateMap(); + CreateMap(); diff --git a/API/I18N/en.json b/API/I18N/en.json index 96366059e..d0c86bcc6 100644 --- a/API/I18N/en.json +++ b/API/I18N/en.json @@ -241,6 +241,6 @@ "update-yearly-stats": "Update Yearly Stats", "generated-reading-profile-name": "Generated from {0}", - "genre-doesnt-exist": "Genre doesn't exist" - + "genre-doesnt-exist": "Genre doesn't exist", + "font-url-not-allowed": "Uploading a Font by url is only allowed from Google Fonts" } diff --git a/API/Program.cs b/API/Program.cs index 401d81ce0..87b2ea4a4 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -124,6 +124,7 @@ public class Program await Seed.SeedRoles(services.GetRequiredService>()); await Seed.SeedSettings(context, directoryService); await Seed.SeedThemes(context); + await Seed.SeedFonts(context); await Seed.SeedDefaultStreams(unitOfWork); await Seed.SeedDefaultSideNavStreams(unitOfWork); await Seed.SeedUserApiKeys(context); diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index 756068ab5..a116db928 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -313,11 +313,11 @@ public partial class BookService : IBookService } /// - /// For each bookmark on this page, inject a specialized icon + /// For each ptoc (text) bookmark on this page, inject a specialized icon /// /// /// - private void InjectPTOCBookmarks(HtmlDocument doc, List ptocBookmarks) + private void InjectTextBookmarks(HtmlDocument doc, List ptocBookmarks) { if (ptocBookmarks.Count == 0) return; @@ -1257,7 +1257,7 @@ public partial class BookService : IBookService InjectImages(doc, book, apiBase); // Inject PTOC Bookmark Icons - InjectPTOCBookmarks(doc, ptocBookmarks); + InjectTextBookmarks(doc, ptocBookmarks); // Inject Annotations InjectAnnotations(doc, annotations); diff --git a/API/Services/DirectoryService.cs b/API/Services/DirectoryService.cs index 7e308d92e..ecce1957a 100644 --- a/API/Services/DirectoryService.cs +++ b/API/Services/DirectoryService.cs @@ -42,6 +42,7 @@ public interface IDirectoryService /// Used for random files needed, like images to check against, list of countries, etc /// string AssetsDirectory { get; } + string EpubFontDirectory { get; } /// /// Lists out top-level folders for a given directory. Filters out System and Hidden folders. /// @@ -100,6 +101,8 @@ public class DirectoryService : IDirectoryService public string TemplateDirectory { get; } public string PublisherDirectory { get; } public string LongTermCacheDirectory { get; } + public string EpubFontDirectory { get; } + private readonly ILogger _logger; private const RegexOptions MatchOptions = RegexOptions.Compiled | RegexOptions.IgnoreCase; @@ -141,6 +144,8 @@ public class DirectoryService : IDirectoryService ExistOrCreate(PublisherDirectory); LongTermCacheDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "cache-long"); ExistOrCreate(LongTermCacheDirectory); + EpubFontDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "fonts"); + ExistOrCreate(EpubFontDirectory); } /// diff --git a/API/Services/FontService.cs b/API/Services/FontService.cs new file mode 100644 index 000000000..f52e0bc25 --- /dev/null +++ b/API/Services/FontService.cs @@ -0,0 +1,264 @@ +using System; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using API.Data; +using API.Entities; +using API.Entities.Enums.Font; +using API.Extensions; +using API.Services.Tasks.Scanner.Parser; +using API.SignalR; +using Flurl.Http; +using Kavita.Common; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; + +namespace API.Services.Tasks; + +// Although we don't use all the fields, just including them all for completeness +internal class GoogleFontsMetadata +{ + /// + /// Name of the zip file container all fonts + /// + public required string zipName { get; init; } + + /// + /// Manifest, information about the content of the zip + /// + public required GoogleFontsManifest manifest { get; init; } + + /// + /// Tries to find the variable font in the manifest + /// + /// GoogleFontsFileRef + public GoogleFontsFileRef? VariableFont() + { + foreach (var fileRef in manifest.fileRefs) + { + // Filename prefixed with static means it's a Bold/Italic/... font + if (!fileRef.filename.StartsWith("static/")) + { + return fileRef; + } + } + + return null; + } +} + +internal class GoogleFontsManifest +{ + /// + /// Files included in the zip + /// README.txt + /// + public required GoogleFontsFile[] files { get; init; } + /// + /// References to the actual fonts + /// + public required GoogleFontsFileRef[] fileRefs { get; init; } +} + +internal class GoogleFontsFile +{ + public required string filename { get; init; } + public required string contents { get; init; } +} + +internal class GoogleFontsFileRef +{ + public required string filename { get; init; } + public required string url { get; init; } + public required GoogleFontsData date { get; init; } +} + +internal class GoogleFontsData +{ + public required int seconds { get; init; } + public required int nanos { get; init; } +} + +public interface IFontService +{ + Task CreateFontFromFileAsync(string path); + Task Delete(int fontId); + Task CreateFontFromUrl(string url); + Task IsFontInUse(int fontId); +} + +public class FontService: IFontService +{ + + public static readonly string DefaultFont = "Default"; + + private readonly IDirectoryService _directoryService; + private readonly IUnitOfWork _unitOfWork; + private readonly ILogger _logger; + private readonly IEventHub _eventHub; + + private const string SupportedFontUrlPrefix = "https://fonts.google.com/"; + private const string DownloadFontUrlPrefix = "https://fonts.google.com/download/list?family="; + private const string GoogleFontsInvalidJsonPrefix = ")]}'"; + + public FontService(IDirectoryService directoryService, IUnitOfWork unitOfWork, ILogger logger, IEventHub eventHub) + { + _directoryService = directoryService; + _unitOfWork = unitOfWork; + _logger = logger; + _eventHub = eventHub; + } + + public async Task CreateFontFromFileAsync(string path) + { + if (!_directoryService.FileSystem.File.Exists(path)) + { + _logger.LogInformation("Unable to create font from manual upload as font not in temp"); + throw new KavitaException("errors.font-manual-upload"); + } + + var fileName = _directoryService.FileSystem.FileInfo.New(path).Name; + var nakedFileName = _directoryService.FileSystem.Path.GetFileNameWithoutExtension(fileName); + var fontName = Parser.PrettifyFileName(nakedFileName); + var normalizedName = Parser.Normalize(nakedFileName); + + if (await _unitOfWork.EpubFontRepository.GetFontDtoByNameAsync(fontName) != null) + { + throw new KavitaException("errors.font-already-in-use"); + } + + _directoryService.CopyFileToDirectory(path, _directoryService.EpubFontDirectory); + var finalLocation = _directoryService.FileSystem.Path.Join(_directoryService.EpubFontDirectory, fileName); + + var font = new EpubFont() + { + Name = fontName, + NormalizedName = normalizedName, + FileName = Path.GetFileName(finalLocation), + Provider = FontProvider.User + }; + _unitOfWork.EpubFontRepository.Add(font); + await _unitOfWork.CommitAsync(); + + // TODO: Send update to UI + return font; + } + + /// + /// This does not check if in use, use + /// + /// + public async Task Delete(int fontId) + { + var font = await _unitOfWork.EpubFontRepository.GetFontAsync(fontId); + if (font == null) return; + + await RemoveFont(font); + } + + public async Task CreateFontFromUrl(string url) + { + if (!url.StartsWith(SupportedFontUrlPrefix)) + { + throw new KavitaException("font-url-not-allowed"); + } + + // Extract Font name from url + var fontFamily = url.Split(SupportedFontUrlPrefix)[1].Split("?")[0].Split("/").Last(); + _logger.LogInformation("Preparing to download {FontName} font", fontFamily.Sanitize()); + + var metaData = await GetGoogleFontsMetadataAsync(fontFamily); + if (metaData == null) + { + _logger.LogError("Unable to find metadata for {FontName}", fontFamily.Sanitize()); + throw new KavitaException("errors.font-not-found"); + } + + var googleFontRef = metaData.VariableFont(); + if (googleFontRef == null) + { + _logger.LogError("Unable to find variable font for {FontName} with metadata {MetaData}", fontFamily.Sanitize(), metaData); + throw new KavitaException("errors.font-not-found"); + } + + var fontExt = Path.GetExtension(googleFontRef.filename); + var fileName = $"{fontFamily}{fontExt}"; + + _logger.LogDebug("Downloading font {FontFamily} to {FileName} from {Url}", fontFamily.Sanitize(), fileName, googleFontRef.url); + var path = await googleFontRef.url.DownloadFileAsync(_directoryService.TempDirectory, fileName); + + return await CreateFontFromFileAsync(path); + } + + /// + /// Returns if the given font is in use by any other user. System provided fonts will always return true. + /// + /// + /// + public async Task IsFontInUse(int fontId) + { + var font = await _unitOfWork.EpubFontRepository.GetFontAsync(fontId); + if (font == null || font.Provider == FontProvider.System) return true; + + return await _unitOfWork.EpubFontRepository.IsFontInUseAsync(fontId); + } + + public async Task RemoveFont(EpubFont font) + { + if (font.Provider == FontProvider.System) return; + + var prefs = await _unitOfWork.UserRepository.GetAllPreferencesByFontAsync(font.Name); + foreach (var pref in prefs) + { + pref.BookReaderFontFamily = DefaultFont; + _unitOfWork.UserRepository.Update(pref); + } + + try + { + // Copy the font file to temp for nightly removal (to give user time to reclaim if made a mistake) + var existingLocation = + _directoryService.FileSystem.Path.Join(_directoryService.EpubFontDirectory, font.FileName); + var newLocation = + _directoryService.FileSystem.Path.Join(_directoryService.TempDirectory, font.FileName); + _directoryService.CopyFileToDirectory(existingLocation, newLocation); + _directoryService.DeleteFiles([existingLocation]); + } + catch (Exception) { /* Swallow */ } + + _unitOfWork.EpubFontRepository.Remove(font); + await _unitOfWork.CommitAsync(); + } + + private async Task GetGoogleFontsMetadataAsync(string fontName) + { + var url = DownloadFontUrlPrefix + fontName; + string content; + + // The request may fail if the users URL is invalid or the font doesn't exist + // The error this produces is ugly and not user-friendly, so we catch it here + try + { + content = await url + .WithHeader("Accept", "application/json") + .WithHeader("User-Agent", "Kavita") + .GetStringAsync(); + } catch (Exception ex) + { + _logger.LogError(ex, "Unable to get metadata for {FontName} from {Url}", fontName.Sanitize(), url); + return null; + } + + // The returned response isn't valid json and has this weird prefix, removing it here... + if (content.StartsWith(GoogleFontsInvalidJsonPrefix)) + { + content = content[GoogleFontsInvalidJsonPrefix.Length..]; + } + + return JsonSerializer.Deserialize(content); + } + + +} diff --git a/API/Services/Tasks/SiteThemeService.cs b/API/Services/SiteThemeService.cs similarity index 100% rename from API/Services/Tasks/SiteThemeService.cs rename to API/Services/SiteThemeService.cs diff --git a/API/Services/Tasks/BackupService.cs b/API/Services/Tasks/BackupService.cs index e2ed61ba1..df2921788 100644 --- a/API/Services/Tasks/BackupService.cs +++ b/API/Services/Tasks/BackupService.cs @@ -124,6 +124,9 @@ public class BackupService : IBackupService await SendProgress(0.5F, "Copying bookmarks"); await CopyBookmarksToBackupDirectory(tempDirectory); + await SendProgress(0.6F, "Copying Fonts"); + CopyFontsToBackupDirectory(tempDirectory); + await SendProgress(0.75F, "Copying themes"); CopyThemesToBackupDirectory(tempDirectory); @@ -225,6 +228,26 @@ public class BackupService : IBackupService } } + private void CopyFontsToBackupDirectory(string tempDirectory) + { + var outputTempDir = Path.Join(tempDirectory, "fonts"); + _directoryService.ExistOrCreate(outputTempDir); + + try + { + _directoryService.CopyDirectoryToDirectory(_directoryService.EpubFontDirectory, outputTempDir); + } + catch (IOException ex) + { + _logger.LogWarning(ex, "Failed to copy fonts to backup directory '{OutputTempDir}'. Fonts will not be included in the backup.", outputTempDir); + } + + if (!_directoryService.GetFiles(outputTempDir, searchOption: SearchOption.AllDirectories).Any()) + { + _directoryService.ClearAndDeleteDirectory(outputTempDir); + } + } + private void CopyThemesToBackupDirectory(string tempDirectory) { var outputTempDir = Path.Join(tempDirectory, "themes"); diff --git a/API/Services/Tasks/Scanner/Parser/Parser.cs b/API/Services/Tasks/Scanner/Parser/Parser.cs index 1ba2cca37..711d8ad40 100644 --- a/API/Services/Tasks/Scanner/Parser/Parser.cs +++ b/API/Services/Tasks/Scanner/Parser/Parser.cs @@ -31,6 +31,7 @@ public static partial class Parser private const string BookFileExtensions = EpubFileExtension + "|" + PdfFileExtension; private const string XmlRegexExtensions = @"\.xml"; public const string MacOsMetadataFileStartsWith = @"._"; + public const string FontFileExtensions = @"\.[woff2|ttf|otf|woff]"; public const string SupportedExtensions = ArchiveFileExtensions + "|" + ImageFileExtensions + "|" + BookFileExtensions; @@ -1257,6 +1258,14 @@ public static partial class Parser return filename; } + /** + * Replaced non-alphanumerical chars with a space + */ + public static string PrettifyFileName(string name) + { + return Regex.Replace(name, "[^a-zA-Z0-9]", " "); + } + /// /// Removes duplicate chapter markers from filename, keeping only the first occurrence /// diff --git a/UI/Web/src/app/_models/preferences/epub-font.ts b/UI/Web/src/app/_models/preferences/epub-font.ts new file mode 100644 index 000000000..8ac1747f7 --- /dev/null +++ b/UI/Web/src/app/_models/preferences/epub-font.ts @@ -0,0 +1,17 @@ +/** + * Where does the font come from + */ +export enum FontProvider { + System = 1, + User = 2, +} + +/** + * Font used in the book reader + */ +export interface EpubFont { + id: number; + name: string; + provider: FontProvider; + fileName: string; +} diff --git a/UI/Web/src/app/_models/wiki.ts b/UI/Web/src/app/_models/wiki.ts index a01267cf3..b4860426f 100644 --- a/UI/Web/src/app/_models/wiki.ts +++ b/UI/Web/src/app/_models/wiki.ts @@ -22,4 +22,5 @@ export enum WikiLink { OpdsClients = 'https://wiki.kavitareader.com/guides/features/opds/#opds-capable-clients', Guides = 'https://wiki.kavitareader.com/guides', ReadingProfiles = "https://wiki.kavitareader.com/guides/user-settings/reading-profiles/", + EpubFontManager = "https://wiki.kavitareader.com/guides/epub-fonts/", } diff --git a/UI/Web/src/app/_services/epub-reader-settings.service.ts b/UI/Web/src/app/_services/epub-reader-settings.service.ts index cfdb5f8c5..aed24ba90 100644 --- a/UI/Web/src/app/_services/epub-reader-settings.service.ts +++ b/UI/Web/src/app/_services/epub-reader-settings.service.ts @@ -6,7 +6,6 @@ import {WritingStyle} from '../_models/preferences/writing-style'; import {BookPageLayoutMode} from "../_models/readers/book-page-layout-mode"; import {FormControl, FormGroup, NonNullableFormBuilder} from "@angular/forms"; import {ReadingProfile, ReadingProfileKind} from "../_models/preferences/reading-profiles"; -import {BookService, FontFamily} from "../book-reader/_services/book.service"; import {ThemeService} from './theme.service'; import {ReadingProfileService} from "./reading-profile.service"; import {debounceTime, distinctUntilChanged, filter, tap} from "rxjs/operators"; @@ -18,6 +17,8 @@ import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; import {UserBreakpoint, UtilityService} from "../shared/_services/utility.service"; import {LayoutMeasurementService} from "./layout-measurement.service"; import {environment} from "../../environments/environment"; +import {EpubFont} from "../_models/preferences/epub-font"; +import {FontService} from "./font.service"; export interface ReaderSettingUpdate { setting: 'pageStyle' | 'clickToPaginate' | 'fullscreen' | 'writingStyle' | 'layoutMode' | 'readingDirection' | 'immersiveMode' | 'theme'; @@ -43,7 +44,7 @@ const COLUMN_GAP = 20; //px gap between columns @Injectable() export class EpubReaderSettingsService { private readonly destroyRef = inject(DestroyRef); - private readonly bookService = inject(BookService); + private readonly fontService = inject(FontService); private readonly themeService = inject(ThemeService); private readonly readingProfileService = inject(ReadingProfileService); private readonly utilityService = inject(UtilityService); @@ -57,6 +58,7 @@ export class EpubReaderSettingsService { private readonly _parentReadingProfile = signal(null); private readonly _currentSeriesId = signal(null); private readonly _isInitialized = signal(false); + private readonly _epubFonts = signal([]); // Settings signals private readonly _pageStyles = signal(this.getDefaultPageStyles()); // Internal property used to capture all the different css properties to render on all elements @@ -70,7 +72,6 @@ export class EpubReaderSettingsService { // Form will be managed separately but updated from signals private settingsForm!: BookReadingProfileFormGroup; - private fontFamilies: FontFamily[] = this.bookService.getFontFamilies(); private isUpdatingFromForm = false; // Flag to prevent infinite loops private isInitialized = this._isInitialized(); // Non-signal, updates in effect @@ -89,6 +90,7 @@ export class EpubReaderSettingsService { public readonly clickToPaginate = this._clickToPaginate.asReadonly(); public readonly immersiveMode = this._immersiveMode.asReadonly(); public readonly isFullscreen = this._isFullscreen.asReadonly(); + public readonly epubFonts = this._epubFonts.asReadonly(); // Computed signals for derived state public readonly layoutMode = computed(() => { @@ -215,6 +217,9 @@ export class EpubReaderSettingsService { * Initialize the service with a reading profile and series ID */ async initialize(seriesId: number, readingProfile: ReadingProfile): Promise { + const fonts = await firstValueFrom(this.fontService.getFonts()); + this._epubFonts.set(fonts); + this._currentSeriesId.set(seriesId); this._currentReadingProfile.set(readingProfile); @@ -248,7 +253,7 @@ export class EpubReaderSettingsService { private setupDefaultsFromProfile(profile: ReadingProfile): void { // Set defaults if undefined if (profile.bookReaderFontFamily === undefined) { - profile.bookReaderFontFamily = 'default'; + profile.bookReaderFontFamily = FontService.DefaultEpubFont; } if (profile.bookReaderFontSize === undefined || profile.bookReaderFontSize < 50) { profile.bookReaderFontSize = 100; @@ -299,13 +304,6 @@ export class EpubReaderSettingsService { return this._currentReadingProfile(); } - /** - * Get font families for UI - */ - getFontFamilies(): FontFamily[] { - return this.fontFamilies; - } - /** * Get available themes */ @@ -490,11 +488,11 @@ export class EpubReaderSettingsService { ).subscribe(fontName => { this.isUpdatingFromForm = true; - const familyName = this.fontFamilies.find(f => f.title === fontName)?.family || 'default'; + const familyName = this._epubFonts().find(f => f.name === fontName)?.name || FontService.DefaultEpubFont; const currentStyles = this._pageStyles(); const newStyles = { ...currentStyles }; - if (familyName === 'default') { + if (familyName === FontService.DefaultEpubFont) { newStyles['font-family'] = 'inherit'; } else { newStyles['font-family'] = `'${familyName}'`; @@ -670,7 +668,7 @@ export class EpubReaderSettingsService { const currentStyles = this._pageStyles(); const newStyles: PageStyle = { - 'font-family': fontFamily || currentStyles['font-family'] || 'default', + 'font-family': fontFamily || currentStyles['font-family'] || FontService.DefaultEpubFont, 'font-size': fontSize || currentStyles['font-size'] || '100%', 'margin-left': margin || currentStyles['margin-left'] || defaultMargin, 'margin-right': margin || currentStyles['margin-right'] || defaultMargin, @@ -682,7 +680,7 @@ export class EpubReaderSettingsService { public getDefaultPageStyles(): PageStyle { return { - 'font-family': 'default', + 'font-family': FontService.DefaultEpubFont, 'font-size': '100%', 'margin-left': '15vw', 'margin-right': '15vw', diff --git a/UI/Web/src/app/_services/font.service.ts b/UI/Web/src/app/_services/font.service.ts new file mode 100644 index 000000000..e2b253e61 --- /dev/null +++ b/UI/Web/src/app/_services/font.service.ts @@ -0,0 +1,66 @@ +import {effect, inject, Injectable} from "@angular/core"; +import {EpubFont, FontProvider} from "../_models/preferences/epub-font"; +import {environment} from 'src/environments/environment'; +import {HttpClient} from "@angular/common/http"; +import {NgxFileDropEntry} from "ngx-file-drop"; +import {AccountService} from "./account.service"; +import {TextResonse} from "../_types/text-response"; +import {map} from "rxjs/operators"; + +@Injectable({ + providedIn: 'root' +}) +export class FontService { + + public static readonly DefaultEpubFont = 'Default'; + + private readonly httpClient = inject(HttpClient); + private readonly accountService = inject(AccountService); + + baseUrl = environment.apiUrl; + apiKey: string = ''; + encodedKey: string = ''; + + constructor() { + effect(() => { + const user = this.accountService.currentUserSignal(); + + if (user) { + this.apiKey = user.apiKey; + this.encodedKey = encodeURIComponent(this.apiKey); + } + }); + } + + getFonts() { + return this.httpClient.get>(this.baseUrl + 'font/all'); + } + + getFontFace(font: EpubFont): FontFace { + // TODO: We need to refactor this so that we loadFonts with an array, fonts have an id to remove them, and we don't keep populating the document + if (font.provider === FontProvider.System) { + return new FontFace(font.name, `url('/assets/fonts/${font.name}/${font.fileName}')`); + } + + return new FontFace(font.name, `url(${this.baseUrl}font?fontId=${font.id}&apiKey=${this.encodedKey})`); + } + + uploadFont(fontFile: File, fileEntry: NgxFileDropEntry) { + const formData = new FormData(); + formData.append('formFile', fontFile, fileEntry.relativePath); + return this.httpClient.post(this.baseUrl + "font/upload", formData); + } + + uploadFromUrl(url: string) { + return this.httpClient.post(this.baseUrl + "font/upload-by-url?url=" + encodeURIComponent(url), {}); + } + + deleteFont(id: number, force: boolean = false) { + return this.httpClient.delete(this.baseUrl + `font?fontId=${id}&force=${force}`); + } + + isFontInUse(id: number) { + return this.httpClient.get(this.baseUrl + `font/in-use?fontId=${id}`, TextResonse).pipe(map(res => res == 'true')); + } + +} diff --git a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss index 40b71842f..bab05c03e 100644 --- a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss +++ b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss @@ -1,59 +1,3 @@ -@font-face { - font-family: "Fira_Sans"; - src: url(../../../../assets/fonts/Fira_Sans/FiraSans-Regular.woff2) format("woff2"); - font-display: swap; -} - -@font-face { - font-family: "Lato"; - src: url(../../../../assets/fonts/Lato/Lato-Regular.woff2) format("woff2"); - font-display: swap; -} - -@font-face { - font-family: "Libre_Baskerville"; - src: url(../../../../assets/fonts/Libre_Baskerville/LibreBaskerville-Regular.woff2) format("woff2"); - font-display: swap; -} - -@font-face { - font-family: "Merriweather"; - src: url(../../../../assets/fonts/Merriweather/Merriweather-Regular.woff2) format("woff2"); - font-display: swap; -} - -@font-face { - font-family: "Nanum_Gothic"; - src: url(../../../../assets/fonts/Nanum_Gothic/NanumGothic-Regular.woff2) format("woff2"); - font-display: swap; -} - -@font-face { - font-family: "RocknRoll_One"; - src: url(../../../../assets/fonts/RocknRoll_One/RocknRollOne-Regular.woff2) format("woff2"); - font-display: swap; -} - -@font-face { - font-family: "OpenDyslexic2"; - src: url(../../../../assets/fonts/OpenDyslexic2/OpenDyslexic-Regular.woff2) format("woff2"); - font-display: swap; -} - -@font-face { - font-family: "FastFontSerif"; - src: url(../../../../assets/fonts/Fast_Font/Fast_Serif.woff2) format("woff2"); - font-display: swap; -} - -@font-face { - font-family: "FastFontSans"; - src: url(../../../../assets/fonts/Fast_Font/Fast_Sans.woff2) format("woff2"); - font-display: swap; -} - - - :root { --br-actionbar-button-text-color: #6c757d; --accordion-body-bg-color: black; 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 037ffb004..e0e845f86 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 @@ -67,6 +67,7 @@ import {LayoutMeasurementService} from "../../../_services/layout-measurement.se import {ColorscapeService} from "../../../_services/colorscape.service"; 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"; @@ -156,6 +157,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { private readonly document = inject(DOCUMENT); private readonly layoutService = inject(LayoutMeasurementService); private readonly colorscapeService = inject(ColorscapeService); + private readonly fontService = inject(FontService); protected readonly BookPageLayoutMode = BookPageLayoutMode; protected readonly WritingStyle = WritingStyle; @@ -776,6 +778,14 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } async ngOnInit() { + this.fontService.getFonts().subscribe(fonts => { + fonts.filter(f => f.name !== FontService.DefaultEpubFont).forEach(font => { + this.fontService.getFontFace(font).load().then(loadedFace => { + (this.document as any).fonts.add(loadedFace); + }); + }); + }); + const libraryId = this.route.snapshot.paramMap.get('libraryId'); const seriesId = this.route.snapshot.paramMap.get('seriesId'); const chapterId = this.route.snapshot.paramMap.get('chapterId'); diff --git a/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.html b/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.html index 500645bed..0adf9bdf9 100644 --- a/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.html +++ b/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.html @@ -16,8 +16,8 @@
diff --git a/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.ts b/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.ts index e20c1cb13..69c938944 100644 --- a/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.ts +++ b/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.ts @@ -6,7 +6,6 @@ import {BookTheme} from 'src/app/_models/preferences/book-theme'; import {ReadingDirection} from 'src/app/_models/preferences/reading-direction'; import {WritingStyle} from 'src/app/_models/preferences/writing-style'; import {ThemeProvider} from 'src/app/_models/preferences/site-theme'; -import {FontFamily} from '../../_services/book.service'; import {BookBlackTheme} from '../../_models/book-black-theme'; import {BookDarkTheme} from '../../_models/book-dark-theme'; import {BookWhiteTheme} from '../../_models/book-white-theme'; @@ -23,6 +22,9 @@ import { import {TranslocoDirective} from "@jsverse/transloco"; import {ReadingProfile, ReadingProfileKind} from "../../../_models/preferences/reading-profiles"; import {BookReadingProfileFormGroup, EpubReaderSettingsService} from "../../../_services/epub-reader-settings.service"; +import {LayoutMode} from "../../../manga-reader/_models/layout-mode"; +import {FontService} from "../../../_services/font.service"; +import {EpubFont} from "../../../_models/preferences/epub-font"; /** * Used for book reader. Do not use for other components @@ -95,11 +97,6 @@ export class ReaderSettingsComponent implements OnInit { @Input({required:true}) readingProfile!: ReadingProfile; @Input({required:true}) readerSettingsService!: EpubReaderSettingsService; - /** - * List of all font families user can select from - */ - fontOptions: Array = []; - fontFamilies: Array = []; settingsForm!: BookReadingProfileFormGroup; /** * System provided themes @@ -118,6 +115,7 @@ export class ReaderSettingsComponent implements OnInit { protected hasParentProfile!: Signal; protected parentReadingProfile!: Signal; protected currentReadingProfile!: Signal; + protected epubFonts!: Signal; protected isVerticalLayout!: Signal; @@ -136,18 +134,17 @@ export class ReaderSettingsComponent implements OnInit { this.hasParentProfile = this.readerSettingsService.hasParentProfile; this.parentReadingProfile = this.readerSettingsService.parentReadingProfile; this.currentReadingProfile = this.readerSettingsService.currentReadingProfile; + this.epubFonts = this.readerSettingsService.epubFonts; + this.themes = this.readerSettingsService.getThemes(); - // Initialize the service if not already done if (!this.readerSettingsService.getCurrentReadingProfile()) { await this.readerSettingsService.initialize(this.seriesId, this.readingProfile); } this.settingsForm = this.readerSettingsService.getSettingsForm(); - this.fontFamilies = this.readerSettingsService.getFontFamilies(); - this.fontOptions = this.fontFamilies.map(f => f.title); this.cdRef.markForCheck(); } diff --git a/UI/Web/src/app/book-reader/_services/book.service.ts b/UI/Web/src/app/book-reader/_services/book.service.ts index b7998fd23..5b44dcd53 100644 --- a/UI/Web/src/app/book-reader/_services/book.service.ts +++ b/UI/Web/src/app/book-reader/_services/book.service.ts @@ -5,17 +5,6 @@ import {environment} from 'src/environments/environment'; import {BookChapterItem} from '../_models/book-chapter-item'; import {BookInfo} from '../_models/book-info'; -export interface FontFamily { - /** - * What the user should see - */ - title: string; - /** - * The actual font face - */ - family: string; -} - @Injectable({ providedIn: 'root' }) @@ -25,13 +14,6 @@ export class BookService { private readonly baseUrl = environment.apiUrl; - getFontFamilies(): Array { - return [{title: 'default', family: 'default'}, {title: 'EBGaramond', family: 'EBGaramond'}, {title: 'Fira Sans', family: 'Fira_Sans'}, - {title: 'Lato', family: 'Lato'}, {title: 'Libre Baskerville', family: 'Libre_Baskerville'}, {title: 'Merriweather', family: 'Merriweather'}, - {title: 'Nanum Gothic', family: 'Nanum_Gothic'}, {title: 'Open Dyslexic', family: 'OpenDyslexic2'}, {title: 'RocknRoll One', family: 'RocknRoll_One'}, - {title: 'Fast Font Serif (Bionic)', family: 'FastFontSerif'}, {title: 'Fast Font Sans (Bionic)', family: 'FastFontSans'}]; - } - getBookChapters(chapterId: number) { return this.http.get>(this.baseUrl + 'book/' + chapterId + '/chapters'); } 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 76295dec8..659846fc5 100644 --- a/UI/Web/src/app/settings/_components/settings/settings.component.html +++ b/UI/Web/src/app/settings/_components/settings/settings.component.html @@ -201,6 +201,14 @@ } } + @defer (when fragment === SettingsTabId.Font; prefetch on idle) { + @if (fragment === SettingsTabId.Font) { +
+ +
+ } + } + @defer (when fragment === SettingsTabId.Devices; prefetch on idle) { @if (fragment === SettingsTabId.Devices) {
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 99eb73293..6d1d605bd 100644 --- a/UI/Web/src/app/settings/_components/settings/settings.component.ts +++ b/UI/Web/src/app/settings/_components/settings/settings.component.ts @@ -60,6 +60,7 @@ import { } from "../../../admin/manage-public-metadata-settings/manage-public-metadata-settings.component"; 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"; @Component({ selector: 'app-settings', @@ -99,7 +100,8 @@ import {ManageOpenIDConnectComponent} from "../../../admin/manage-open-idconnect ManageReadingProfilesComponent, ManageOpenIDConnectComponent, ManagePublicMetadataSettingsComponent, - ImportMappingsComponent + ImportMappingsComponent, + FontManagerComponent ], templateUrl: './settings.component.html', styleUrl: './settings.component.scss', 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 0a31f1f8b..997e233b8 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 @@ -53,6 +53,7 @@ export enum SettingsTabId { Account = 'account', Preferences = 'preferences', ReadingProfiles = 'reading-profiles', + Font = 'font', Clients = 'clients', Theme = 'theme', Devices = 'devices', @@ -224,6 +225,7 @@ export class PreferenceNavComponent implements AfterViewInit { new SideNavItem(SettingsTabId.Customize, [], undefined, [Role.ReadOnly]), new SideNavItem(SettingsTabId.Clients), new SideNavItem(SettingsTabId.Theme), + new SideNavItem(SettingsTabId.Font), new SideNavItem(SettingsTabId.Devices), new SideNavItem(SettingsTabId.UserStats), ] diff --git a/UI/Web/src/app/user-settings/font-manager/font-manager/font-manager.component.html b/UI/Web/src/app/user-settings/font-manager/font-manager/font-manager.component.html new file mode 100644 index 000000000..83630f292 --- /dev/null +++ b/UI/Web/src/app/user-settings/font-manager/font-manager/font-manager.component.html @@ -0,0 +1,154 @@ + +
+ +

+
+
+
+
+ +
+ +
+ + +
+
+ +
    + + @for (font of visibleFonts(); track font.id) { + + } @empty { + + } +
+
+
+ +
+
+ + @let currentFont = selectedFont(); + @if (currentFont === undefined) { +
+
+
+
+ {{t('preview-default')}} +
+
+
+
+ + @if (files && files.length > 0) { + + } @else { + + + + + @switch (uploadMode()) { + @case ('all') { + + } + @case ('url') { +
+
+ + + +
+ +
+ } + } +
+ +
+ } + } @else { +

+ {{currentFont.name | sentenceCase}} +
+ @if (currentFont.provider !== FontProvider.System && currentFont.name !== FontService.DefaultEpubFont) { + + } +
+

+ +
+ +
+ } +
+
+
+
+
+ + + @if (item !== undefined) { + @let currentFont = selectedFont(); +
  • +
    +
    {{item.name | sentenceCase}}
    +
    +
    + {{item.provider | siteThemeProvider}} +
    +
  • + } +
    + + + @if (item) { +
    + @if (item.name === FontService.DefaultEpubFont) { +
    + {{t('no-preview')}} +
    + } +
    + + @if (item.name !== FontService.DefaultEpubFont) { +
    + {{t('brown-fox')}} +
    + } + } +
    +
    diff --git a/UI/Web/src/app/user-settings/font-manager/font-manager/font-manager.component.scss b/UI/Web/src/app/user-settings/font-manager/font-manager/font-manager.component.scss new file mode 100644 index 000000000..a646baded --- /dev/null +++ b/UI/Web/src/app/user-settings/font-manager/font-manager/font-manager.component.scss @@ -0,0 +1,48 @@ +.pill { + font-size: .8rem; + background-color: var(--card-bg-color); + border-radius: 0.375rem; +} + +.scroller { + max-height: calc(100dvh - 180px); + overflow-y: auto; + scrollbar-gutter: stable; + + @media (max-width: 576px) { + max-height: calc(100dvh - 480px); + } +} + +.list-group-item { + background-color: transparent; + + &:hover { + background-color: var(--card-bg-color); + } + + &.active { + background-color: var(--primary-color); + } +} + +.list-group-item, .list-group-item.active { + border-top-width: 0; + border-bottom-width: 0; +} + +ngx-file-drop ::ng-deep > div { + // styling for the outer drop box + width: 100%; + border: 2px solid var(--primary-color); + border-radius: 5px; + height: 100px; + margin: auto; + + > div { + // styling for the inner box (template) + width: 100%; + display: inline-block; + + } +} diff --git a/UI/Web/src/app/user-settings/font-manager/font-manager/font-manager.component.ts b/UI/Web/src/app/user-settings/font-manager/font-manager/font-manager.component.ts new file mode 100644 index 000000000..95cacd5a9 --- /dev/null +++ b/UI/Web/src/app/user-settings/font-manager/font-manager/font-manager.component.ts @@ -0,0 +1,214 @@ +import {ChangeDetectionStrategy, Component, computed, inject, OnInit, signal} from '@angular/core'; +import {FontService} from "src/app/_services/font.service"; +import {AccountService} from "../../../_services/account.service"; +import {ConfirmService} from "../../../shared/confirm.service"; +import {EpubFont, FontProvider} from 'src/app/_models/preferences/epub-font'; +import {NgxFileDropEntry, NgxFileDropModule} from "ngx-file-drop"; +import {DOCUMENT, NgStyle, NgTemplateOutlet} from "@angular/common"; +import {LoadingComponent} from "../../../shared/loading/loading.component"; +import {FormControl, FormGroup, FormsModule, ReactiveFormsModule} from "@angular/forms"; +import {SentenceCasePipe} from "../../../_pipes/sentence-case.pipe"; +import {SiteThemeProviderPipe} from "../../../_pipes/site-theme-provider.pipe"; +import {translate, TranslocoDirective} from "@jsverse/transloco"; +import {animate, style, transition, trigger} from "@angular/animations"; +import {WikiLink} from "../../../_models/wiki"; +import {ToastrService} from "ngx-toastr"; + +@Component({ + selector: 'app-font-manager', + imports: [ + LoadingComponent, + NgxFileDropModule, + FormsModule, + ReactiveFormsModule, + SentenceCasePipe, + SiteThemeProviderPipe, + NgTemplateOutlet, + TranslocoDirective, + NgStyle, + ], + templateUrl: './font-manager.component.html', + styleUrl: './font-manager.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + animations: [ + trigger('loadNewFontAnimation', [ + transition('void => loaded', [ + style({ backgroundColor: 'var(--primary-color)' }), + animate('2s', style({ backgroundColor: 'var(--list-group-item-bg-color)' })) + ]) + ]) + ], +}) +export class FontManagerComponent implements OnInit { + private readonly document = inject(DOCUMENT); + private readonly accountService = inject(AccountService); + private readonly confirmService = inject(ConfirmService); + private readonly toastr = inject(ToastrService); + protected readonly fontService = inject(FontService); + + protected readonly FontProvider = FontProvider; + protected readonly WikiLink = WikiLink.EpubFontManager; + protected readonly user = this.accountService.currentUserSignal; + protected readonly isReadOnly = computed(() => { + const u = this.accountService.currentUserSignal(); + if (!u) return true; + + return this.accountService.hasReadOnlyRole(u); + }); + + fonts = signal([]); + visibleFonts = computed(() => { + const fonts = this.fonts(); + const hide = this.hideSystemFonts(); + if (!hide) return fonts; + + return fonts.filter(f => f.provider === FontProvider.User); + }); + + hideSystemFonts = signal(false); + + + /** + * Fonts added during the current sessions + */ + loadedFonts = signal([]); + + selectedFont = signal(undefined); + isUploadingFont = signal(false); + uploadMode = signal<'file' | 'url' | 'all'>('all'); + + form: FormGroup = new FormGroup({ + fontUrl: new FormControl('', []), + filter: new FormControl(this.hideSystemFonts(), []) + }); + + files: NgxFileDropEntry[] = []; + // When accepting more types, also need to update in the Parser + acceptableExtensions = ['.woff2', '.woff', '.ttf', '.otf'].join(','); + + ngOnInit() { + this.loadFonts(); + } + + loadFonts() { + this.fontService.getFonts().subscribe(fonts => { + this.fonts.set(fonts); + + // First load, if there are user provided fonts, switch the filter toggle + if (fonts.filter(f => f.provider != FontProvider.System).length > 0 && !this.hideSystemFonts()) { + this.setHideSystemFontsFilter(true); + } + }); + } + + selectFont(font: EpubFont | undefined) { + if (font === undefined) { + this.selectedFont.set(font); + return; + } + + + if (font.name !== FontService.DefaultEpubFont) { + this.fontService.getFontFace(font).load().then(loadedFace => { + (this.document as any).fonts.add(loadedFace); + this.selectedFont.set(font); + }); + } else { + this.selectedFont.set(font); + } + } + + dropped(files: NgxFileDropEntry[]) { + for (const droppedFile of files) { + if (!droppedFile.fileEntry.isFile) { + continue; + } + + const fileEntry = droppedFile.fileEntry as FileSystemFileEntry; + fileEntry.file((file: File) => { + this.fontService.uploadFont(file, droppedFile).subscribe(f => { + this.addFont(f); + this.isUploadingFont.set(false); + }); + }); + } + + this.isUploadingFont.set(true); + } + + uploadFromUrl() { + const url = this.form.get('fontUrl')?.value.trim(); + if (!url || url === '') return; + + this.isUploadingFont.set(true); + this.fontService.uploadFromUrl(url).subscribe((f) => { + this.addFont(f); + this.form.get('fontUrl')!.setValue(''); + this.isUploadingFont.set(false); + }); + } + + async deleteFont(id: number) { + if (!await this.confirmService.confirm(translate('toasts.confirm-delete-font'))) { + return; + } + + // Check if this font is in use + this.fontService.isFontInUse(id).subscribe(async (inUse) => { + if (!inUse) { + this.performDeleteFont(id); + return; + } + + const isAdmin = this.accountService.hasAdminRole(this.accountService.currentUserSignal()!); + + if (!isAdmin) { + this.toastr.info(translate('toasts.font-in-use')) + return; + } + + if (!await this.confirmService.confirm(translate('toasts.confirm-force-delete-font'))) { + return; + } + this.performDeleteFont(id, true); + }) + + + } + + private setHideSystemFontsFilter(value: boolean) { + this.hideSystemFonts.set(value); + this.form.get('filter')?.setValue(value); + } + + private performDeleteFont(id: number, force: boolean = false) { + this.fontService.deleteFont(id, force).subscribe(() => { + this.fonts.update(x => x.filter(f => f.id !== id)); + + // Select the first font in the list + const visibleFonts = this.visibleFonts(); + if (visibleFonts.length === 0 && this.hideSystemFonts()) { + this.setHideSystemFontsFilter(false); + this.selectFont(this.fonts()[0]); // Default + return; + } + + if (visibleFonts.length > 0) { + this.selectFont(visibleFonts[visibleFonts.length - 1]); + } + }); + } + + private addFont(font: EpubFont) { + this.fonts.update(x => [...x, font]); + this.loadedFonts.update(x => [...x, font]); + setTimeout(() => this.selectedFont.set(font), 100); + } + + animationState(font: EpubFont) { + return this.loadedFonts().includes(font) ? 'loaded' : ''; + } + + protected readonly FontService = FontService; +} diff --git a/UI/Web/src/app/user-settings/manage-reading-profiles/manage-reading-profiles.component.html b/UI/Web/src/app/user-settings/manage-reading-profiles/manage-reading-profiles.component.html index edec261eb..bf9f57d62 100644 --- a/UI/Web/src/app/user-settings/manage-reading-profiles/manage-reading-profiles.component.html +++ b/UI/Web/src/app/user-settings/manage-reading-profiles/manage-reading-profiles.component.html @@ -332,8 +332,8 @@ diff --git a/UI/Web/src/app/user-settings/manage-reading-profiles/manage-reading-profiles.component.ts b/UI/Web/src/app/user-settings/manage-reading-profiles/manage-reading-profiles.component.ts index eb5d262ac..d96f821a5 100644 --- a/UI/Web/src/app/user-settings/manage-reading-profiles/manage-reading-profiles.component.ts +++ b/UI/Web/src/app/user-settings/manage-reading-profiles/manage-reading-profiles.component.ts @@ -1,8 +1,18 @@ -import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit, signal} from '@angular/core'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DestroyRef, + effect, + inject, + OnInit, + signal +} from '@angular/core'; import {ReadingProfileService} from "../../_services/reading-profile.service"; import { bookLayoutModes, - bookWritingStyles, breakPoints, + bookWritingStyles, + breakPoints, layoutModes, pageSplitOptions, pdfScrollModes, @@ -19,7 +29,7 @@ import {NgStyle, NgTemplateOutlet, TitleCasePipe} from "@angular/common"; import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller"; import {User} from "../../_models/user"; import {AccountService} from "../../_services/account.service"; -import {debounceTime, distinctUntilChanged, map, take, tap} from "rxjs/operators"; +import {debounceTime, distinctUntilChanged, tap} from "rxjs/operators"; import {SentenceCasePipe} from "../../_pipes/sentence-case.pipe"; import {FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators} from "@angular/forms"; import {BookService} from "../../book-reader/_services/book.service"; @@ -41,7 +51,7 @@ import {SettingItemComponent} from "../../settings/_components/setting-item/sett import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component"; import {WritingStylePipe} from "../../_pipes/writing-style.pipe"; import {NgbNav, NgbNavContent, NgbNavItem, NgbNavLinkBase, NgbNavOutlet, NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; -import {catchError, filter, of, switchMap} from "rxjs"; +import {catchError, filter, forkJoin, of, switchMap} from "rxjs"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {LoadingComponent} from "../../shared/loading/loading.component"; import {ToastrService} from "ngx-toastr"; @@ -53,6 +63,8 @@ import { } from "../../settings/_components/setting-colour-picker/setting-color-picker.component"; import {ColorscapeService} from "../../_services/colorscape.service"; import {Color} from "@iplab/ngx-color-picker"; +import {FontService} from "../../_services/font.service"; +import {EpubFont} from "../../_models/preferences/epub-font"; enum TabId { ImageReader = "image-reader", @@ -108,12 +120,13 @@ export class ManageReadingProfilesComponent implements OnInit { private readonly toastr = inject(ToastrService); private readonly confirmService = inject(ConfirmService); private readonly transLoco = inject(TranslocoService); + private readonly fontService = inject(FontService); virtualScrollerBreakPoint = 20; savingProfile = signal(false); + fonts = signal([]); - fontFamilies: Array = []; readingProfiles: ReadingProfile[] = []; user!: User; activeTabId = TabId.ImageReader; @@ -128,18 +141,21 @@ export class ManageReadingProfilesComponent implements OnInit { }); constructor() { - this.fontFamilies = this.bookService.getFontFamilies().map(f => f.title); - this.cdRef.markForCheck(); - } - - ngOnInit(): void { - this.accountService.currentUser$.pipe(take(1)).subscribe(user => { + effect(() => { + const user = this.accountService.currentUserSignal(); if (user) { this.user = user; } }); + } + + ngOnInit(): void { + forkJoin([ + this.fontService.getFonts(), + this.readingProfileService.getAllProfiles() + ]).subscribe(([fonts, profiles]) => { + this.fonts.set(fonts); - this.readingProfileService.getAllProfiles().subscribe(profiles => { this.readingProfiles = profiles; this.loading = false; this.setupForm(); @@ -149,7 +165,6 @@ export class ManageReadingProfilesComponent implements OnInit { this.cdRef.markForCheck(); }); - } async delete(readingProfile: ReadingProfile) { @@ -175,7 +190,7 @@ export class ManageReadingProfilesComponent implements OnInit { return (val <= 0) ? '' : val + '%' } - setupForm() { + async setupForm() { if (this.selectedProfile == null) { return; } @@ -183,8 +198,8 @@ export class ManageReadingProfilesComponent implements OnInit { this.readingProfileForm = new FormGroup({}) - if (this.fontFamilies.indexOf(this.selectedProfile.bookReaderFontFamily) < 0) { - this.selectedProfile.bookReaderFontFamily = 'default'; + if (this.fonts().find(font => font.name === this.selectedProfile?.bookReaderFontFamily) === undefined) { + this.selectedProfile.bookReaderFontFamily = FontService.DefaultEpubFont; } this.readingProfileForm.addControl('name', new FormControl(this.selectedProfile.name, Validators.required)); 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 e49566296..f42fc348a 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 @@ -46,7 +46,6 @@ export class ManageUserPreferencesComponent implements OnInit { protected readonly licenseService = inject(LicenseService); - fontFamilies: Array = []; locales: Array = []; settingsForm: FormGroup = new FormGroup({}); @@ -65,9 +64,6 @@ export class ManageUserPreferencesComponent implements OnInit { constructor() { - this.fontFamilies = this.bookService.getFontFamilies().map(f => f.title); - this.cdRef.markForCheck(); - this.localizationService.getLocales().subscribe(res => { this.locales = res; diff --git a/UI/Web/src/assets/fonts/EBGarmond/EBGaramond-Italic-VariableFont_wght.ttf b/UI/Web/src/assets/fonts/EB Garamond/EBGaramond-Italic-VariableFont_wght.ttf similarity index 100% rename from UI/Web/src/assets/fonts/EBGarmond/EBGaramond-Italic-VariableFont_wght.ttf rename to UI/Web/src/assets/fonts/EB Garamond/EBGaramond-Italic-VariableFont_wght.ttf diff --git a/UI/Web/src/assets/fonts/EBGarmond/EBGaramond-Italic-VariableFont_wght.woff2 b/UI/Web/src/assets/fonts/EB Garamond/EBGaramond-Italic-VariableFont_wght.woff2 similarity index 100% rename from UI/Web/src/assets/fonts/EBGarmond/EBGaramond-Italic-VariableFont_wght.woff2 rename to UI/Web/src/assets/fonts/EB Garamond/EBGaramond-Italic-VariableFont_wght.woff2 diff --git a/UI/Web/src/assets/fonts/EBGarmond/EBGaramond-VariableFont_wght.ttf b/UI/Web/src/assets/fonts/EB Garamond/EBGaramond-VariableFont_wght.ttf similarity index 100% rename from UI/Web/src/assets/fonts/EBGarmond/EBGaramond-VariableFont_wght.ttf rename to UI/Web/src/assets/fonts/EB Garamond/EBGaramond-VariableFont_wght.ttf diff --git a/UI/Web/src/assets/fonts/EBGarmond/EBGaramond-VariableFont_wght.woff2 b/UI/Web/src/assets/fonts/EB Garamond/EBGaramond-VariableFont_wght.woff2 similarity index 100% rename from UI/Web/src/assets/fonts/EBGarmond/EBGaramond-VariableFont_wght.woff2 rename to UI/Web/src/assets/fonts/EB Garamond/EBGaramond-VariableFont_wght.woff2 diff --git a/UI/Web/src/assets/fonts/EBGarmond/OFL.txt b/UI/Web/src/assets/fonts/EB Garamond/OFL.txt similarity index 100% rename from UI/Web/src/assets/fonts/EBGarmond/OFL.txt rename to UI/Web/src/assets/fonts/EB Garamond/OFL.txt diff --git a/UI/Web/src/assets/fonts/Fast_Font/Fast_Sans.woff2 b/UI/Web/src/assets/fonts/Fast Font Sans/Fast_Sans.woff2 similarity index 100% rename from UI/Web/src/assets/fonts/Fast_Font/Fast_Sans.woff2 rename to UI/Web/src/assets/fonts/Fast Font Sans/Fast_Sans.woff2 diff --git a/UI/Web/src/assets/fonts/Fast_Font/Fast_Serif.woff2 b/UI/Web/src/assets/fonts/Fast Font Serif/Fast_Serif.woff2 similarity index 100% rename from UI/Web/src/assets/fonts/Fast_Font/Fast_Serif.woff2 rename to UI/Web/src/assets/fonts/Fast Font Serif/Fast_Serif.woff2 diff --git a/UI/Web/src/assets/fonts/Fira_Sans/FiraSans-Black.ttf b/UI/Web/src/assets/fonts/Fira Sans/FiraSans-Black.ttf similarity index 100% rename from UI/Web/src/assets/fonts/Fira_Sans/FiraSans-Black.ttf rename to UI/Web/src/assets/fonts/Fira Sans/FiraSans-Black.ttf diff --git a/UI/Web/src/assets/fonts/Fira_Sans/FiraSans-Black.woff2 b/UI/Web/src/assets/fonts/Fira Sans/FiraSans-Black.woff2 similarity index 100% rename from UI/Web/src/assets/fonts/Fira_Sans/FiraSans-Black.woff2 rename to UI/Web/src/assets/fonts/Fira Sans/FiraSans-Black.woff2 diff --git a/UI/Web/src/assets/fonts/Fira_Sans/FiraSans-BlackItalic.ttf b/UI/Web/src/assets/fonts/Fira Sans/FiraSans-BlackItalic.ttf similarity index 100% rename from UI/Web/src/assets/fonts/Fira_Sans/FiraSans-BlackItalic.ttf rename to UI/Web/src/assets/fonts/Fira Sans/FiraSans-BlackItalic.ttf diff --git a/UI/Web/src/assets/fonts/Fira_Sans/FiraSans-BlackItalic.woff2 b/UI/Web/src/assets/fonts/Fira Sans/FiraSans-BlackItalic.woff2 similarity index 100% rename from UI/Web/src/assets/fonts/Fira_Sans/FiraSans-BlackItalic.woff2 rename to UI/Web/src/assets/fonts/Fira Sans/FiraSans-BlackItalic.woff2 diff --git a/UI/Web/src/assets/fonts/Fira_Sans/FiraSans-Bold.ttf b/UI/Web/src/assets/fonts/Fira Sans/FiraSans-Bold.ttf similarity index 100% rename from UI/Web/src/assets/fonts/Fira_Sans/FiraSans-Bold.ttf rename to UI/Web/src/assets/fonts/Fira Sans/FiraSans-Bold.ttf diff --git a/UI/Web/src/assets/fonts/Fira_Sans/FiraSans-Bold.woff2 b/UI/Web/src/assets/fonts/Fira Sans/FiraSans-Bold.woff2 similarity index 100% rename from UI/Web/src/assets/fonts/Fira_Sans/FiraSans-Bold.woff2 rename to UI/Web/src/assets/fonts/Fira Sans/FiraSans-Bold.woff2 diff --git a/UI/Web/src/assets/fonts/Fira_Sans/FiraSans-BoldItalic.ttf b/UI/Web/src/assets/fonts/Fira Sans/FiraSans-BoldItalic.ttf similarity index 100% rename from UI/Web/src/assets/fonts/Fira_Sans/FiraSans-BoldItalic.ttf rename to UI/Web/src/assets/fonts/Fira Sans/FiraSans-BoldItalic.ttf diff --git a/UI/Web/src/assets/fonts/Fira_Sans/FiraSans-BoldItalic.woff2 b/UI/Web/src/assets/fonts/Fira Sans/FiraSans-BoldItalic.woff2 similarity index 100% rename from UI/Web/src/assets/fonts/Fira_Sans/FiraSans-BoldItalic.woff2 rename to UI/Web/src/assets/fonts/Fira Sans/FiraSans-BoldItalic.woff2 diff --git a/UI/Web/src/assets/fonts/Fira_Sans/FiraSans-ExtraBold.ttf b/UI/Web/src/assets/fonts/Fira Sans/FiraSans-ExtraBold.ttf similarity index 100% rename from UI/Web/src/assets/fonts/Fira_Sans/FiraSans-ExtraBold.ttf rename to UI/Web/src/assets/fonts/Fira Sans/FiraSans-ExtraBold.ttf diff --git a/UI/Web/src/assets/fonts/Fira_Sans/FiraSans-ExtraBold.woff2 b/UI/Web/src/assets/fonts/Fira Sans/FiraSans-ExtraBold.woff2 similarity index 100% rename from UI/Web/src/assets/fonts/Fira_Sans/FiraSans-ExtraBold.woff2 rename to UI/Web/src/assets/fonts/Fira Sans/FiraSans-ExtraBold.woff2 diff --git a/UI/Web/src/assets/fonts/Fira_Sans/FiraSans-ExtraBoldItalic.ttf b/UI/Web/src/assets/fonts/Fira Sans/FiraSans-ExtraBoldItalic.ttf similarity index 100% rename from UI/Web/src/assets/fonts/Fira_Sans/FiraSans-ExtraBoldItalic.ttf rename to UI/Web/src/assets/fonts/Fira Sans/FiraSans-ExtraBoldItalic.ttf diff --git a/UI/Web/src/assets/fonts/Fira_Sans/FiraSans-ExtraBoldItalic.woff2 b/UI/Web/src/assets/fonts/Fira Sans/FiraSans-ExtraBoldItalic.woff2 similarity index 100% rename from UI/Web/src/assets/fonts/Fira_Sans/FiraSans-ExtraBoldItalic.woff2 rename to UI/Web/src/assets/fonts/Fira Sans/FiraSans-ExtraBoldItalic.woff2 diff --git a/UI/Web/src/assets/fonts/Fira_Sans/FiraSans-ExtraLight.ttf b/UI/Web/src/assets/fonts/Fira Sans/FiraSans-ExtraLight.ttf similarity index 100% rename from UI/Web/src/assets/fonts/Fira_Sans/FiraSans-ExtraLight.ttf rename to UI/Web/src/assets/fonts/Fira Sans/FiraSans-ExtraLight.ttf diff --git a/UI/Web/src/assets/fonts/Fira_Sans/FiraSans-ExtraLight.woff2 b/UI/Web/src/assets/fonts/Fira Sans/FiraSans-ExtraLight.woff2 similarity index 100% rename from UI/Web/src/assets/fonts/Fira_Sans/FiraSans-ExtraLight.woff2 rename to UI/Web/src/assets/fonts/Fira Sans/FiraSans-ExtraLight.woff2 diff --git a/UI/Web/src/assets/fonts/Fira_Sans/FiraSans-ExtraLightItalic.ttf b/UI/Web/src/assets/fonts/Fira Sans/FiraSans-ExtraLightItalic.ttf similarity index 100% rename from UI/Web/src/assets/fonts/Fira_Sans/FiraSans-ExtraLightItalic.ttf rename to UI/Web/src/assets/fonts/Fira Sans/FiraSans-ExtraLightItalic.ttf diff --git a/UI/Web/src/assets/fonts/Fira_Sans/FiraSans-ExtraLightItalic.woff2 b/UI/Web/src/assets/fonts/Fira Sans/FiraSans-ExtraLightItalic.woff2 similarity index 100% rename from UI/Web/src/assets/fonts/Fira_Sans/FiraSans-ExtraLightItalic.woff2 rename to UI/Web/src/assets/fonts/Fira Sans/FiraSans-ExtraLightItalic.woff2 diff --git a/UI/Web/src/assets/fonts/Fira_Sans/FiraSans-Italic.ttf b/UI/Web/src/assets/fonts/Fira Sans/FiraSans-Italic.ttf similarity index 100% rename from UI/Web/src/assets/fonts/Fira_Sans/FiraSans-Italic.ttf rename to UI/Web/src/assets/fonts/Fira Sans/FiraSans-Italic.ttf diff --git a/UI/Web/src/assets/fonts/Fira_Sans/FiraSans-Italic.woff2 b/UI/Web/src/assets/fonts/Fira Sans/FiraSans-Italic.woff2 similarity index 100% rename from UI/Web/src/assets/fonts/Fira_Sans/FiraSans-Italic.woff2 rename to UI/Web/src/assets/fonts/Fira Sans/FiraSans-Italic.woff2 diff --git a/UI/Web/src/assets/fonts/Fira_Sans/FiraSans-Light.ttf b/UI/Web/src/assets/fonts/Fira Sans/FiraSans-Light.ttf similarity index 100% rename from UI/Web/src/assets/fonts/Fira_Sans/FiraSans-Light.ttf rename to UI/Web/src/assets/fonts/Fira Sans/FiraSans-Light.ttf diff --git a/UI/Web/src/assets/fonts/Fira_Sans/FiraSans-Light.woff2 b/UI/Web/src/assets/fonts/Fira Sans/FiraSans-Light.woff2 similarity index 100% rename from UI/Web/src/assets/fonts/Fira_Sans/FiraSans-Light.woff2 rename to UI/Web/src/assets/fonts/Fira Sans/FiraSans-Light.woff2 diff --git a/UI/Web/src/assets/fonts/Fira_Sans/FiraSans-LightItalic.ttf b/UI/Web/src/assets/fonts/Fira Sans/FiraSans-LightItalic.ttf similarity index 100% rename from UI/Web/src/assets/fonts/Fira_Sans/FiraSans-LightItalic.ttf rename to UI/Web/src/assets/fonts/Fira Sans/FiraSans-LightItalic.ttf diff --git a/UI/Web/src/assets/fonts/Fira_Sans/FiraSans-LightItalic.woff2 b/UI/Web/src/assets/fonts/Fira Sans/FiraSans-LightItalic.woff2 similarity index 100% rename from UI/Web/src/assets/fonts/Fira_Sans/FiraSans-LightItalic.woff2 rename to UI/Web/src/assets/fonts/Fira Sans/FiraSans-LightItalic.woff2 diff --git a/UI/Web/src/assets/fonts/Fira_Sans/FiraSans-Medium.ttf b/UI/Web/src/assets/fonts/Fira Sans/FiraSans-Medium.ttf similarity index 100% rename from UI/Web/src/assets/fonts/Fira_Sans/FiraSans-Medium.ttf rename to UI/Web/src/assets/fonts/Fira Sans/FiraSans-Medium.ttf diff --git a/UI/Web/src/assets/fonts/Fira_Sans/FiraSans-Medium.woff2 b/UI/Web/src/assets/fonts/Fira Sans/FiraSans-Medium.woff2 similarity index 100% rename from UI/Web/src/assets/fonts/Fira_Sans/FiraSans-Medium.woff2 rename to UI/Web/src/assets/fonts/Fira Sans/FiraSans-Medium.woff2 diff --git a/UI/Web/src/assets/fonts/Fira_Sans/FiraSans-MediumItalic.ttf b/UI/Web/src/assets/fonts/Fira Sans/FiraSans-MediumItalic.ttf similarity index 100% rename from UI/Web/src/assets/fonts/Fira_Sans/FiraSans-MediumItalic.ttf rename to UI/Web/src/assets/fonts/Fira Sans/FiraSans-MediumItalic.ttf diff --git a/UI/Web/src/assets/fonts/Fira_Sans/FiraSans-MediumItalic.woff2 b/UI/Web/src/assets/fonts/Fira Sans/FiraSans-MediumItalic.woff2 similarity index 100% rename from UI/Web/src/assets/fonts/Fira_Sans/FiraSans-MediumItalic.woff2 rename to UI/Web/src/assets/fonts/Fira Sans/FiraSans-MediumItalic.woff2 diff --git a/UI/Web/src/assets/fonts/Fira_Sans/FiraSans-Regular.ttf b/UI/Web/src/assets/fonts/Fira Sans/FiraSans-Regular.ttf similarity index 100% rename from UI/Web/src/assets/fonts/Fira_Sans/FiraSans-Regular.ttf rename to UI/Web/src/assets/fonts/Fira Sans/FiraSans-Regular.ttf diff --git a/UI/Web/src/assets/fonts/Fira_Sans/FiraSans-Regular.woff2 b/UI/Web/src/assets/fonts/Fira Sans/FiraSans-Regular.woff2 similarity index 100% rename from UI/Web/src/assets/fonts/Fira_Sans/FiraSans-Regular.woff2 rename to UI/Web/src/assets/fonts/Fira Sans/FiraSans-Regular.woff2 diff --git a/UI/Web/src/assets/fonts/Fira_Sans/FiraSans-SemiBold.ttf b/UI/Web/src/assets/fonts/Fira Sans/FiraSans-SemiBold.ttf similarity index 100% rename from UI/Web/src/assets/fonts/Fira_Sans/FiraSans-SemiBold.ttf rename to UI/Web/src/assets/fonts/Fira Sans/FiraSans-SemiBold.ttf diff --git a/UI/Web/src/assets/fonts/Fira_Sans/FiraSans-SemiBold.woff2 b/UI/Web/src/assets/fonts/Fira Sans/FiraSans-SemiBold.woff2 similarity index 100% rename from UI/Web/src/assets/fonts/Fira_Sans/FiraSans-SemiBold.woff2 rename to UI/Web/src/assets/fonts/Fira Sans/FiraSans-SemiBold.woff2 diff --git a/UI/Web/src/assets/fonts/Fira_Sans/FiraSans-SemiBoldItalic.ttf b/UI/Web/src/assets/fonts/Fira Sans/FiraSans-SemiBoldItalic.ttf similarity index 100% rename from UI/Web/src/assets/fonts/Fira_Sans/FiraSans-SemiBoldItalic.ttf rename to UI/Web/src/assets/fonts/Fira Sans/FiraSans-SemiBoldItalic.ttf diff --git a/UI/Web/src/assets/fonts/Fira_Sans/FiraSans-SemiBoldItalic.woff2 b/UI/Web/src/assets/fonts/Fira Sans/FiraSans-SemiBoldItalic.woff2 similarity index 100% rename from UI/Web/src/assets/fonts/Fira_Sans/FiraSans-SemiBoldItalic.woff2 rename to UI/Web/src/assets/fonts/Fira Sans/FiraSans-SemiBoldItalic.woff2 diff --git a/UI/Web/src/assets/fonts/Fira_Sans/FiraSans-Thin.ttf b/UI/Web/src/assets/fonts/Fira Sans/FiraSans-Thin.ttf similarity index 100% rename from UI/Web/src/assets/fonts/Fira_Sans/FiraSans-Thin.ttf rename to UI/Web/src/assets/fonts/Fira Sans/FiraSans-Thin.ttf diff --git a/UI/Web/src/assets/fonts/Fira_Sans/FiraSans-Thin.woff2 b/UI/Web/src/assets/fonts/Fira Sans/FiraSans-Thin.woff2 similarity index 100% rename from UI/Web/src/assets/fonts/Fira_Sans/FiraSans-Thin.woff2 rename to UI/Web/src/assets/fonts/Fira Sans/FiraSans-Thin.woff2 diff --git a/UI/Web/src/assets/fonts/Fira_Sans/FiraSans-ThinItalic.ttf b/UI/Web/src/assets/fonts/Fira Sans/FiraSans-ThinItalic.ttf similarity index 100% rename from UI/Web/src/assets/fonts/Fira_Sans/FiraSans-ThinItalic.ttf rename to UI/Web/src/assets/fonts/Fira Sans/FiraSans-ThinItalic.ttf diff --git a/UI/Web/src/assets/fonts/Fira_Sans/FiraSans-ThinItalic.woff2 b/UI/Web/src/assets/fonts/Fira Sans/FiraSans-ThinItalic.woff2 similarity index 100% rename from UI/Web/src/assets/fonts/Fira_Sans/FiraSans-ThinItalic.woff2 rename to UI/Web/src/assets/fonts/Fira Sans/FiraSans-ThinItalic.woff2 diff --git a/UI/Web/src/assets/fonts/Fira_Sans/OFL.txt b/UI/Web/src/assets/fonts/Fira Sans/OFL.txt similarity index 100% rename from UI/Web/src/assets/fonts/Fira_Sans/OFL.txt rename to UI/Web/src/assets/fonts/Fira Sans/OFL.txt diff --git a/UI/Web/src/assets/fonts/Libre_Baskerville/LibreBaskerville-Bold.ttf b/UI/Web/src/assets/fonts/Libre Baskerville/LibreBaskerville-Bold.ttf similarity index 100% rename from UI/Web/src/assets/fonts/Libre_Baskerville/LibreBaskerville-Bold.ttf rename to UI/Web/src/assets/fonts/Libre Baskerville/LibreBaskerville-Bold.ttf diff --git a/UI/Web/src/assets/fonts/Libre_Baskerville/LibreBaskerville-Bold.woff2 b/UI/Web/src/assets/fonts/Libre Baskerville/LibreBaskerville-Bold.woff2 similarity index 100% rename from UI/Web/src/assets/fonts/Libre_Baskerville/LibreBaskerville-Bold.woff2 rename to UI/Web/src/assets/fonts/Libre Baskerville/LibreBaskerville-Bold.woff2 diff --git a/UI/Web/src/assets/fonts/Libre_Baskerville/LibreBaskerville-Italic.ttf b/UI/Web/src/assets/fonts/Libre Baskerville/LibreBaskerville-Italic.ttf similarity index 100% rename from UI/Web/src/assets/fonts/Libre_Baskerville/LibreBaskerville-Italic.ttf rename to UI/Web/src/assets/fonts/Libre Baskerville/LibreBaskerville-Italic.ttf diff --git a/UI/Web/src/assets/fonts/Libre_Baskerville/LibreBaskerville-Italic.woff2 b/UI/Web/src/assets/fonts/Libre Baskerville/LibreBaskerville-Italic.woff2 similarity index 100% rename from UI/Web/src/assets/fonts/Libre_Baskerville/LibreBaskerville-Italic.woff2 rename to UI/Web/src/assets/fonts/Libre Baskerville/LibreBaskerville-Italic.woff2 diff --git a/UI/Web/src/assets/fonts/Libre_Baskerville/LibreBaskerville-Regular.ttf b/UI/Web/src/assets/fonts/Libre Baskerville/LibreBaskerville-Regular.ttf similarity index 100% rename from UI/Web/src/assets/fonts/Libre_Baskerville/LibreBaskerville-Regular.ttf rename to UI/Web/src/assets/fonts/Libre Baskerville/LibreBaskerville-Regular.ttf diff --git a/UI/Web/src/assets/fonts/Libre_Baskerville/LibreBaskerville-Regular.woff2 b/UI/Web/src/assets/fonts/Libre Baskerville/LibreBaskerville-Regular.woff2 similarity index 100% rename from UI/Web/src/assets/fonts/Libre_Baskerville/LibreBaskerville-Regular.woff2 rename to UI/Web/src/assets/fonts/Libre Baskerville/LibreBaskerville-Regular.woff2 diff --git a/UI/Web/src/assets/fonts/Libre_Baskerville/OFL.txt b/UI/Web/src/assets/fonts/Libre Baskerville/OFL.txt similarity index 100% rename from UI/Web/src/assets/fonts/Libre_Baskerville/OFL.txt rename to UI/Web/src/assets/fonts/Libre Baskerville/OFL.txt diff --git a/UI/Web/src/assets/fonts/Libre_Caslon/LibreCaslonText-Bold.ttf b/UI/Web/src/assets/fonts/Libre Caslon/LibreCaslonText-Bold.ttf similarity index 100% rename from UI/Web/src/assets/fonts/Libre_Caslon/LibreCaslonText-Bold.ttf rename to UI/Web/src/assets/fonts/Libre Caslon/LibreCaslonText-Bold.ttf diff --git a/UI/Web/src/assets/fonts/Libre_Caslon/LibreCaslonText-Bold.woff2 b/UI/Web/src/assets/fonts/Libre Caslon/LibreCaslonText-Bold.woff2 similarity index 100% rename from UI/Web/src/assets/fonts/Libre_Caslon/LibreCaslonText-Bold.woff2 rename to UI/Web/src/assets/fonts/Libre Caslon/LibreCaslonText-Bold.woff2 diff --git a/UI/Web/src/assets/fonts/Libre_Caslon/LibreCaslonText-Italic.ttf b/UI/Web/src/assets/fonts/Libre Caslon/LibreCaslonText-Italic.ttf similarity index 100% rename from UI/Web/src/assets/fonts/Libre_Caslon/LibreCaslonText-Italic.ttf rename to UI/Web/src/assets/fonts/Libre Caslon/LibreCaslonText-Italic.ttf diff --git a/UI/Web/src/assets/fonts/Libre_Caslon/LibreCaslonText-Italic.woff2 b/UI/Web/src/assets/fonts/Libre Caslon/LibreCaslonText-Italic.woff2 similarity index 100% rename from UI/Web/src/assets/fonts/Libre_Caslon/LibreCaslonText-Italic.woff2 rename to UI/Web/src/assets/fonts/Libre Caslon/LibreCaslonText-Italic.woff2 diff --git a/UI/Web/src/assets/fonts/Libre_Caslon/LibreCaslonText-Regular.ttf b/UI/Web/src/assets/fonts/Libre Caslon/LibreCaslonText-Regular.ttf similarity index 100% rename from UI/Web/src/assets/fonts/Libre_Caslon/LibreCaslonText-Regular.ttf rename to UI/Web/src/assets/fonts/Libre Caslon/LibreCaslonText-Regular.ttf diff --git a/UI/Web/src/assets/fonts/Libre_Caslon/LibreCaslonText-Regular.woff2 b/UI/Web/src/assets/fonts/Libre Caslon/LibreCaslonText-Regular.woff2 similarity index 100% rename from UI/Web/src/assets/fonts/Libre_Caslon/LibreCaslonText-Regular.woff2 rename to UI/Web/src/assets/fonts/Libre Caslon/LibreCaslonText-Regular.woff2 diff --git a/UI/Web/src/assets/fonts/Libre_Caslon/OFL.txt b/UI/Web/src/assets/fonts/Libre Caslon/OFL.txt similarity index 100% rename from UI/Web/src/assets/fonts/Libre_Caslon/OFL.txt rename to UI/Web/src/assets/fonts/Libre Caslon/OFL.txt diff --git a/UI/Web/src/assets/fonts/Nanum_Gothic/NanumGothic-Bold.ttf b/UI/Web/src/assets/fonts/Nanum Gothic/NanumGothic-Bold.ttf similarity index 100% rename from UI/Web/src/assets/fonts/Nanum_Gothic/NanumGothic-Bold.ttf rename to UI/Web/src/assets/fonts/Nanum Gothic/NanumGothic-Bold.ttf diff --git a/UI/Web/src/assets/fonts/Nanum_Gothic/NanumGothic-Bold.woff2 b/UI/Web/src/assets/fonts/Nanum Gothic/NanumGothic-Bold.woff2 similarity index 100% rename from UI/Web/src/assets/fonts/Nanum_Gothic/NanumGothic-Bold.woff2 rename to UI/Web/src/assets/fonts/Nanum Gothic/NanumGothic-Bold.woff2 diff --git a/UI/Web/src/assets/fonts/Nanum_Gothic/NanumGothic-ExtraBold.ttf b/UI/Web/src/assets/fonts/Nanum Gothic/NanumGothic-ExtraBold.ttf similarity index 100% rename from UI/Web/src/assets/fonts/Nanum_Gothic/NanumGothic-ExtraBold.ttf rename to UI/Web/src/assets/fonts/Nanum Gothic/NanumGothic-ExtraBold.ttf diff --git a/UI/Web/src/assets/fonts/Nanum_Gothic/NanumGothic-ExtraBold.woff2 b/UI/Web/src/assets/fonts/Nanum Gothic/NanumGothic-ExtraBold.woff2 similarity index 100% rename from UI/Web/src/assets/fonts/Nanum_Gothic/NanumGothic-ExtraBold.woff2 rename to UI/Web/src/assets/fonts/Nanum Gothic/NanumGothic-ExtraBold.woff2 diff --git a/UI/Web/src/assets/fonts/Nanum_Gothic/NanumGothic-Regular.ttf b/UI/Web/src/assets/fonts/Nanum Gothic/NanumGothic-Regular.ttf similarity index 100% rename from UI/Web/src/assets/fonts/Nanum_Gothic/NanumGothic-Regular.ttf rename to UI/Web/src/assets/fonts/Nanum Gothic/NanumGothic-Regular.ttf diff --git a/UI/Web/src/assets/fonts/Nanum_Gothic/NanumGothic-Regular.woff2 b/UI/Web/src/assets/fonts/Nanum Gothic/NanumGothic-Regular.woff2 similarity index 100% rename from UI/Web/src/assets/fonts/Nanum_Gothic/NanumGothic-Regular.woff2 rename to UI/Web/src/assets/fonts/Nanum Gothic/NanumGothic-Regular.woff2 diff --git a/UI/Web/src/assets/fonts/Nanum_Gothic/OFL.txt b/UI/Web/src/assets/fonts/Nanum Gothic/OFL.txt similarity index 100% rename from UI/Web/src/assets/fonts/Nanum_Gothic/OFL.txt rename to UI/Web/src/assets/fonts/Nanum Gothic/OFL.txt diff --git a/UI/Web/src/assets/fonts/OpenDyslexic2/OpenDyslexic-Bold.otf b/UI/Web/src/assets/fonts/Open Dyslexic/OpenDyslexic-Bold.otf similarity index 100% rename from UI/Web/src/assets/fonts/OpenDyslexic2/OpenDyslexic-Bold.otf rename to UI/Web/src/assets/fonts/Open Dyslexic/OpenDyslexic-Bold.otf diff --git a/UI/Web/src/assets/fonts/OpenDyslexic2/OpenDyslexic-Bold.woff2 b/UI/Web/src/assets/fonts/Open Dyslexic/OpenDyslexic-Bold.woff2 similarity index 100% rename from UI/Web/src/assets/fonts/OpenDyslexic2/OpenDyslexic-Bold.woff2 rename to UI/Web/src/assets/fonts/Open Dyslexic/OpenDyslexic-Bold.woff2 diff --git a/UI/Web/src/assets/fonts/OpenDyslexic2/OpenDyslexic-BoldItalic.otf b/UI/Web/src/assets/fonts/Open Dyslexic/OpenDyslexic-BoldItalic.otf similarity index 100% rename from UI/Web/src/assets/fonts/OpenDyslexic2/OpenDyslexic-BoldItalic.otf rename to UI/Web/src/assets/fonts/Open Dyslexic/OpenDyslexic-BoldItalic.otf diff --git a/UI/Web/src/assets/fonts/OpenDyslexic2/OpenDyslexic-BoldItalic.woff2 b/UI/Web/src/assets/fonts/Open Dyslexic/OpenDyslexic-BoldItalic.woff2 similarity index 100% rename from UI/Web/src/assets/fonts/OpenDyslexic2/OpenDyslexic-BoldItalic.woff2 rename to UI/Web/src/assets/fonts/Open Dyslexic/OpenDyslexic-BoldItalic.woff2 diff --git a/UI/Web/src/assets/fonts/OpenDyslexic2/OpenDyslexic-Italic.otf b/UI/Web/src/assets/fonts/Open Dyslexic/OpenDyslexic-Italic.otf similarity index 100% rename from UI/Web/src/assets/fonts/OpenDyslexic2/OpenDyslexic-Italic.otf rename to UI/Web/src/assets/fonts/Open Dyslexic/OpenDyslexic-Italic.otf diff --git a/UI/Web/src/assets/fonts/OpenDyslexic2/OpenDyslexic-Italic.woff2 b/UI/Web/src/assets/fonts/Open Dyslexic/OpenDyslexic-Italic.woff2 similarity index 100% rename from UI/Web/src/assets/fonts/OpenDyslexic2/OpenDyslexic-Italic.woff2 rename to UI/Web/src/assets/fonts/Open Dyslexic/OpenDyslexic-Italic.woff2 diff --git a/UI/Web/src/assets/fonts/OpenDyslexic2/OpenDyslexic-Regular.otf b/UI/Web/src/assets/fonts/Open Dyslexic/OpenDyslexic-Regular.otf similarity index 100% rename from UI/Web/src/assets/fonts/OpenDyslexic2/OpenDyslexic-Regular.otf rename to UI/Web/src/assets/fonts/Open Dyslexic/OpenDyslexic-Regular.otf diff --git a/UI/Web/src/assets/fonts/OpenDyslexic2/OpenDyslexic-Regular.woff2 b/UI/Web/src/assets/fonts/Open Dyslexic/OpenDyslexic-Regular.woff2 similarity index 100% rename from UI/Web/src/assets/fonts/OpenDyslexic2/OpenDyslexic-Regular.woff2 rename to UI/Web/src/assets/fonts/Open Dyslexic/OpenDyslexic-Regular.woff2 diff --git a/UI/Web/src/assets/fonts/RocknRoll_One/OFL.txt b/UI/Web/src/assets/fonts/RocknRoll One/OFL.txt similarity index 100% rename from UI/Web/src/assets/fonts/RocknRoll_One/OFL.txt rename to UI/Web/src/assets/fonts/RocknRoll One/OFL.txt diff --git a/UI/Web/src/assets/fonts/RocknRoll_One/RocknRollOne-Regular.ttf b/UI/Web/src/assets/fonts/RocknRoll One/RocknRollOne-Regular.ttf similarity index 100% rename from UI/Web/src/assets/fonts/RocknRoll_One/RocknRollOne-Regular.ttf rename to UI/Web/src/assets/fonts/RocknRoll One/RocknRollOne-Regular.ttf diff --git a/UI/Web/src/assets/fonts/RocknRoll_One/RocknRollOne-Regular.woff2 b/UI/Web/src/assets/fonts/RocknRoll One/RocknRollOne-Regular.woff2 similarity index 100% rename from UI/Web/src/assets/fonts/RocknRoll_One/RocknRollOne-Regular.woff2 rename to UI/Web/src/assets/fonts/RocknRoll One/RocknRollOne-Regular.woff2 diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index d32bafd69..fce3ac2f9 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -184,6 +184,7 @@ "stats-tab": "{{tabs.stats-tab}}", "scrobbling-tab": "{{tabs.scrobbling-tab}}", "smart-filters-tab": "{{tabs.smart-filters-tab}}", + "font-tab": "Font", "success-toast": "User preferences updated", @@ -259,6 +260,24 @@ "add": "{{common.add}}" }, + "font-manager": { + "description": "Kavita's epub reader comes with a few build-in fonts, add your own fonts here. Upload a file, or use a google fonts url. See the wiki for more information.", + "enter-an-url-pre-title": "Enter an {{url}}", + "url": "url", + "add": "{{common.add}}", + "drag-n-drop": "{{cover-image-chooser.drag-n-drop}}", + "upload": "{{cover-image-chooser.upload}}", + "upload-continued": "a font file", + "preview-default": "Upload your own font via file or Google Font url", + "delete": "{{common.delete}}", + "url-label": "Url", + "back": "Back", + "load": "Load", + "no-preview": "This font cannot be previewed. This will take the default style from the book.", + "brown-fox": "The quick brown fox jumps over the lazy dog", + "filter-system-fonts-label": "Filter System Fonts" + }, + "theme": { "theme-dark": "Dark", "theme-black": "Black", @@ -1898,6 +1917,7 @@ "user-stats": "Stats", "scrobbling": "Scrobbling", "theme": "Theme", + "font": "Epub Fonts", "customize": "Customize", "cbl-import": "CBL Reading List", "mal-stack-import": "MAL Stack", @@ -2641,6 +2661,9 @@ "delete-theme-in-use": "Theme is currently in use by at least one user, cannot delete", "theme-manual-upload": "There was an issue creating Theme from manual upload", "theme-already-in-use": "Theme already exists by that name", + "delete-font-in-use": "Font is currently in use by at least one user, cannot delete", + "font-manual-upload": "There was an issue creating Font from manual upload", + "font-already-in-use": "Font already exists by that name", "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" @@ -2971,7 +2994,7 @@ "volume-deleted": "Volume deleted", "pdf-book-mode-screen-size": "Screen too small for Book mode", "stack-imported": "Stack Imported", - "confirm-delete-theme": "Removing this theme will delete it from the disk. You can grab it from temp directory before removal", + "confirm-delete-theme": "Removing this theme will delete it from the disk. You can grab it from temp directory before removal.", "mal-token-required": "MAL Token is required, set in User Settings", "confirm-reset-server-settings": "This will reset your settings to first install values. Are you sure you want to continue?", "must-select-library": "At least one library must be selected", @@ -2986,7 +3009,10 @@ "library-bound-to-reading-profile": "Library bound to Reading Profile {{name}}", "external-match-rate-error": "Kavita ran out of rate looking up {{seriesName}}. Try again in 5 minutes.", "confirm-delete-bookmark": "Are you sure you want to delete this Bookmark?", - "confirm-delete-annotation": "Are you sure you want to delete this Annotation?" + "confirm-delete-annotation": "Are you sure you want to delete this Annotation?", + "confirm-delete-font": "Removing this font will delete it from the disk. You can grab it from temp directory before removal.", + "confirm-force-delete-font": "This font is currently in use. Do you want to force delete it? This will force users back to the Default font.", + "font-in-use": "Cannot delete as the font is in use by one or more users." }, "read-time-pipe": { diff --git a/UI/Web/src/styles.scss b/UI/Web/src/styles.scss index bd2b91f28..41c17a052 100644 --- a/UI/Web/src/styles.scss +++ b/UI/Web/src/styles.scss @@ -63,7 +63,7 @@ // Global Styles @font-face { font-family: "EBGarmond"; - src: url("assets/fonts/EBGarmond/EBGaramond-VariableFont_wght.woff2") format("woff2"); + src: url("assets/fonts/EB Garamond/EBGaramond-VariableFont_wght.woff2") format("woff2"); font-display: swap; } diff --git a/openapi.json b/openapi.json index 4565870f3..4ce0de290 100644 --- a/openapi.json +++ b/openapi.json @@ -29371,4 +29371,4 @@ "description": "Responsible for all things Want To Read" } ] -} \ No newline at end of file +}