diff --git a/.gitignore b/.gitignore index 612917a47..dbe37af31 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 3cd3513b8..b27c391c0 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -191,6 +191,7 @@ + Always diff --git a/API/Controllers/FontController.cs b/API/Controllers/FontController.cs new file mode 100644 index 000000000..364122ac6 --- /dev/null +++ b/API/Controllers/FontController.cs @@ -0,0 +1,138 @@ +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 + /// + /// + /// + /// + [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 + /// + [HttpDelete] + public async Task DeleteFont(int fontId, bool confirmed = false) + { + // TODO: We need to check if this font is used by anyone else and if so, need to inform the user + // Need to check if this is a system font as well + var forceDelete = User.IsInRole(PolicyConstants.AdminRole) && confirmed; + await _fontService.Delete(fontId); + return Ok(); + } + + /// + /// Manual upload + /// + /// + /// + [HttpPost("upload")] + public async Task> UploadFont(IFormFile formFile) + { + 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) + { + // Validate url + try + { + await _fontService.CreateFontFromUrl(url); + } + catch (KavitaException ex) + { + return BadRequest(_localizationService.Translate(User.GetUserId(), ex.Message)); + } + + return Ok(); + } + + 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 fb9371919..cdffa43ca 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..8eb2712a4 --- /dev/null +++ b/API/DTOs/Font/EpubFontDto.cs @@ -0,0 +1,13 @@ +using System; +using API.Entities.Enums.Font; + +namespace API.DTOs.Font; + +public class 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 4af165249..96252d0bd 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -66,6 +66,7 @@ public sealed class DataContext : IdentityDbContext ManualMigrationHistory { get; set; } = null!; public DbSet SeriesBlacklist { get; set; } = null!; public DbSet AppUserCollection { get; set; } = null!; + public DbSet EpubFont { get; set; } = null!; protected override void OnModelCreating(ModelBuilder builder) diff --git a/API/Data/Migrations/20240621211843_EpubFontInitial.Designer.cs b/API/Data/Migrations/20240621211843_EpubFontInitial.Designer.cs new file mode 100644 index 000000000..986801433 --- /dev/null +++ b/API/Data/Migrations/20240621211843_EpubFontInitial.Designer.cs @@ -0,0 +1,3078 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20240621211843_EpubFontInitial")] + partial class EpubFontInitial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.6"); + + 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("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("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + 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.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + 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("Promoted") + .HasColumnType("INTEGER"); + + 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("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("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.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("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("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("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + 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("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .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("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("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.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.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + 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("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("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("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.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.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("AverageScore") + .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.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("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + 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.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("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("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.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("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("Promoted") + .HasColumnType("INTEGER"); + + 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("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .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("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("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + 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("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("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("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("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.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.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.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.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.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("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("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("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + + 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.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/20240621211843_EpubFontInitial.cs b/API/Data/Migrations/20240621211843_EpubFontInitial.cs new file mode 100644 index 000000000..cd073fd35 --- /dev/null +++ b/API/Data/Migrations/20240621211843_EpubFontInitial.cs @@ -0,0 +1,42 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class EpubFontInitial : 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 c59b774d6..ba7e61ab5 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -15,7 +15,7 @@ namespace API.Data.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "8.0.4"); + modelBuilder.HasAnnotation("ProductVersion", "8.0.6"); modelBuilder.Entity("API.Entities.AppRole", b => { @@ -907,6 +907,41 @@ namespace API.Data.Migrations b.ToTable("Device"); }); + 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..8a396d6dd --- /dev/null +++ b/API/Data/Repositories/EpubFontRepository.cs @@ -0,0 +1,101 @@ +#nullable enable +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs.Font; +using API.Entities; +using API.Extensions; +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 == "Default" ? -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.AppUserPreferences + .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 07723bf1b..70b4ef8f4 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -76,6 +76,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); @@ -260,6 +261,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 ddc682c32..8ddd960e6 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -9,9 +9,11 @@ using API.Constants; using API.Data.Repositories; using API.Entities; using API.Entities.Enums; +using API.Entities.Enums.Font; using API.Entities.Enums.Theme; using API.Extensions; using API.Services; +using API.Services.Tasks.Scanner.Parser; using Kavita.Common; using Kavita.Common.EnvironmentInfo; using Microsoft.AspNetCore.Identity; @@ -26,6 +28,97 @@ public static class Seed /// public static ImmutableArray DefaultSettings; + public static readonly ImmutableArray DefaultFonts = + [ + ..new List + { + new () + { + Name = "Default", + NormalizedName = Parser.Normalize("Default"), + 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 = "Libre Caslon", + NormalizedName = Parser.Normalize("Libre Caslon"), + Provider = FontProvider.System, + FileName = "LibreCaslonText-Regular.woff2", + }, + new () + { + Name = "Nanum Gothic", + NormalizedName = Parser.Normalize("Nanum Gothic"), + Provider = FontProvider.System, + FileName = "NanumGothic-Regular.woff2", + }, + new () + { + Name = "Open Dyslexic 2", + NormalizedName = Parser.Normalize("Open Dyslexic 2"), + Provider = FontProvider.System, + FileName = "OpenDyslexic-Regular.woff2", + }, + new () + { + Name = "Oswald", + NormalizedName = Parser.Normalize("Oswald"), + Provider = FontProvider.System, + FileName = "Oswald-VariableFont_wght.woff2", + }, + new () + { + Name = "RocknRoll One", + NormalizedName = Parser.Normalize("RocknRoll One"), + Provider = FontProvider.System, + FileName = "RocknRollOne-Regular.woff2", + }, + new () + { + Name = "Spartan", + NormalizedName = Parser.Normalize("Spartan"), + Provider = FontProvider.System, + FileName = "Spartan-VariableFont_wght.woff2", + }, + } + ]; + public static readonly ImmutableArray DefaultThemes = [ ..new List { @@ -143,7 +236,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); @@ -153,6 +246,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); @@ -259,7 +368,7 @@ public static class Seed foreach (var defaultSetting in DefaultSettings) { - var existing = context.ServerSetting.FirstOrDefault(s => s.Key == defaultSetting.Key); + var existing = await context.ServerSetting.FirstOrDefaultAsync(s => s.Key == defaultSetting.Key); if (existing == null) { await context.ServerSetting.AddAsync(defaultSetting); @@ -269,15 +378,15 @@ public static class Seed await context.SaveChangesAsync(); // Port, IpAddresses and LoggingLevel are managed in appSettings.json. Update the DB values to match - context.ServerSetting.First(s => s.Key == ServerSettingKey.Port).Value = + (await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.Port)).Value = Configuration.Port + string.Empty; - context.ServerSetting.First(s => s.Key == ServerSettingKey.IpAddresses).Value = + (await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.IpAddresses)).Value = Configuration.IpAddresses; - context.ServerSetting.First(s => s.Key == ServerSettingKey.CacheDirectory).Value = + (await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.CacheDirectory)).Value = directoryService.CacheDirectory + string.Empty; - context.ServerSetting.First(s => s.Key == ServerSettingKey.BackupDirectory).Value = + (await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.BackupDirectory)).Value = DirectoryService.BackupDirectory + string.Empty; - context.ServerSetting.First(s => s.Key == ServerSettingKey.CacheSize).Value = + (await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.CacheSize)).Value = Configuration.CacheSize + string.Empty; await context.SaveChangesAsync(); diff --git a/API/Data/UnitOfWork.cs b/API/Data/UnitOfWork.cs index 97ef3e07b..645d258f0 100644 --- a/API/Data/UnitOfWork.cs +++ b/API/Data/UnitOfWork.cs @@ -31,6 +31,7 @@ public interface IUnitOfWork IAppUserSmartFilterRepository AppUserSmartFilterRepository { get; } IAppUserExternalSourceRepository AppUserExternalSourceRepository { get; } IExternalSeriesMetadataRepository ExternalSeriesMetadataRepository { get; } + IEpubFontRepository EpubFontRepository { get; } bool Commit(); Task CommitAsync(); bool HasChanges(); @@ -74,6 +75,7 @@ public class UnitOfWork : IUnitOfWork public IAppUserSmartFilterRepository AppUserSmartFilterRepository => new AppUserSmartFilterRepository(_context, _mapper); public IAppUserExternalSourceRepository AppUserExternalSourceRepository => new AppUserExternalSourceRepository(_context, _mapper); public IExternalSeriesMetadataRepository ExternalSeriesMetadataRepository => new ExternalSeriesMetadataRepository(_context, _mapper); + public IEpubFontRepository EpubFontRepository => new EpubFontRepository(_context, _mapper); /// /// Commits changes to the DB. Completes the open transaction. 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 f68b4461d..28f9cbbad 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -53,6 +53,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 06a1a4b2e..0358494f6 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -10,6 +10,7 @@ using API.DTOs.Dashboard; using API.DTOs.Device; using API.DTOs.Filtering; using API.DTOs.Filtering.v2; +using API.DTOs.Font; using API.DTOs.MediaErrors; using API.DTOs.Metadata; using API.DTOs.Progress; @@ -262,6 +263,8 @@ public class AutoMapperProfiles : Profile opt => opt.MapFrom(src => src.BookReaderLayoutMode)); + CreateMap(); + CreateMap(); diff --git a/API/I18N/en.json b/API/I18N/en.json index e038a9ea8..c7f1e8627 100644 --- a/API/I18N/en.json +++ b/API/I18N/en.json @@ -218,6 +218,9 @@ "scan-libraries": "Scan Libraries", "kavita+-data-refresh": "Kavita+ Data Refresh", "backup": "Backup", - "update-yearly-stats": "Update Yearly Stats" + "update-yearly-stats": "Update Yearly Stats", + + + "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 925214920..59c0a6709 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -126,6 +126,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/DirectoryService.cs b/API/Services/DirectoryService.cs index 8c6c796c9..184f9d3bb 100644 --- a/API/Services/DirectoryService.cs +++ b/API/Services/DirectoryService.cs @@ -33,6 +33,7 @@ public interface IDirectoryService /// Original BookmarkDirectory. Only used for resetting directory. Use for actual path. /// string BookmarkDirectory { get; } + string EpubFontDirectory { get; } /// /// Lists out top-level folders for a given directory. Filters out System and Hidden folders. /// @@ -88,6 +89,8 @@ public class DirectoryService : IDirectoryService public string LocalizationDirectory { get; } public string CustomizedTemplateDirectory { get; } public string TemplateDirectory { get; } + public string EpubFontDirectory { get; } + private readonly ILogger _logger; private const RegexOptions MatchOptions = RegexOptions.Compiled | RegexOptions.IgnoreCase; @@ -125,6 +128,8 @@ public class DirectoryService : IDirectoryService ExistOrCreate(CustomizedTemplateDirectory); TemplateDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "EmailTemplates"); ExistOrCreate(TemplateDirectory); + EpubFontDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "fonts"); + ExistOrCreate(EpubFontDirectory); } /// diff --git a/API/Services/Tasks/FontService.cs b/API/Services/Tasks/FontService.cs new file mode 100644 index 000000000..37e6d3130 --- /dev/null +++ b/API/Services/Tasks/FontService.cs @@ -0,0 +1,133 @@ +using System; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using API.Data; +using API.Entities; +using API.Entities.Enums.Font; +using API.Services.Tasks.Scanner.Parser; +using API.SignalR; +using Kavita.Common; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; + +namespace API.Services.Tasks; + +public interface IFontService +{ + Task CreateFontFromFileAsync(string path); + Task Delete(int fontId); + Task CreateFontFromUrl(string url); +} + +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/specimen/"; + + 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; + } + + public async Task Delete(int fontId) + { + if (await _unitOfWork.EpubFontRepository.IsFontInUseAsync(fontId)) + { + throw new KavitaException("errors.delete-font-in-use"); + } + + var font = await _unitOfWork.EpubFontRepository.GetFontAsync(fontId); + if (font == null) return; + + await RemoveFont(font); + } + + public 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]; + _logger.LogInformation("Preparing to download {FontName} font", fontFamily); + + // TODO: Send a font update event + return Task.CompletedTask; + } + + 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 theme file to temp for nightly removal (to give user time to reclaim if made a mistake) + var existingLocation = + _directoryService.FileSystem.Path.Join(_directoryService.SiteThemeDirectory, 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(); + } +} diff --git a/API/Services/Tasks/Scanner/Parser/Parser.cs b/API/Services/Tasks/Scanner/Parser/Parser.cs index 840e7a6d8..f581c8de5 100644 --- a/API/Services/Tasks/Scanner/Parser/Parser.cs +++ b/API/Services/Tasks/Scanner/Parser/Parser.cs @@ -32,6 +32,7 @@ public static class Parser private const string BookFileExtensions = EpubFileExtension + "|" + PdfFileExtension; private const string XmlRegexExtensions = @"\.xml"; public const string MacOsMetadataFileStartsWith = @"._"; + public const string FontFileExtensions = @"\.[woff2|tff|otf|woff]"; public const string SupportedExtensions = ArchiveFileExtensions + "|" + ImageFileExtensions + "|" + BookFileExtensions; @@ -1259,4 +1260,12 @@ public static 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]", " "); + } } 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/_services/font.service.ts b/UI/Web/src/app/_services/font.service.ts new file mode 100644 index 000000000..96e54bf78 --- /dev/null +++ b/UI/Web/src/app/_services/font.service.ts @@ -0,0 +1,66 @@ +import {DestroyRef, inject, Injectable} from "@angular/core"; +import {map, ReplaySubject} from "rxjs"; +import {EpubFont, FontProvider} from "../_models/preferences/epub-font"; +import {environment} from 'src/environments/environment'; +import {HttpClient} from "@angular/common/http"; +import {MessageHubService} from "./message-hub.service"; +import {NgxFileDropEntry} from "ngx-file-drop"; +import {AccountService} from "./account.service"; +import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; + +@Injectable({ + providedIn: 'root' +}) +export class FontService { + private readonly destroyRef = inject(DestroyRef); + public defaultEpubFont: string = 'default'; + + private fontsSource = new ReplaySubject(1); + public fonts$ = this.fontsSource.asObservable(); + + baseUrl: string = environment.apiUrl; + apiKey: string = ''; + encodedKey: string = ''; + + constructor(private httpClient: HttpClient, messageHub: MessageHubService, private accountService: AccountService) { + this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => { + if (user) { + this.apiKey = user.apiKey; + this.encodedKey = encodeURIComponent(this.apiKey); + } + }); + } + + getFonts() { + return this.httpClient.get>(this.baseUrl + 'font/all').pipe(map(fonts => { + this.fontsSource.next(fonts); + return fonts; + })); + } + + + + 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) { + return this.httpClient.delete(this.baseUrl + `font?fontId=${id}`); + } + +} 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 8d9a36f78..77cd193bb 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,45 +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; -} - :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 7a672693b..a6680a1db 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 @@ -52,6 +52,7 @@ import { PersonalToCEvent } from "../personal-table-of-contents/personal-table-of-contents.component"; import {translate, TranslocoDirective} from "@ngneat/transloco"; +import {FontService} from "../../../_services/font.service"; enum TabID { @@ -124,6 +125,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { private readonly libraryService = inject(LibraryService); private readonly themeService = inject(ThemeService); private readonly cdRef = inject(ChangeDetectorRef); + private readonly fontService = inject(FontService); protected readonly BookPageLayoutMode = BookPageLayoutMode; protected readonly WritingStyle = WritingStyle; @@ -583,6 +585,15 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } ngOnInit(): void { + this.fontService.getFonts().subscribe(fonts => { + fonts.forEach(font => { + this.fontService.getFontFace(font).load().then(loadedFace => { + console.log('loaded font: ', 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 5e2602807..8040678c3 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,7 +16,9 @@
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 b4ea74806..e9fb03a49 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 @@ -19,7 +19,7 @@ import { ThemeProvider } from 'src/app/_models/preferences/site-theme'; import { User } from 'src/app/_models/user'; import { AccountService } from 'src/app/_services/account.service'; import { ThemeService } from 'src/app/_services/theme.service'; -import { FontFamily, BookService } from '../../_services/book.service'; +import {BookService} 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'; @@ -27,6 +27,8 @@ import { BookPaperTheme } from '../../_models/book-paper-theme'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import { NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader, NgbAccordionToggle, NgbAccordionButton, NgbCollapse, NgbAccordionCollapse, NgbAccordionBody, NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; import {TranslocoDirective} from "@ngneat/transloco"; +import {FontService} from "../../../_services/font.service"; +import {EpubFont} from "../../../_models/preferences/epub-font"; /** * Used for book reader. Do not use for other components @@ -83,6 +85,7 @@ export const bookColorThemes = [ ]; const mobileBreakpointMarginOverride = 700; +const defaultFontFamily = 'Default'; @Component({ selector: 'app-reader-settings', @@ -130,8 +133,7 @@ export class ReaderSettingsComponent implements OnInit { /** * List of all font families user can select from */ - fontOptions: Array = []; - fontFamilies: Array = []; + fontFamilies: Array = []; /** * Internal property used to capture all the different css properties to render on all elements */ @@ -171,20 +173,20 @@ export class ReaderSettingsComponent implements OnInit { constructor(private bookService: BookService, private accountService: AccountService, @Inject(DOCUMENT) private document: Document, private themeService: ThemeService, - private readonly cdRef: ChangeDetectorRef) {} + private readonly cdRef: ChangeDetectorRef, private fontService: FontService) {} ngOnInit(): void { - - this.fontFamilies = this.bookService.getFontFamilies(); - this.fontOptions = this.fontFamilies.map(f => f.title); - this.cdRef.markForCheck(); + this.fontService.getFonts().subscribe(fonts => { + this.fontFamilies = fonts; + this.cdRef.markForCheck(); + }) this.accountService.currentUser$.pipe(take(1)).subscribe(user => { if (user) { this.user = user; if (this.user.preferences.bookReaderFontFamily === undefined) { - this.user.preferences.bookReaderFontFamily = 'default'; + this.user.preferences.bookReaderFontFamily = defaultFontFamily; } if (this.user.preferences.bookReaderFontSize === undefined || this.user.preferences.bookReaderFontSize < 50) { this.user.preferences.bookReaderFontSize = 100; @@ -208,11 +210,11 @@ export class ReaderSettingsComponent implements OnInit { this.settingsForm.addControl('bookReaderFontFamily', new FormControl(this.user.preferences.bookReaderFontFamily, [])); this.settingsForm.get('bookReaderFontFamily')!.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(fontName => { - const familyName = this.fontFamilies.filter(f => f.title === fontName)[0].family; - if (familyName === 'default') { + console.log('updating font-family to ', fontName); + if (fontName === defaultFontFamily) { this.pageStyles['font-family'] = 'inherit'; } else { - this.pageStyles['font-family'] = "'" + familyName + "'"; + this.pageStyles['font-family'] = "'" + fontName + "'"; } this.styleUpdate.emit(this.pageStyles); 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 65549ab48..18e96048d 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,12 +14,6 @@ export class BookService { constructor(private http: HttpClient) { } - 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: 'RocknRoll One', family: 'RocknRoll_One'}, {title: 'Open Dyslexic', family: 'OpenDyslexic2'}]; - } - getBookChapters(chapterId: number) { return this.http.get>(this.baseUrl + 'book/' + chapterId + '/chapters'); } 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..b3b3df3cc --- /dev/null +++ b/UI/Web/src/app/user-settings/font-manager/font-manager/font-manager.component.html @@ -0,0 +1,144 @@ + +
+
+

{{t('title')}}

+
+ +

{{t('description')}}

+ + +
+
+
+
    + + @for (font of fonts; track font.name) { + + } +
+
+
+ +
+
+ + @if (selectedFont === undefined) { + +
+
+
+
+ {{t('preview-default')}} +
+
+
+
+ + + @if (files && files.length > 0) { + + } @else { + + + + + @switch (mode) { + @case ('all') { + + } + @case ('url') { +
+
+
+ + + +
+ +
+
+ } + } +
+ +
+ } + + } @else if (selectedFont) { +

+ {{selectedFont.name | sentenceCase}} +
+ @if (selectedFont.provider !== FontProvider.System && selectedFont.name !== 'Default') { + + } +
+

+ +
+ +
+ } +
+
+
+
+ + + @if (item !== undefined) { +
  • +
    +
    {{item.name | sentenceCase}}
    +
    +
    {{item.provider | siteThemeProvider}}
    +
  • + } +
    + + + @if (item) { +
    + @if (item.name === 'Default') { +
    + This font cannot be previewed. This will take the default style from the book. +
    + } + +
    + @if (item.hasOwnProperty('provider') && item.provider === FontProvider.User && item.hasOwnProperty('id')) { + + } +
    +
    + +
    + The quick brown fox jumps over the lazy dog +
    + } +
    +
    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..10cde3389 --- /dev/null +++ b/UI/Web/src/app/user-settings/font-manager/font-manager/font-manager.component.scss @@ -0,0 +1,26 @@ +.pill { + font-size: .8rem; + background-color: var(--card-bg-color); + border-radius: 0.375rem; +} + +.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..b5ee62fe6 --- /dev/null +++ b/UI/Web/src/app/user-settings/font-manager/font-manager/font-manager.component.ts @@ -0,0 +1,142 @@ +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, Inject, inject, OnInit} from '@angular/core'; +import {translate, TranslocoDirective} from "@ngneat/transloco"; +import {FontService} from "src/app/_services/font.service"; +import {AccountService} from "../../../_services/account.service"; +import {ToastrService} from "ngx-toastr"; +import {ConfirmService} from "../../../shared/confirm.service"; +import {EpubFont, FontProvider} from 'src/app/_models/preferences/epub-font'; +import {User} from "../../../_models/user"; +import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {shareReplay} from "rxjs/operators"; +import {map} from "rxjs"; +import {NgxFileDropEntry, NgxFileDropModule} from "ngx-file-drop"; +import {AsyncPipe, DOCUMENT, NgIf, NgStyle, NgTemplateOutlet} from "@angular/common"; +import {LoadingComponent} from "../../../shared/loading/loading.component"; +import {FormBuilder, FormControl, FormGroup, FormsModule, ReactiveFormsModule} from "@angular/forms"; +import {SentenceCasePipe} from "../../../_pipes/sentence-case.pipe"; +import {SiteThemeProviderPipe} from "../../../_pipes/site-theme-provider.pipe"; +import {CarouselReelComponent} from "../../../carousel/_components/carousel-reel/carousel-reel.component"; +import {DefaultValuePipe} from "../../../_pipes/default-value.pipe"; +import {ImageComponent} from "../../../shared/image/image.component"; +import {SafeUrlPipe} from "../../../_pipes/safe-url.pipe"; + +@Component({ + selector: 'app-font-manager', + imports: [ + TranslocoDirective, + AsyncPipe, + LoadingComponent, + NgxFileDropModule, + FormsModule, + NgIf, + ReactiveFormsModule, + SentenceCasePipe, + SiteThemeProviderPipe, + NgTemplateOutlet, + NgStyle, + CarouselReelComponent, + DefaultValuePipe, + ImageComponent, + SafeUrlPipe + ], + templateUrl: './font-manager.component.html', + styleUrl: './font-manager.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, +}) +export class FontManagerComponent implements OnInit { + private readonly destroyRef = inject(DestroyRef); + protected readonly fontService = inject(FontService); + private readonly accountService = inject(AccountService); + public readonly fb = inject(FormBuilder); + private readonly toastr = inject(ToastrService); + private readonly cdRef = inject(ChangeDetectorRef); + private readonly confirmService = inject(ConfirmService); + + protected readonly FontProvider = FontProvider; + + user: User | undefined; + fonts: Array = []; + hasAdmin$ = this.accountService.currentUser$.pipe( + takeUntilDestroyed(this.destroyRef), shareReplay({refCount: true, bufferSize: 1}), + map(c => c && this.accountService.hasAdminRole(c)) + ); + + form: FormGroup = new FormGroup({ + fontUrl: new FormControl('', []) + }); + + selectedFont: EpubFont | undefined = undefined; + + files: NgxFileDropEntry[] = []; + acceptableExtensions = ['.woff2', 'woff', 'tff', 'otf'].join(','); + mode: 'file' | 'url' | 'all' = 'all'; + isUploadingFont: boolean = false; + + + constructor(@Inject(DOCUMENT) private document: Document) {} + + ngOnInit() { + this.loadFonts(); + } + + loadFonts() { + this.fontService.getFonts().subscribe(fonts => { + this.fonts = fonts; + this.cdRef.markForCheck(); + }); + } + + selectFont(font: EpubFont) { + this.fontService.getFontFace(font).load().then(loadedFace => { + (this.document as any).fonts.add(loadedFace); + }); + this.selectedFont = font; + this.cdRef.markForCheck(); + } + + 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.isUploadingFont = false; + this.fonts = [...this.fonts, f]; + this.cdRef.markForCheck(); + }); + }); + } + this.isUploadingFont = true; + this.cdRef.markForCheck(); + } + + uploadFromUrl() { + const url = this.form.get('fontUrl')?.value.trim(); + if (!url || url === '') return; + + this.fontService.uploadFromUrl(url).subscribe(() => { + this.loadFonts(); + }); + } + + async deleteFont(id: number) { + if (!await this.confirmService.confirm(translate('toasts.confirm-delete-font'))) { + return; + } + + this.fontService.deleteFont(id).subscribe(() => { + this.fonts = this.fonts.filter(f => f.id !== id); + this.cdRef.markForCheck(); + }); + } + + changeMode(mode: 'file' | 'url' | 'all') { + this.mode = mode; + this.cdRef.markForCheck(); + } + +} diff --git a/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.html b/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.html index 7563ee311..398311ab6 100644 --- a/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.html +++ b/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.html @@ -71,8 +71,7 @@ } - } - @else { + } @else {

    {{selectedTheme.name | sentenceCase}}
    diff --git a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html index 4ac8b10b2..6da2b5b9c 100644 --- a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html +++ b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html @@ -619,6 +619,10 @@ } + @defer (when tab.fragment === FragmentID.Font; prefetch on idle) { + + } + @defer (when tab.fragment === FragmentID.Devices; prefetch on idle) { } diff --git a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts index c50814872..90c4bb4f8 100644 --- a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts +++ b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts @@ -33,7 +33,6 @@ import {SettingsService} from 'src/app/admin/settings.service'; import {BookPageLayoutMode} from 'src/app/_models/readers/book-page-layout-mode'; import {forkJoin} from 'rxjs'; import {bookColorThemes} from 'src/app/book-reader/_components/reader-settings/reader-settings.component'; -import {BookService} from 'src/app/book-reader/_services/book.service'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {SentenceCasePipe} from '../../_pipes/sentence-case.pipe'; import {UserHoldsComponent} from '../user-holds/user-holds.component'; @@ -75,8 +74,9 @@ import {ManageScrobblingProvidersComponent} from "../manage-scrobbling-providers import {PdfLayoutModePipe} from "../../pdf-reader/_pipe/pdf-layout-mode.pipe"; import {PdfTheme} from "../../_models/preferences/pdf-theme"; import {PdfScrollMode} from "../../_models/preferences/pdf-scroll-mode"; -import {PdfLayoutMode} from "../../_models/preferences/pdf-layout-mode"; import {PdfSpreadMode} from "../../_models/preferences/pdf-spread-mode"; +import {FontManagerComponent} from "../font-manager/font-manager/font-manager.component"; +import {FontService} from "../../_services/font.service"; enum AccordionPanelID { ImageReader = 'image-reader', @@ -90,6 +90,7 @@ enum FragmentID { Preferences = '', Clients = 'clients', Theme = 'theme', + Font = 'font', Devices = 'devices', Stats = 'stats', Scrobbling = 'scrobbling' @@ -105,14 +106,14 @@ enum FragmentID { ChangePasswordComponent, ChangeAgeRestrictionComponent, ReactiveFormsModule, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader, NgbAccordionToggle, NgbAccordionButton, NgbCollapse, NgbAccordionCollapse, NgbAccordionBody, NgbTooltip, NgTemplateOutlet, ColorPickerModule, ApiKeyComponent, ThemeManagerComponent, ManageDevicesComponent, UserStatsComponent, UserScrobbleHistoryComponent, UserHoldsComponent, NgbNavOutlet, TitleCasePipe, SentenceCasePipe, - TranslocoDirective, LoadingComponent, ManageScrobblingProvidersComponent, PdfLayoutModePipe], + TranslocoDirective, LoadingComponent, ManageScrobblingProvidersComponent, PdfLayoutModePipe, FontManagerComponent], }) export class UserPreferencesComponent implements OnInit, OnDestroy { private readonly destroyRef = inject(DestroyRef); private readonly accountService = inject(AccountService); private readonly toastr = inject(ToastrService); - private readonly bookService = inject(BookService); + private readonly fontService = inject(FontService); private readonly titleService = inject(Title); private readonly route = inject(ActivatedRoute); private readonly settingsService = inject(SettingsService); @@ -153,6 +154,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy { {title: 'preferences-tab', fragment: FragmentID.Preferences}, {title: '3rd-party-clients-tab', fragment: FragmentID.Clients}, {title: 'theme-tab', fragment: FragmentID.Theme}, + {title: 'font-tab', fragment: FragmentID.Font}, {title: 'devices-tab', fragment: FragmentID.Devices}, {title: 'stats-tab', fragment: FragmentID.Stats}, ]; @@ -166,7 +168,6 @@ export class UserPreferencesComponent implements OnInit, OnDestroy { constructor() { - this.fontFamilies = this.bookService.getFontFamilies().map(f => f.title); this.cdRef.markForCheck(); this.accountService.getOpdsUrl().subscribe(res => { @@ -174,6 +175,11 @@ export class UserPreferencesComponent implements OnInit, OnDestroy { this.cdRef.markForCheck(); }); + this.fontService.getFonts().subscribe(res => { + this.fontFamilies = res.map(f => f.name); + this.cdRef.markForCheck(); + }) + this.settingsService.getOpdsEnabled().subscribe(res => { this.opdsEnabled = res; this.cdRef.markForCheck(); @@ -227,7 +233,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy { this.user.preferences = results.pref; if (this.fontFamilies.indexOf(this.user.preferences.bookReaderFontFamily) < 0) { - this.user.preferences.bookReaderFontFamily = 'default'; + this.user.preferences.bookReaderFontFamily = 'Default'; } this.settingsForm.addControl('readingDirection', new FormControl(this.user.preferences.readingDirection, [])); 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/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 2/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 2/OpenDyslexic-Bold.otf diff --git a/UI/Web/src/assets/fonts/OpenDyslexic2/OpenDyslexic-Bold.woff2 b/UI/Web/src/assets/fonts/Open Dyslexic 2/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 2/OpenDyslexic-Bold.woff2 diff --git a/UI/Web/src/assets/fonts/OpenDyslexic2/OpenDyslexic-BoldItalic.otf b/UI/Web/src/assets/fonts/Open Dyslexic 2/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 2/OpenDyslexic-BoldItalic.otf diff --git a/UI/Web/src/assets/fonts/OpenDyslexic2/OpenDyslexic-BoldItalic.woff2 b/UI/Web/src/assets/fonts/Open Dyslexic 2/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 2/OpenDyslexic-BoldItalic.woff2 diff --git a/UI/Web/src/assets/fonts/OpenDyslexic2/OpenDyslexic-Italic.otf b/UI/Web/src/assets/fonts/Open Dyslexic 2/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 2/OpenDyslexic-Italic.otf diff --git a/UI/Web/src/assets/fonts/OpenDyslexic2/OpenDyslexic-Italic.woff2 b/UI/Web/src/assets/fonts/Open Dyslexic 2/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 2/OpenDyslexic-Italic.woff2 diff --git a/UI/Web/src/assets/fonts/OpenDyslexic2/OpenDyslexic-Regular.otf b/UI/Web/src/assets/fonts/Open Dyslexic 2/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 2/OpenDyslexic-Regular.otf diff --git a/UI/Web/src/assets/fonts/OpenDyslexic2/OpenDyslexic-Regular.woff2 b/UI/Web/src/assets/fonts/Open Dyslexic 2/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 2/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 da092a9b0..61da27916 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -102,6 +102,7 @@ "preferences-tab": "Preferences", "3rd-party-clients-tab": "3rd Party Clients", "theme-tab": "Theme", + "font-tab": "Font", "devices-tab": "Devices", "stats-tab": "Stats", "scrobbling-tab": "Scrobbling", @@ -203,6 +204,21 @@ "preview-title": "Preview" }, + "font-manager": { + "title": "Font Manager", + "description": "Kavita comes with a few fonts, but you can add your own. This page is for adding and removing fonts, use the book reading settings to choose the font you want to use.", + "enter-an-url-pre-title": "Enter an {{url}}", + "url": "url", + "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 url", + "delete": "{{common.delete}}", + "url-label": "Url", + "back": "Back", + "load": "Load" + }, + "theme": { "theme-dark": "Dark", "theme-black": "Black", @@ -1972,7 +1988,10 @@ "invalid-password-reset-url": "Invalid reset password url", "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" + "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" }, "metadata-builder": { @@ -2218,7 +2237,8 @@ "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", - "mal-token-required": "MAL Token is required, set in User Settings" + "mal-token-required": "MAL Token is required, set in User Settings", + "confirm-delete-font": "Removing this font will delete it from the disk. You can grab it from temp directory before removal" }, "actionable": { diff --git a/UI/Web/src/styles.scss b/UI/Web/src/styles.scss index 5e7de8cab..65e28f152 100644 --- a/UI/Web/src/styles.scss +++ b/UI/Web/src/styles.scss @@ -53,7 +53,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 321c53278..f8fdd9fc9 100644 --- a/openapi.json +++ b/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.1", "info": { "title": "Kavita", - "description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.2.0", + "description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.2.1", "license": { "name": "GPL-3.0", "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" @@ -2314,6 +2314,179 @@ } } }, + "/api/Font/all": { + "get": { + "tags": [ + "Font" + ], + "summary": "List out the fonts", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EpubFontDto" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EpubFontDto" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EpubFontDto" + } + } + } + } + } + } + } + }, + "/api/Font": { + "get": { + "tags": [ + "Font" + ], + "summary": "Returns a font", + "parameters": [ + { + "name": "fontId", + "in": "query", + "description": "", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "apiKey", + "in": "query", + "description": "", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + }, + "delete": { + "tags": [ + "Font" + ], + "summary": "Removes a font from the system", + "parameters": [ + { + "name": "fontId", + "in": "query", + "description": "", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "confirmed", + "in": "query", + "description": "If the font is in use by other users and an admin wants it deleted, they must confirm to force delete it", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/Font/upload": { + "post": { + "tags": [ + "Font" + ], + "summary": "Manual upload", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "formFile": { + "type": "string", + "format": "binary" + } + } + }, + "encoding": { + "formFile": { + "style": "form" + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/EpubFontDto" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/EpubFontDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/EpubFontDto" + } + } + } + } + } + } + }, + "/api/Font/upload-by-url": { + "post": { + "tags": [ + "Font" + ], + "parameters": [ + { + "name": "url", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/api/Health": { "get": { "tags": [ @@ -16180,6 +16353,32 @@ "additionalProperties": false, "description": "Represents if Test Email Service URL was successful or not and if any error occured" }, + "EpubFontDto": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string", + "nullable": true + }, + "provider": { + "enum": [ + 1, + 2 + ], + "type": "integer", + "format": "int32" + }, + "fileName": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, "ExternalRating": { "type": "object", "properties": {