diff --git a/API.Tests/Services/SiteThemeServiceTests.cs b/API.Tests/Services/SiteThemeServiceTests.cs index 0ff6681dd..463d49df4 100644 --- a/API.Tests/Services/SiteThemeServiceTests.cs +++ b/API.Tests/Services/SiteThemeServiceTests.cs @@ -9,6 +9,7 @@ using API.Services; using API.Services.Tasks; using API.SignalR; using Kavita.Common; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; @@ -44,13 +45,14 @@ public abstract class SiteThemeServiceTest : AbstractDbTest var filesystem = CreateFileSystem(); filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123")); var ds = new DirectoryService(Substitute.For>(), filesystem); - var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub); + var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub, Substitute.For(), + Substitute.For>(), Substitute.For()); _context.SiteTheme.Add(new SiteTheme() { Name = "Custom", NormalizedName = "Custom".ToNormalized(), - Provider = ThemeProvider.User, + Provider = ThemeProvider.Custom, FileName = "custom.css", IsDefault = false }); @@ -61,63 +63,6 @@ public abstract class SiteThemeServiceTest : AbstractDbTest } - [Fact] - public async Task Scan_ShouldFindCustomFile() - { - await ResetDb(); - _testOutputHelper.WriteLine($"[Scan_ShouldOnlyInsertOnceOnSecondScan] All Themes: {(await _unitOfWork.SiteThemeRepository.GetThemes()).Count(t => t.IsDefault)}"); - var filesystem = CreateFileSystem(); - filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("")); - var ds = new DirectoryService(Substitute.For>(), filesystem); - var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub); - await siteThemeService.Scan(); - - Assert.NotNull(await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("custom")); - } - - [Fact] - public async Task Scan_ShouldOnlyInsertOnceOnSecondScan() - { - await ResetDb(); - _testOutputHelper.WriteLine( - $"[Scan_ShouldOnlyInsertOnceOnSecondScan] All Themes: {(await _unitOfWork.SiteThemeRepository.GetThemes()).Count(t => t.IsDefault)}"); - var filesystem = CreateFileSystem(); - filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("")); - var ds = new DirectoryService(Substitute.For>(), filesystem); - var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub); - await siteThemeService.Scan(); - - Assert.NotNull(await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("custom")); - - await siteThemeService.Scan(); - - var customThemes = (await _unitOfWork.SiteThemeRepository.GetThemeDtos()).Where(t => - t.Name.ToNormalized().Equals("custom".ToNormalized())); - - Assert.Single(customThemes); - } - - [Fact] - public async Task Scan_ShouldDeleteWhenFileDoesntExistOnSecondScan() - { - await ResetDb(); - _testOutputHelper.WriteLine($"[Scan_ShouldDeleteWhenFileDoesntExistOnSecondScan] All Themes: {(await _unitOfWork.SiteThemeRepository.GetThemes()).Count(t => t.IsDefault)}"); - var filesystem = CreateFileSystem(); - filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("")); - var ds = new DirectoryService(Substitute.For>(), filesystem); - var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub); - await siteThemeService.Scan(); - - Assert.NotNull(await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("custom")); - - filesystem.RemoveFile($"{SiteThemeDirectory}custom.css"); - await siteThemeService.Scan(); - - var themes = (await _unitOfWork.SiteThemeRepository.GetThemeDtos()); - - Assert.Equal(0, themes.Count(t => - t.Name.ToNormalized().Equals("custom".ToNormalized()))); - } [Fact] public async Task GetContent_ShouldReturnContent() @@ -127,13 +72,14 @@ public abstract class SiteThemeServiceTest : AbstractDbTest var filesystem = CreateFileSystem(); filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123")); var ds = new DirectoryService(Substitute.For>(), filesystem); - var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub); + var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub, Substitute.For(), + Substitute.For>(), Substitute.For()); _context.SiteTheme.Add(new SiteTheme() { Name = "Custom", NormalizedName = "Custom".ToNormalized(), - Provider = ThemeProvider.User, + Provider = ThemeProvider.Custom, FileName = "custom.css", IsDefault = false }); @@ -153,13 +99,14 @@ public abstract class SiteThemeServiceTest : AbstractDbTest var filesystem = CreateFileSystem(); filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123")); var ds = new DirectoryService(Substitute.For>(), filesystem); - var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub); + var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub, Substitute.For(), + Substitute.For>(), Substitute.For()); _context.SiteTheme.Add(new SiteTheme() { Name = "Custom", NormalizedName = "Custom".ToNormalized(), - Provider = ThemeProvider.User, + Provider = ThemeProvider.Custom, FileName = "custom.css", IsDefault = false }); diff --git a/API/Controllers/ThemeController.cs b/API/Controllers/ThemeController.cs index 7fa722624..fb9371919 100644 --- a/API/Controllers/ThemeController.cs +++ b/API/Controllers/ThemeController.cs @@ -1,13 +1,21 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; using System.Threading.Tasks; +using API.Constants; using API.Data; using API.DTOs.Theme; +using API.Entities; using API.Extensions; using API.Services; using API.Services.Tasks; +using AutoMapper; using Kavita.Common; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; namespace API.Controllers; @@ -17,16 +25,19 @@ public class ThemeController : BaseApiController { private readonly IUnitOfWork _unitOfWork; private readonly IThemeService _themeService; - private readonly ITaskScheduler _taskScheduler; private readonly ILocalizationService _localizationService; + private readonly IDirectoryService _directoryService; + private readonly IMapper _mapper; - public ThemeController(IUnitOfWork unitOfWork, IThemeService themeService, ITaskScheduler taskScheduler, - ILocalizationService localizationService) + + public ThemeController(IUnitOfWork unitOfWork, IThemeService themeService, + ILocalizationService localizationService, IDirectoryService directoryService, IMapper mapper) { _unitOfWork = unitOfWork; _themeService = themeService; - _taskScheduler = taskScheduler; _localizationService = localizationService; + _directoryService = directoryService; + _mapper = mapper; } [ResponseCache(CacheProfileName = "10Minute")] @@ -37,13 +48,6 @@ public class ThemeController : BaseApiController return Ok(await _unitOfWork.SiteThemeRepository.GetThemeDtos()); } - [Authorize("RequireAdminRole")] - [HttpPost("scan")] - public ActionResult Scan() - { - _taskScheduler.ScanSiteThemes(); - return Ok(); - } [Authorize("RequireAdminRole")] [HttpPost("update-default")] @@ -78,4 +82,68 @@ public class ThemeController : BaseApiController return BadRequest(await _localizationService.Get("en", ex.Message)); } } + + /// + /// Browse themes that can be used on this server + /// + /// + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour)] + [HttpGet("browse")] + public async Task>> BrowseThemes() + { + var themes = await _themeService.GetDownloadableThemes(); + return Ok(themes.Where(t => !t.AlreadyDownloaded)); + } + + /// + /// Attempts to delete a theme. If already in use by users, will not allow + /// + /// + /// + [HttpDelete] + public async Task>> DeleteTheme(int themeId) + { + + await _themeService.DeleteTheme(themeId); + + return Ok(); + } + + /// + /// Downloads a SiteTheme from upstream + /// + /// + /// + [HttpPost("download-theme")] + public async Task> DownloadTheme(DownloadableSiteThemeDto dto) + { + return Ok(_mapper.Map(await _themeService.DownloadRepoTheme(dto))); + } + + /// + /// Uploads a new theme file + /// + /// + /// + [HttpPost("upload-theme")] + public async Task> DownloadTheme(IFormFile formFile) + { + if (!formFile.FileName.EndsWith(".css")) return BadRequest("Invalid file"); + if (formFile.FileName.Contains("..")) return BadRequest("Invalid file"); + var tempFile = await UploadToTemp(formFile); + + // Set summary as "Uploaded by User.GetUsername() on DATE" + var theme = await _themeService.CreateThemeFromFile(tempFile, User.GetUsername()); + return Ok(_mapper.Map(theme)); + } + + 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/UploadController.cs b/API/Controllers/UploadController.cs index 2430064c8..b4a7dcc6c 100644 --- a/API/Controllers/UploadController.cs +++ b/API/Controllers/UploadController.cs @@ -391,4 +391,6 @@ public class UploadController : BaseApiController return BadRequest(await _localizationService.Translate(User.GetUserId(), "reset-chapter-lock")); } + + } diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs index 7207c3498..26039d700 100644 --- a/API/Controllers/UsersController.cs +++ b/API/Controllers/UsersController.cs @@ -122,9 +122,10 @@ public class UsersController : BaseApiController existingPreferences.PdfScrollMode = preferencesDto.PdfScrollMode; existingPreferences.PdfSpreadMode = preferencesDto.PdfSpreadMode; - if (existingPreferences.Theme.Id != preferencesDto.Theme?.Id) + if (preferencesDto.Theme != null && existingPreferences.Theme.Id != preferencesDto.Theme?.Id) { - existingPreferences.Theme = preferencesDto.Theme ?? await _unitOfWork.SiteThemeRepository.GetDefaultTheme(); + var theme = await _unitOfWork.SiteThemeRepository.GetTheme(preferencesDto.Theme!.Id); + existingPreferences.Theme = theme ?? await _unitOfWork.SiteThemeRepository.GetDefaultTheme(); } diff --git a/API/DTOs/Theme/DownloadableSiteThemeDto.cs b/API/DTOs/Theme/DownloadableSiteThemeDto.cs new file mode 100644 index 000000000..dbcedfe61 --- /dev/null +++ b/API/DTOs/Theme/DownloadableSiteThemeDto.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; + +namespace API.DTOs.Theme; + + +public class DownloadableSiteThemeDto +{ + /// + /// Theme Name + /// + public string Name { get; set; } + /// + /// Url to download css file + /// + public string CssUrl { get; set; } + public string CssFile { get; set; } + /// + /// Url to preview image + /// + public IList PreviewUrls { get; set; } + /// + /// If Already downloaded + /// + public bool AlreadyDownloaded { get; set; } + /// + /// Sha of the file + /// + public string Sha { get; set; } + /// + /// Path of the Folder the files reside in + /// + public string Path { get; set; } + /// + /// Author of the theme + /// + /// Derived from Readme + public string Author { get; set; } + /// + /// Last version tested against + /// + /// Derived from Readme + public string LastCompatibleVersion { get; set; } + /// + /// If version compatible with version + /// + public bool IsCompatible { get; set; } + /// + /// Small blurb about the Theme + /// + public string Description { get; set; } +} diff --git a/API/DTOs/Theme/SiteThemeDto.cs b/API/DTOs/Theme/SiteThemeDto.cs index 18a281b56..eb2a14904 100644 --- a/API/DTOs/Theme/SiteThemeDto.cs +++ b/API/DTOs/Theme/SiteThemeDto.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using API.Entities.Enums.Theme; using API.Services; @@ -30,5 +31,21 @@ public class SiteThemeDto /// Where did the theme come from /// public ThemeProvider Provider { get; set; } + + public IList PreviewUrls { get; set; } + /// + /// Information about the theme + /// + public string Description { get; set; } + /// + /// Author of the Theme (only applies to non-system provided themes) + /// + public string Author { get; set; } + /// + /// Last compatible version. System provided will always be most current + /// + public string CompatibleVersion { get; set; } + + public string Selector => "bg-" + Name.ToLower(); } diff --git a/API/DTOs/UserPreferencesDto.cs b/API/DTOs/UserPreferencesDto.cs index 1221c73e5..5fe4f297b 100644 --- a/API/DTOs/UserPreferencesDto.cs +++ b/API/DTOs/UserPreferencesDto.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using API.DTOs.Theme; using API.Entities; using API.Entities.Enums; using API.Entities.Enums.UserPreferences; @@ -104,7 +105,7 @@ public class UserPreferencesDto /// /// Should default to Dark [Required] - public SiteTheme? Theme { get; set; } + public SiteThemeDto? Theme { get; set; } [Required] public string BookReaderThemeName { get; set; } = null!; [Required] diff --git a/API/Data/Migrations/20240510134030_SiteThemeFields.Designer.cs b/API/Data/Migrations/20240510134030_SiteThemeFields.Designer.cs new file mode 100644 index 000000000..c88a1628f --- /dev/null +++ b/API/Data/Migrations/20240510134030_SiteThemeFields.Designer.cs @@ -0,0 +1,3043 @@ +// +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("20240510134030_SiteThemeFields")] + partial class SiteThemeFields + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.4"); + + 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.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/20240510134030_SiteThemeFields.cs b/API/Data/Migrations/20240510134030_SiteThemeFields.cs new file mode 100644 index 000000000..36171fa0a --- /dev/null +++ b/API/Data/Migrations/20240510134030_SiteThemeFields.cs @@ -0,0 +1,78 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class SiteThemeFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Author", + table: "SiteTheme", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "CompatibleVersion", + table: "SiteTheme", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "Description", + table: "SiteTheme", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "GitHubPath", + table: "SiteTheme", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "PreviewUrls", + table: "SiteTheme", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "ShaHash", + table: "SiteTheme", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Author", + table: "SiteTheme"); + + migrationBuilder.DropColumn( + name: "CompatibleVersion", + table: "SiteTheme"); + + migrationBuilder.DropColumn( + name: "Description", + table: "SiteTheme"); + + migrationBuilder.DropColumn( + name: "GitHubPath", + table: "SiteTheme"); + + migrationBuilder.DropColumn( + name: "PreviewUrls", + table: "SiteTheme"); + + migrationBuilder.DropColumn( + name: "ShaHash", + table: "SiteTheme"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 5507db79a..c59b774d6 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -1871,15 +1871,27 @@ namespace API.Data.Migrations .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"); @@ -1895,9 +1907,15 @@ namespace API.Data.Migrations 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"); diff --git a/API/Data/Repositories/SiteThemeRepository.cs b/API/Data/Repositories/SiteThemeRepository.cs index 4e1a01c98..2498dfa60 100644 --- a/API/Data/Repositories/SiteThemeRepository.cs +++ b/API/Data/Repositories/SiteThemeRepository.cs @@ -19,6 +19,8 @@ public interface ISiteThemeRepository Task GetThemeDtoByName(string themeName); Task GetDefaultTheme(); Task> GetThemes(); + Task GetTheme(int themeId); + Task IsThemeInUse(int themeId); } public class SiteThemeRepository : ISiteThemeRepository @@ -88,6 +90,19 @@ public class SiteThemeRepository : ISiteThemeRepository .ToListAsync(); } + public async Task GetTheme(int themeId) + { + return await _context.SiteTheme + .Where(t => t.Id == themeId) + .FirstOrDefaultAsync(); + } + + public async Task IsThemeInUse(int themeId) + { + return await _context.AppUserPreferences + .AnyAsync(p => p.Theme.Id == themeId); + } + public async Task GetThemeDto(int themeId) { return await _context.SiteTheme diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index ac1cfb1f1..955450a37 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -25,8 +25,8 @@ public static class Seed /// public static ImmutableArray DefaultSettings; - public static readonly ImmutableArray DefaultThemes = ImmutableArray.Create( - new List + public static readonly ImmutableArray DefaultThemes = [ + ..new List { new() { @@ -36,7 +36,8 @@ public static class Seed FileName = "dark.scss", IsDefault = true, } - }.ToArray()); + }.ToArray() + ]; public static readonly ImmutableArray DefaultStreams = ImmutableArray.Create( new List diff --git a/API/Entities/Enums/Theme/ThemeProvider.cs b/API/Entities/Enums/Theme/ThemeProvider.cs index 45af2d94b..cc12a552e 100644 --- a/API/Entities/Enums/Theme/ThemeProvider.cs +++ b/API/Entities/Enums/Theme/ThemeProvider.cs @@ -10,8 +10,8 @@ public enum ThemeProvider [Description("System")] System = 1, /// - /// Theme is provided by the User (ie it's custom) + /// Theme is provided by the User (ie it's custom) or Downloaded via Themes Repo /// - [Description("User")] - User = 2 + [Description("Custom")] + Custom = 2, } diff --git a/API/Entities/SiteTheme.cs b/API/Entities/SiteTheme.cs index 09b348cb8..107dca556 100644 --- a/API/Entities/SiteTheme.cs +++ b/API/Entities/SiteTheme.cs @@ -37,4 +37,30 @@ public class SiteTheme : IEntityDate, ITheme public DateTime LastModified { get; set; } public DateTime CreatedUtc { get; set; } public DateTime LastModifiedUtc { get; set; } + + #region ThemeBrowser + + /// + /// The Url on the repo to download the file from + /// + public string? GitHubPath { get; set; } + /// + /// Hash of the Css File + /// + public string? ShaHash { get; set; } + /// + /// Pipe (|) separated urls of the images. Empty string if + /// + public string PreviewUrls { get; set; } + // /// + // /// A description about the theme + // /// + public string Description { get; set; } + // /// + // /// Author of the Theme + // /// + public string Author { get; set; } + public string CompatibleVersion { get; set; } + + #endregion } diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index 4862682b7..874fabd9a 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using API.Data.Migrations; using API.DTOs; @@ -241,7 +242,10 @@ public class AutoMapperProfiles : Profile IncludeUnknowns = src.AgeRestrictionIncludeUnknowns })); - CreateMap(); + CreateMap() + .ForMember(dest => dest.PreviewUrls, + opt => + opt.MapFrom(src => (src.PreviewUrls ?? string.Empty).Split('|', StringSplitOptions.TrimEntries))); CreateMap() .ForMember(dest => dest.Theme, opt => diff --git a/API/Services/FileService.cs b/API/Services/FileService.cs index a4194b820..37222655a 100644 --- a/API/Services/FileService.cs +++ b/API/Services/FileService.cs @@ -1,5 +1,10 @@ using System; +using System.IO; using System.IO.Abstractions; +using System.Runtime.Intrinsics.Arm; +using System.Security.Cryptography; +using System.Text; +using System.Text.Unicode; using API.Extensions; namespace API.Services; @@ -9,6 +14,7 @@ public interface IFileService IFileSystem GetFileSystem(); bool HasFileBeenModifiedSince(string filePath, DateTime time); bool Exists(string filePath); + bool ValidateSha(string filepath, string sha); } public class FileService : IFileService @@ -43,4 +49,28 @@ public class FileService : IFileService { return _fileSystem.File.Exists(filePath); } + + /// + /// Validates the Sha256 hash matches + /// + /// + /// + /// + public bool ValidateSha(string filepath, string sha) + { + if (!Exists(filepath)) return false; + if (string.IsNullOrEmpty(sha)) throw new ArgumentException("Sha cannot be null"); + + using var fs = _fileSystem.File.OpenRead(filepath); + fs.Position = 0; + + using var reader = new StreamReader(fs, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + var content = reader.ReadToEnd(); + + // Compute SHA hash + var checksum = SHA256.HashData(Encoding.UTF8.GetBytes(content)); + + return BitConverter.ToString(checksum).Replace("-", string.Empty).Equals(sha); + + } } diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index 015336ea7..c25f97e9f 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -32,7 +32,6 @@ public interface ITaskScheduler void AnalyzeFilesForLibrary(int libraryId, bool forceUpdate = false); void CancelStatsTasks(); Task RunStatCollection(); - void ScanSiteThemes(); void CovertAllCoversToEncoding(); Task CleanupDbEntries(); Task CheckForUpdate(); @@ -64,6 +63,7 @@ public class TaskScheduler : ITaskScheduler public const string DefaultQueue = "default"; public const string RemoveFromWantToReadTaskId = "remove-from-want-to-read"; public const string UpdateYearlyStatsTaskId = "update-yearly-stats"; + public const string SyncThemesTaskId = "sync-themes"; public const string CheckForUpdateId = "check-updates"; public const string CleanupDbTaskId = "cleanup-db"; public const string CleanupTaskId = "cleanup"; @@ -161,6 +161,9 @@ public class TaskScheduler : ITaskScheduler RecurringJob.AddOrUpdate(UpdateYearlyStatsTaskId, () => _statisticService.UpdateServerStatistics(), Cron.Monthly, RecurringJobOptions); + RecurringJob.AddOrUpdate(SyncThemesTaskId, () => _themeService.SyncThemes(), + Cron.Weekly, RecurringJobOptions); + await ScheduleKavitaPlusTasks(); } @@ -200,7 +203,7 @@ public class TaskScheduler : ITaskScheduler public async Task ScheduleStatsTasks() { - var allowStatCollection = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).AllowStatCollection; + var allowStatCollection = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).AllowStatCollection; if (!allowStatCollection) { _logger.LogDebug("User has opted out of stat collection, not registering tasks"); @@ -241,18 +244,6 @@ public class TaskScheduler : ITaskScheduler BackgroundJob.Schedule(() => _statsService.Send(), DateTimeOffset.Now.AddDays(1)); } - public void ScanSiteThemes() - { - if (HasAlreadyEnqueuedTask("ThemeService", "Scan", Array.Empty(), ScanQueue)) - { - _logger.LogInformation("A Theme Scan is already running"); - return; - } - - _logger.LogInformation("Enqueueing Site Theme scan"); - BackgroundJob.Enqueue(() => _themeService.Scan()); - } - public void CovertAllCoversToEncoding() { var defaultParams = Array.Empty(); diff --git a/API/Services/Tasks/SiteThemeService.cs b/API/Services/Tasks/SiteThemeService.cs index 730900c16..f71d97c7e 100644 --- a/API/Services/Tasks/SiteThemeService.cs +++ b/API/Services/Tasks/SiteThemeService.cs @@ -1,35 +1,105 @@ using System; +using System.Collections.Generic; +using System.IO; using System.Linq; using System.Threading.Tasks; using API.Data; +using API.DTOs.Theme; using API.Entities; using API.Entities.Enums.Theme; using API.Extensions; using API.SignalR; +using Flurl.Http; +using HtmlAgilityPack; using Kavita.Common; -using Microsoft.AspNetCore.Authorization; +using Kavita.Common.EnvironmentInfo; +using MarkdownDeep; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; namespace API.Services.Tasks; #nullable enable +internal class GitHubContent +{ + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("path")] + public string Path { get; set; } + + [JsonProperty("type")] + public string Type { get; set; } + + [JsonProperty("download_url")] + public string DownloadUrl { get; set; } + + [JsonProperty("sha")] + public string Sha { get; set; } +} + +/// +/// The readme of the Theme repo +/// +internal class ThemeMetadata +{ + public string Author { get; set; } + public string AuthorUrl { get; set; } + public string Description { get; set; } + public Version LastCompatible { get; set; } +} + + public interface IThemeService { Task GetContent(int themeId); - Task Scan(); Task UpdateDefault(int themeId); + /// + /// Browse theme repo for themes to download + /// + /// + Task> GetDownloadableThemes(); + + Task DownloadRepoTheme(DownloadableSiteThemeDto dto); + Task DeleteTheme(int siteThemeId); + Task CreateThemeFromFile(string tempFile, string username); + Task SyncThemes(); } + + public class ThemeService : IThemeService { private readonly IDirectoryService _directoryService; private readonly IUnitOfWork _unitOfWork; private readonly IEventHub _eventHub; + private readonly IFileService _fileService; + private readonly ILogger _logger; + private readonly Markdown _markdown = new(); + private readonly IMemoryCache _cache; + private readonly MemoryCacheEntryOptions _cacheOptions; - public ThemeService(IDirectoryService directoryService, IUnitOfWork unitOfWork, IEventHub eventHub) + private const string GithubBaseUrl = "https://api.github.com"; + + /// + /// Used for refreshing metadata around themes + /// + private const string GithubReadme = "https://raw.githubusercontent.com/Kareadita/Themes/main/README.md"; + + public ThemeService(IDirectoryService directoryService, IUnitOfWork unitOfWork, + IEventHub eventHub, IFileService fileService, ILogger logger, IMemoryCache cache) { _directoryService = directoryService; _unitOfWork = unitOfWork; _eventHub = eventHub; + _fileService = fileService; + _logger = logger; + _cache = cache; + + _cacheOptions = new MemoryCacheEntryOptions() + .SetSize(1) + .SetAbsoluteExpiration(TimeSpan.FromMinutes(30)); } /// @@ -39,8 +109,7 @@ public class ThemeService : IThemeService /// public async Task GetContent(int themeId) { - var theme = await _unitOfWork.SiteThemeRepository.GetThemeDto(themeId); - if (theme == null) throw new KavitaException("theme-doesnt-exist"); + var theme = await _unitOfWork.SiteThemeRepository.GetThemeDto(themeId) ?? throw new KavitaException("theme-doesnt-exist"); var themeFile = _directoryService.FileSystem.Path.Join(_directoryService.SiteThemeDirectory, theme.FileName); if (string.IsNullOrEmpty(themeFile) || !_directoryService.FileSystem.File.Exists(themeFile)) throw new KavitaException("theme-doesnt-exist"); @@ -48,78 +117,350 @@ public class ThemeService : IThemeService return await _directoryService.FileSystem.File.ReadAllTextAsync(themeFile); } - /// - /// Scans the site theme directory for custom css files and updates what the system has on store - /// - public async Task Scan() + public async Task> GetDownloadableThemes() { - _directoryService.ExistOrCreate(_directoryService.SiteThemeDirectory); - var reservedNames = Seed.DefaultThemes.Select(t => t.NormalizedName).ToList(); - var themeFiles = _directoryService - .GetFilesWithExtension(Scanner.Parser.Parser.NormalizePath(_directoryService.SiteThemeDirectory), @"\.css") - .Where(name => !reservedNames.Contains(name.ToNormalized()) && !name.Contains(" ")) - .ToList(); - - var allThemes = (await _unitOfWork.SiteThemeRepository.GetThemes()).ToList(); - - // First remove any files from allThemes that are User Defined and not on disk - var userThemes = allThemes.Where(t => t.Provider == ThemeProvider.User).ToList(); - foreach (var userTheme in userThemes) + const string cacheKey = "browse"; + var existingThemes = (await _unitOfWork.SiteThemeRepository.GetThemeDtos()).ToDictionary(k => k.Name); + if (_cache.TryGetValue(cacheKey, out List? themes) && themes != null) { - var filepath = Scanner.Parser.Parser.NormalizePath( - _directoryService.FileSystem.Path.Join(_directoryService.SiteThemeDirectory, userTheme.FileName)); - if (_directoryService.FileSystem.File.Exists(filepath)) continue; - - // I need to do the removal different. I need to update all user preferences to use DefaultTheme - allThemes.Remove(userTheme); - await RemoveTheme(userTheme); - } - - // Add new custom themes - var allThemeNames = allThemes.Select(t => t.NormalizedName).ToList(); - foreach (var themeFile in themeFiles) - { - var themeName = - _directoryService.FileSystem.Path.GetFileNameWithoutExtension(themeFile).ToNormalized(); - if (allThemeNames.Contains(themeName)) continue; - - _unitOfWork.SiteThemeRepository.Add(new SiteTheme() + foreach (var t in themes) { - Name = _directoryService.FileSystem.Path.GetFileNameWithoutExtension(themeFile), - NormalizedName = themeName, - FileName = _directoryService.FileSystem.Path.GetFileName(themeFile), - Provider = ThemeProvider.User, - IsDefault = false, - }); - - await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, - MessageFactory.SiteThemeProgressEvent(_directoryService.FileSystem.Path.GetFileName(themeFile), themeName, - ProgressEventType.Updated)); + t.AlreadyDownloaded = existingThemes.ContainsKey(t.Name); + } + return themes; } + // Fetch contents of the Native Themes directory + var themesContents = await GetDirectoryContent("Native%20Themes"); - if (_unitOfWork.HasChanges()) - { - await _unitOfWork.CommitAsync(); - } + // Filter out directories + var themeDirectories = themesContents.Where(c => c.Type == "dir").ToList(); - // if there are no default themes, reselect Dark as default - var postSaveThemes = (await _unitOfWork.SiteThemeRepository.GetThemes()).ToList(); - if (!postSaveThemes.Exists(t => t.IsDefault)) + // Get the Readme and augment the theme data + var themeMetadata = await GetReadme(); + + var themeDtos = new List(); + foreach (var themeDir in themeDirectories) { - var defaultThemeName = Seed.DefaultThemes.Single(t => t.IsDefault).NormalizedName; - var theme = postSaveThemes.SingleOrDefault(t => t.NormalizedName == defaultThemeName); - if (theme != null) + var themeName = themeDir.Name.Trim(); + + // Fetch contents of the theme directory + var themeContents = await GetDirectoryContent(themeDir.Path); + + // Find css and preview files + var cssFile = themeContents.FirstOrDefault(c => c.Name.EndsWith(".css")); + var previewUrls = GetPreviewUrls(themeContents); + + if (cssFile == null) continue; + + var cssUrl = cssFile.DownloadUrl; + + + var dto = new DownloadableSiteThemeDto() { - theme.IsDefault = true; - _unitOfWork.SiteThemeRepository.Update(theme); - await _unitOfWork.CommitAsync(); + Name = themeName, + CssUrl = cssUrl, + CssFile = cssFile.Name, + PreviewUrls = previewUrls, + Sha = cssFile.Sha, + Path = themeDir.Path, + }; + + if (themeMetadata.TryGetValue(themeName, out var metadata)) + { + dto.Author = metadata.Author; + dto.LastCompatibleVersion = metadata.LastCompatible.ToString(); + dto.IsCompatible = BuildInfo.Version <= metadata.LastCompatible; + dto.AlreadyDownloaded = existingThemes.ContainsKey(themeName); + dto.Description = metadata.Description; } + themeDtos.Add(dto); } + _cache.Set(themeDtos, themes, _cacheOptions); + + return themeDtos; + } + + private static IList GetPreviewUrls(IEnumerable themeContents) + { + return themeContents.Where(c => c.Name.ToLower().EndsWith(".jpg") || c.Name.ToLower().EndsWith(".png") ) + .Select(p => p.DownloadUrl) + .ToList(); + } + + private static async Task> GetDirectoryContent(string path) + { + return await $"{GithubBaseUrl}/repos/Kareadita/Themes/contents/{path}" + .WithHeader("Accept", "application/vnd.github+json") + .WithHeader("User-Agent", "Kavita") + .GetJsonAsync>(); + } + + /// + /// Returns a map of all Native Themes names mapped to their metadata + /// + /// + private async Task> GetReadme() + { + var tempDownloadFile = await GithubReadme.DownloadFileAsync(_directoryService.TempDirectory); + + // Read file into Markdown + var htmlContent = _markdown.Transform(await _directoryService.FileSystem.File.ReadAllTextAsync(tempDownloadFile)); + var htmlDoc = new HtmlDocument(); + htmlDoc.LoadHtml(htmlContent); + + // Find the table of Native Themes + var tableContent = htmlDoc.DocumentNode + .SelectSingleNode("//h2[contains(text(),'Native Themes')]/following-sibling::p").InnerText; + + // Initialize dictionary to store theme metadata + var themes = new Dictionary(); + + + // Split the table content by rows + var rows = tableContent.Split("\r\n").Select(row => row.Trim()).Where(row => !string.IsNullOrWhiteSpace(row)).ToList(); + + // Parse each row in the Native Themes table + foreach (var row in rows.Skip(2)) + { + + var cells = row.Split('|').Skip(1).Select(cell => cell.Trim()).ToList(); + + // Extract information from each cell + var themeName = cells[0]; + var authorName = cells[1]; + var description = cells[2]; + var compatibility = Version.Parse(cells[3]); + + // Create ThemeMetadata object + var themeMetadata = new ThemeMetadata + { + Author = authorName, + Description = description, + LastCompatible = compatibility + }; + + // Add theme metadata to dictionary + themes.Add(themeName, themeMetadata); + } + + return themes; + } + + + private async Task DownloadSiteTheme(DownloadableSiteThemeDto dto) + { + if (string.IsNullOrEmpty(dto.Sha)) + { + throw new ArgumentException("SHA cannot be null or empty for already downloaded themes."); + } + + _directoryService.ExistOrCreate(_directoryService.SiteThemeDirectory); + var existingTempFile = _directoryService.FileSystem.Path.Join(_directoryService.SiteThemeDirectory, + _directoryService.FileSystem.FileInfo.New(dto.CssUrl).Name); + _directoryService.DeleteFiles([existingTempFile]); + + var tempDownloadFile = await dto.CssUrl.DownloadFileAsync(_directoryService.TempDirectory); + + // Validate the hash on the downloaded file + // if (!_fileService.ValidateSha(tempDownloadFile, dto.Sha)) + // { + // throw new KavitaException("Cannot download theme, hash does not match"); + // } + + _directoryService.CopyFileToDirectory(tempDownloadFile, _directoryService.SiteThemeDirectory); + var finalLocation = _directoryService.FileSystem.Path.Join(_directoryService.SiteThemeDirectory, dto.CssFile); + + return finalLocation; + } + + + public async Task DownloadRepoTheme(DownloadableSiteThemeDto dto) + { + + // Validate we don't have a collision with existing or existing doesn't already exist + var existingThemes = _directoryService.ScanFiles(_directoryService.SiteThemeDirectory, string.Empty); + if (existingThemes.Any(f => Path.GetFileName(f) == dto.CssFile)) + { + throw new KavitaException("Cannot download file, file already on disk"); + } + + var finalLocation = await DownloadSiteTheme(dto); + + // Create a new entry and note that this is downloaded + var theme = new SiteTheme() + { + Name = dto.Name, + NormalizedName = dto.Name.ToNormalized(), + FileName = _directoryService.FileSystem.Path.GetFileName(finalLocation), + Provider = ThemeProvider.Custom, + IsDefault = false, + GitHubPath = dto.Path, + Description = dto.Description, + PreviewUrls = string.Join('|', dto.PreviewUrls), + Author = dto.Author, + ShaHash = dto.Sha, + CompatibleVersion = dto.LastCompatibleVersion, + }; + _unitOfWork.SiteThemeRepository.Add(theme); + + await _unitOfWork.CommitAsync(); + + // Inform about the new theme await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, - MessageFactory.SiteThemeProgressEvent("", "", ProgressEventType.Ended)); + MessageFactory.SiteThemeProgressEvent(_directoryService.FileSystem.Path.GetFileName(theme.FileName), theme.Name, + ProgressEventType.Ended)); + return theme; + } + + public async Task SyncThemes() + { + var themes = await _unitOfWork.SiteThemeRepository.GetThemes(); + var themeMetadata = await GetReadme(); + foreach (var theme in themes) + { + await SyncTheme(theme, themeMetadata); + } + _logger.LogInformation("Sync Themes complete"); + } + + /// + /// If the Theme is from the Theme repo, see if there is a new version that is compatible + /// + /// + /// The Readme information + private async Task SyncTheme(SiteTheme? theme, IDictionary themeMetadata) + { + // Given a theme, first validate that it is applicable + if (theme == null || theme.Provider == ThemeProvider.System || string.IsNullOrEmpty(theme.GitHubPath)) + { + _logger.LogInformation("Cannot Sync {ThemeName} as it is not valid", theme?.Name); + return; + } + + if (new Version(theme.CompatibleVersion) > BuildInfo.Version) + { + _logger.LogDebug("{ThemeName} theme supports a more up-to-date version ({Version}) of Kavita. Please update", theme.Name, theme.CompatibleVersion); + return; + } + + + var themeContents = await GetDirectoryContent(theme.GitHubPath); + var cssFile = themeContents.FirstOrDefault(c => c.Name.EndsWith(".css")); + + if (cssFile == null) return; + + // Update any metadata + if (themeMetadata.TryGetValue(theme.Name, out var metadata)) + { + theme.Description = metadata.Description; + theme.Author = metadata.Author; + theme.CompatibleVersion = metadata.LastCompatible.ToString(); + theme.PreviewUrls = string.Join('|', GetPreviewUrls(themeContents)); + } + + var hasUpdated = cssFile.Sha != theme.ShaHash; + if (hasUpdated) + { + _logger.LogDebug("Theme {ThemeName} is out of date, updating", theme.Name); + var tempLocation = _directoryService.FileSystem.Path.Join(_directoryService.TempDirectory, theme.FileName); + + _directoryService.DeleteFiles([tempLocation]); + + var location = await cssFile.DownloadUrl.DownloadFileAsync(_directoryService.TempDirectory); + if (_directoryService.FileSystem.File.Exists(location)) + { + _directoryService.CopyFileToDirectory(location, _directoryService.SiteThemeDirectory); + _logger.LogInformation("Updated Theme on disk for {ThemeName}", theme.Name); + } + } + + await _unitOfWork.CommitAsync(); + + + if (hasUpdated) + { + await _eventHub.SendMessageAsync(MessageFactory.SiteThemeUpdated, + MessageFactory.SiteThemeUpdatedEvent(theme.Name)); + } + + // Send an update to refresh metadata around the themes + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.SiteThemeProgressEvent(_directoryService.FileSystem.Path.GetFileName(theme.FileName), theme.Name, + ProgressEventType.Ended)); + + _logger.LogInformation("Theme Sync complete"); + } + + /// + /// Deletes a SiteTheme. The CSS file will be moved to temp/ to allow user to recover data + /// + /// + public async Task DeleteTheme(int siteThemeId) + { + // Validate no one else is using this theme + var inUse = await _unitOfWork.SiteThemeRepository.IsThemeInUse(siteThemeId); + if (inUse) + { + throw new KavitaException("errors.delete-theme-in-use"); + } + + var siteTheme = await _unitOfWork.SiteThemeRepository.GetTheme(siteThemeId); + if (siteTheme == null) return; + + await RemoveTheme(siteTheme); + } + + /// + /// This assumes a file is already in temp directory and will be used for + /// + /// + /// + public async Task CreateThemeFromFile(string tempFile, string username) + { + if (!_directoryService.FileSystem.File.Exists(tempFile)) + { + _logger.LogInformation("Unable to create theme from manual upload as file not in temp"); + throw new KavitaException("errors.theme-manual-upload"); + } + + + var filename = _directoryService.FileSystem.FileInfo.New(tempFile).Name; + var themeName = Path.GetFileNameWithoutExtension(filename); + + if (await _unitOfWork.SiteThemeRepository.GetThemeDtoByName(themeName) != null) + { + throw new KavitaException("errors.theme-already-in-use"); + } + + _directoryService.CopyFileToDirectory(tempFile, _directoryService.SiteThemeDirectory); + var finalLocation = _directoryService.FileSystem.Path.Join(_directoryService.SiteThemeDirectory, filename); + + + // Create a new entry and note that this is downloaded + var theme = new SiteTheme() + { + Name = Path.GetFileNameWithoutExtension(filename), + NormalizedName = themeName.ToNormalized(), + FileName = _directoryService.FileSystem.Path.GetFileName(finalLocation), + Provider = ThemeProvider.Custom, + IsDefault = false, + Description = $"Manually uploaded via UI by {username}", + PreviewUrls = string.Empty, + Author = username, + }; + _unitOfWork.SiteThemeRepository.Add(theme); + + await _unitOfWork.CommitAsync(); + + // Inform about the new theme + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.SiteThemeProgressEvent(_directoryService.FileSystem.Path.GetFileName(theme.FileName), theme.Name, + ProgressEventType.Ended)); + return theme; + } @@ -130,6 +471,7 @@ public class ThemeService : IThemeService /// private async Task RemoveTheme(SiteTheme theme) { + _logger.LogInformation("Removing {ThemeName}. File can be found in temp/ until nightly cleanup", theme.Name); var prefs = await _unitOfWork.UserRepository.GetAllPreferencesByThemeAsync(theme.Id); var defaultTheme = await _unitOfWork.SiteThemeRepository.GetDefaultTheme(); foreach (var pref in prefs) @@ -137,6 +479,20 @@ public class ThemeService : IThemeService pref.Theme = defaultTheme; _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, theme.FileName); + var newLocation = + _directoryService.FileSystem.Path.Join(_directoryService.TempDirectory, theme.FileName); + _directoryService.CopyFileToDirectory(existingLocation, newLocation); + _directoryService.DeleteFiles([existingLocation]); + } + catch (Exception) { /* Swallow */ } + + _unitOfWork.SiteThemeRepository.Remove(theme); await _unitOfWork.CommitAsync(); } diff --git a/API/SignalR/MessageFactory.cs b/API/SignalR/MessageFactory.cs index 2fded8ccc..31a5c949f 100644 --- a/API/SignalR/MessageFactory.cs +++ b/API/SignalR/MessageFactory.cs @@ -130,6 +130,10 @@ public static class MessageFactory /// Order, Visibility, etc has changed on the Sidenav. UI will refresh the layout /// public const string SideNavUpdate = "SideNavUpdate"; + /// + /// A Theme was updated and UI should refresh to get the latest version + /// + public const string SiteThemeUpdated = "SiteThemeUpdated"; public static SignalRMessage DashboardUpdateEvent(int userId) { @@ -485,7 +489,7 @@ public static class MessageFactory return new SignalRMessage() { Name = SiteThemeProgress, - Title = "Scanning Site Theme", + Title = "Processing Site Theme", // TODO: Localize SignalRMessage titles SubTitle = subtitle, EventType = eventType, Progress = ProgressType.Indeterminate, @@ -496,6 +500,25 @@ public static class MessageFactory }; } + /// + /// Sends an event to the UI informing of a SiteTheme update and UI needs to refresh the content + /// + /// + /// + public static SignalRMessage SiteThemeUpdatedEvent(string themeName) + { + return new SignalRMessage() + { + Name = SiteThemeUpdated, + Title = "SiteTheme Update", + Progress = ProgressType.None, + Body = new + { + ThemeName = themeName, + } + }; + } + public static SignalRMessage BookThemeProgressEvent(string subtitle, string themeName, string eventType) { return new SignalRMessage() diff --git a/API/Startup.cs b/API/Startup.cs index 695506d53..8d9e45fa5 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -398,7 +398,10 @@ public class Startup endpoints.MapControllers(); endpoints.MapHub("hubs/messages"); endpoints.MapHub("hubs/logs"); - endpoints.MapHangfireDashboard(); + if (env.IsDevelopment()) + { + endpoints.MapHangfireDashboard(); + } endpoints.MapFallbackToController("Index", "Fallback"); }); diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index 2b6eedf49..3224cf303 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -504,6 +504,7 @@ "version": "17.3.4", "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-17.3.4.tgz", "integrity": "sha512-TVWjpZSI/GIXTYsmVgEKYjBckcW8Aj62DcxLNehRFR+c7UB95OY3ZFjU8U4jL0XvWPgTkkVWQVq+P6N4KCBsyw==", + "dev": true, "dependencies": { "@babel/core": "7.23.9", "@jridgewell/sourcemap-codec": "^1.4.14", @@ -531,6 +532,7 @@ "version": "7.23.9", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.9.tgz", "integrity": "sha512-5q0175NOjddqpvvzU+kDiSOAk4PfdO6FvwCWoQ6RO7rTzEe8vlo+4HVfcnAREhD4npMs0e9uZypjTwzZPCf/cw==", + "dev": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.23.5", @@ -559,12 +561,14 @@ "node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true }, "node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "bin": { "semver": "bin/semver.js" } @@ -745,6 +749,7 @@ "version": "7.24.0", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.0.tgz", "integrity": "sha512-fQfkg0Gjkza3nf0c7/w6Xf34BW4YvzNfACRLmmb7XRLa6XHdR+K9AlJlxneFfWYf6uhOzuzZVTjF/8KfndZANw==", + "dev": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.23.5", @@ -773,12 +778,14 @@ "node_modules/@babel/core/node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true }, "node_modules/@babel/core/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "bin": { "semver": "bin/semver.js" } @@ -5622,6 +5629,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -5634,6 +5642,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, "engines": { "node": ">=8.6" }, @@ -5905,6 +5914,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, "engines": { "node": ">=8" }, @@ -6216,6 +6226,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -6507,7 +6518,8 @@ "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true }, "node_modules/cookie": { "version": "0.6.0", @@ -7409,6 +7421,7 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, "optional": true, "dependencies": { "iconv-lite": "^0.6.2" @@ -7418,6 +7431,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, "optional": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -8526,6 +8540,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -9207,6 +9222,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, "dependencies": { "binary-extensions": "^2.0.0" }, @@ -11047,6 +11063,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -12436,6 +12453,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, "dependencies": { "picomatch": "^2.2.1" }, @@ -12447,6 +12465,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, "engines": { "node": ">=8.6" }, @@ -12457,7 +12476,8 @@ "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", - "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "dev": true }, "node_modules/regenerate": { "version": "1.4.2", @@ -12925,7 +12945,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "devOptional": true + "dev": true }, "node_modules/sass": { "version": "1.71.1", @@ -13044,6 +13064,7 @@ "version": "7.6.0", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "dev": true, "dependencies": { "lru-cache": "^6.0.0" }, @@ -13058,6 +13079,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, "dependencies": { "yallist": "^4.0.0" }, @@ -13068,7 +13090,8 @@ "node_modules/semver/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true }, "node_modules/send": { "version": "0.18.0", @@ -14199,6 +14222,7 @@ "version": "5.4.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/UI/Web/src/app/_models/events/site-theme-updated-event.ts b/UI/Web/src/app/_models/events/site-theme-updated-event.ts new file mode 100644 index 000000000..fea80c979 --- /dev/null +++ b/UI/Web/src/app/_models/events/site-theme-updated-event.ts @@ -0,0 +1,3 @@ +export interface SiteThemeUpdatedEvent { + themeName: string; +} diff --git a/UI/Web/src/app/_models/preferences/site-theme.ts b/UI/Web/src/app/_models/preferences/site-theme.ts index 675d4dad3..a861a5a11 100644 --- a/UI/Web/src/app/_models/preferences/site-theme.ts +++ b/UI/Web/src/app/_models/preferences/site-theme.ts @@ -3,9 +3,9 @@ */ export enum ThemeProvider { System = 1, - User = 2 + Custom = 2, } - + /** * Theme for the whole instance */ @@ -20,4 +20,8 @@ * The actual class the root is defined against. It is generated at the backend. */ selector: string; - } \ No newline at end of file + description: string; + previewUrls: Array; + author: string; + + } diff --git a/UI/Web/src/app/_models/theme/downloadable-site-theme.ts b/UI/Web/src/app/_models/theme/downloadable-site-theme.ts new file mode 100644 index 000000000..62885c063 --- /dev/null +++ b/UI/Web/src/app/_models/theme/downloadable-site-theme.ts @@ -0,0 +1,10 @@ +export interface DownloadableSiteTheme { + name: string; + cssUrl: string; + previewUrls: Array; + author: string; + isCompatible: boolean; + lastCompatibleVersion: string; + alreadyDownloaded: boolean; + description: string; +} diff --git a/UI/Web/src/app/_pipes/site-theme-provider.pipe.ts b/UI/Web/src/app/_pipes/site-theme-provider.pipe.ts index e7af63752..bb7ff09b3 100644 --- a/UI/Web/src/app/_pipes/site-theme-provider.pipe.ts +++ b/UI/Web/src/app/_pipes/site-theme-provider.pipe.ts @@ -16,8 +16,8 @@ export class SiteThemeProviderPipe implements PipeTransform { switch(provider) { case ThemeProvider.System: return this.translocoService.translate('site-theme-provider-pipe.system'); - case ThemeProvider.User: - return this.translocoService.translate('site-theme-provider-pipe.user'); + case ThemeProvider.Custom: + return this.translocoService.translate('site-theme-provider-pipe.custom'); default: return ''; } diff --git a/UI/Web/src/app/_services/message-hub.service.ts b/UI/Web/src/app/_services/message-hub.service.ts index 7d3bbcadf..a7f30fda2 100644 --- a/UI/Web/src/app/_services/message-hub.service.ts +++ b/UI/Web/src/app/_services/message-hub.service.ts @@ -9,6 +9,7 @@ import { UserUpdateEvent } from '../_models/events/user-update-event'; import { User } from '../_models/user'; import {DashboardUpdateEvent} from "../_models/events/dashboard-update-event"; import {SideNavUpdateEvent} from "../_models/events/sidenav-update-event"; +import {SiteThemeUpdatedEvent} from "../_models/events/site-theme-updated-event"; export enum EVENTS { UpdateAvailable = 'UpdateAvailable', @@ -98,7 +99,11 @@ export enum EVENTS { /** * User's sidenav needs to be re-rendered */ - SideNavUpdate = 'SideNavUpdate' + SideNavUpdate = 'SideNavUpdate', + /** + * A Theme was updated and UI should refresh to get the latest version + */ + SiteThemeUpdated= 'SiteThemeUpdated' } export interface Message { @@ -194,6 +199,13 @@ export class MessageHubService { }); }); + this.hubConnection.on(EVENTS.SiteThemeUpdated, resp => { + this.messagesSource.next({ + event: EVENTS.SiteThemeUpdated, + payload: resp.body as SiteThemeUpdatedEvent + }); + }); + this.hubConnection.on(EVENTS.DashboardUpdate, resp => { this.messagesSource.next({ event: EVENTS.DashboardUpdate, diff --git a/UI/Web/src/app/_services/theme.service.ts b/UI/Web/src/app/_services/theme.service.ts index 5b7e325b9..827f93b9a 100644 --- a/UI/Web/src/app/_services/theme.service.ts +++ b/UI/Web/src/app/_services/theme.service.ts @@ -1,25 +1,20 @@ -import { DOCUMENT } from '@angular/common'; -import { HttpClient } from '@angular/common/http'; -import { - DestroyRef, - inject, - Inject, - Injectable, - Renderer2, - RendererFactory2, - SecurityContext -} from '@angular/core'; -import { DomSanitizer } from '@angular/platform-browser'; -import { ToastrService } from 'ngx-toastr'; -import { map, ReplaySubject, take } from 'rxjs'; -import { environment } from 'src/environments/environment'; -import { ConfirmService } from '../shared/confirm.service'; -import { NotificationProgressEvent } from '../_models/events/notification-progress-event'; -import { SiteTheme, ThemeProvider } from '../_models/preferences/site-theme'; -import { TextResonse } from '../_types/text-response'; -import { EVENTS, MessageHubService } from './message-hub.service'; +import {DOCUMENT} from '@angular/common'; +import {HttpClient} from '@angular/common/http'; +import {DestroyRef, inject, Inject, Injectable, Renderer2, RendererFactory2, SecurityContext} from '@angular/core'; +import {DomSanitizer} from '@angular/platform-browser'; +import {ToastrService} from 'ngx-toastr'; +import {map, ReplaySubject, take} from 'rxjs'; +import {environment} from 'src/environments/environment'; +import {ConfirmService} from '../shared/confirm.service'; +import {NotificationProgressEvent} from '../_models/events/notification-progress-event'; +import {SiteTheme, ThemeProvider} from '../_models/preferences/site-theme'; +import {TextResonse} from '../_types/text-response'; +import {EVENTS, MessageHubService} from './message-hub.service'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {translate} from "@ngneat/transloco"; +import {DownloadableSiteTheme} from "../_models/theme/downloadable-site-theme"; +import {NgxFileDropEntry} from "ngx-file-drop"; +import {SiteThemeUpdatedEvent} from "../_models/events/site-theme-updated-event"; @Injectable({ @@ -52,18 +47,45 @@ export class ThemeService { messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(message => { - if (message.event !== EVENTS.NotificationProgress) return; - const notificationEvent = (message.payload as NotificationProgressEvent); - if (notificationEvent.name !== EVENTS.SiteThemeProgress) return; + if (message.event === EVENTS.NotificationProgress) { + const notificationEvent = (message.payload as NotificationProgressEvent); + if (notificationEvent.name !== EVENTS.SiteThemeProgress) return; - if (notificationEvent.eventType === 'ended') { - if (notificationEvent.name === EVENTS.SiteThemeProgress) this.getThemes().subscribe(() => { + if (notificationEvent.eventType === 'ended') { + if (notificationEvent.name === EVENTS.SiteThemeProgress) this.getThemes().subscribe(); + } + return; + } + + if (message.event === EVENTS.SiteThemeUpdated) { + const evt = (message.payload as SiteThemeUpdatedEvent); + this.currentTheme$.pipe(take(1)).subscribe(currentTheme => { + if (currentTheme && currentTheme.name !== EVENTS.SiteThemeProgress) return; + console.log('Active theme has been updated, refreshing theme'); + this.setTheme(currentTheme.name); }); } + + }); } + getDownloadableThemes() { + return this.httpClient.get>(this.baseUrl + 'theme/browse'); + } + + downloadTheme(theme: DownloadableSiteTheme) { + return this.httpClient.post(this.baseUrl + 'theme/download-theme', theme); + } + + uploadTheme(themeFile: File, fileEntry: NgxFileDropEntry) { + const formData = new FormData() + formData.append('formFile', themeFile, fileEntry.relativePath); + + return this.httpClient.post(this.baseUrl + 'theme/upload-theme', formData); + } + getColorScheme() { return getComputedStyle(this.document.body).getPropertyValue('--color-scheme').trim(); } @@ -113,6 +135,12 @@ export class ThemeService { this.unsetThemes(); } + deleteTheme(themeId: number) { + return this.httpClient.delete(this.baseUrl + 'theme?themeId=' + themeId).pipe(map(() => { + this.getThemes().subscribe(() => {}); + })); + } + setDefault(themeId: number) { return this.httpClient.post(this.baseUrl + 'theme/update-default', {themeId: themeId}).pipe(map(() => { // Refresh the cache when a default state is changed @@ -148,7 +176,7 @@ export class ThemeService { this.unsetThemes(); this.renderer.addClass(this.document.querySelector('body'), theme.selector); - if (theme.provider === ThemeProvider.User && !this.hasThemeInHead(theme.name)) { + if (theme.provider !== ThemeProvider.System && !this.hasThemeInHead(theme.name)) { // We need to load the styles into the browser this.fetchThemeContent(theme.id).subscribe(async (content) => { if (content === null) { diff --git a/UI/Web/src/app/app.component.ts b/UI/Web/src/app/app.component.ts index b1b6c576f..197208916 100644 --- a/UI/Web/src/app/app.component.ts +++ b/UI/Web/src/app/app.component.ts @@ -13,7 +13,6 @@ import { SideNavComponent } from './sidenav/_components/side-nav/side-nav.compon import {NavHeaderComponent} from "./nav/_components/nav-header/nav-header.component"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {ServerService} from "./_services/server.service"; -import {ImportCblModalComponent} from "./reading-list/_modals/import-cbl-modal/import-cbl-modal.component"; import {OutOfDateModalComponent} from "./announcements/_components/out-of-date-modal/out-of-date-modal.component"; @Component({ diff --git a/UI/Web/src/app/collections/_components/import-mal-collection-modal/import-mal-collection-modal.component.html b/UI/Web/src/app/collections/_components/import-mal-collection-modal/import-mal-collection-modal.component.html index 03a49535e..9f5845a0b 100644 --- a/UI/Web/src/app/collections/_components/import-mal-collection-modal/import-mal-collection-modal.component.html +++ b/UI/Web/src/app/collections/_components/import-mal-collection-modal/import-mal-collection-modal.component.html @@ -16,27 +16,19 @@
by {{stack.author}} • {{t('series-count', {num: stack.seriesCount | number})}} • {{t('restack-count', {num: stack.restackCount | number})}}
+ } @empty { + @if (isLoading) { + + } @else { +

{{t('nothing-found')}}

+ } } diff --git a/UI/Web/src/app/collections/_components/import-mal-collection-modal/import-mal-collection-modal.component.ts b/UI/Web/src/app/collections/_components/import-mal-collection-modal/import-mal-collection-modal.component.ts index 041cb5a35..de76916ac 100644 --- a/UI/Web/src/app/collections/_components/import-mal-collection-modal/import-mal-collection-modal.component.ts +++ b/UI/Web/src/app/collections/_components/import-mal-collection-modal/import-mal-collection-modal.component.ts @@ -10,6 +10,7 @@ import {ScrobbleProvider} from "../../../_services/scrobbling.service"; import {forkJoin} from "rxjs"; import {ToastrService} from "ngx-toastr"; import {DecimalPipe} from "@angular/common"; +import {LoadingComponent} from "../../../shared/loading/loading.component"; @Component({ selector: 'app-import-mal-collection-modal', @@ -18,7 +19,8 @@ import {DecimalPipe} from "@angular/common"; TranslocoDirective, ReactiveFormsModule, Select2Module, - DecimalPipe + DecimalPipe, + LoadingComponent ], templateUrl: './import-mal-collection-modal.component.html', styleUrl: './import-mal-collection-modal.component.scss', 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 c6e666f5c..fa493d0c8 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 @@ -1,32 +1,141 @@
-

{{t('title')}}

-
- -
+

{{t('title')}}

-

- {{t('looking-for-theme')}}{{t('looking-for-theme-continued')}} -

+

{{t('description')}}

-
-

{{t('site-themes')}}

- -
-
-
{{theme.name | sentenceCase}}
-
{{theme.provider | siteThemeProvider}}
- - -
+ +
+
+
+
    + + @for (theme of themeService.themes$ | async; track theme.name) { + + } + + @for (theme of downloadableThemes; track theme.name) { + + } +
- +
+ +
+ @if (selectedTheme === undefined) { + +
+
+
+
+ @if (hasAdmin$ | async) { + {{t('preview-default-admin')}} + } @else { + {{t('preview-default')}} + } + +
+
+
+
+ + + @if (files && files.length > 0) { + + } @else if (hasAdmin$ | async) { + + + +
+
+
+ +
+ +
+
+ {{t('drag-n-drop')}} + + {{t('upload')}} {{t('upload-continued')}} +
+
+
+
+ +
+ +
+ } + + } + @else { +

+ {{selectedTheme.name | sentenceCase}} +
+ @if (selectedTheme.isSiteTheme) { + @if (selectedTheme.name !== 'Dark') { + + } + @if (hasAdmin$ | async) { + + } + + } @else { + + } +
+

+ @if(!selectedTheme.isSiteTheme) { +

{{selectedTheme.downloadable!.description | defaultValue}}

+ + + + + + + + + } @else { +

{{selectedTheme.site!.description | defaultValue}}

+ + + + + + + + + } + } +
+ + @if (item !== undefined) { + +
  • +
    +
    {{item.name | sentenceCase}}
    + + @if (item.hasOwnProperty('provider')) { + {{item.provider | siteThemeProvider}} + } @else if (item.hasOwnProperty('lastCompatibleVersion')) { + {{ThemeProvider.Custom | siteThemeProvider}} • v{{item.lastCompatibleVersion}} + } + @if (currentTheme && item.name === currentTheme.name) { + • {{t('active-theme')}} + } +
    + @if (item.hasOwnProperty('isDefault') && item.isDefault) { + + } +
  • + } +
    +
    diff --git a/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.scss b/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.scss index e69de29bb..a333e8571 100644 --- a/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.scss +++ b/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.scss @@ -0,0 +1,27 @@ +//.theme-container { +// max-height: calc(100 * var(--vh)); +// overflow-y: auto; +//} + +.chooser { + display: grid; + grid-template-columns: repeat(auto-fill, 158px); + grid-gap: 0.5rem; + justify-content: space-around; +} + +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/theme-manager/theme-manager.component.ts b/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.ts index c145ff135..2c3055a37 100644 --- a/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.ts +++ b/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.ts @@ -6,17 +6,36 @@ import { inject, } from '@angular/core'; import { ToastrService } from 'ngx-toastr'; -import { distinctUntilChanged, take } from 'rxjs'; +import {distinctUntilChanged, map, take} from 'rxjs'; import { ThemeService } from 'src/app/_services/theme.service'; -import { SiteTheme } from 'src/app/_models/preferences/site-theme'; +import {SiteTheme, ThemeProvider} from 'src/app/_models/preferences/site-theme'; import { User } from 'src/app/_models/user'; import { AccountService } from 'src/app/_services/account.service'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import { SiteThemeProviderPipe } from '../../_pipes/site-theme-provider.pipe'; import { SentenceCasePipe } from '../../_pipes/sentence-case.pipe'; -import { NgIf, NgFor, AsyncPipe } from '@angular/common'; -import {TranslocoDirective, TranslocoService} from "@ngneat/transloco"; -import {tap} from "rxjs/operators"; +import {NgIf, NgFor, AsyncPipe, NgTemplateOutlet} from '@angular/common'; +import {translate, TranslocoDirective} from "@ngneat/transloco"; +import {shareReplay} from "rxjs/operators"; +import {CarouselReelComponent} from "../../carousel/_components/carousel-reel/carousel-reel.component"; +import {SeriesCardComponent} from "../../cards/series-card/series-card.component"; +import {ImageComponent} from "../../shared/image/image.component"; +import {DownloadableSiteTheme} from "../../_models/theme/downloadable-site-theme"; +import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; +import {SafeUrlPipe} from "../../_pipes/safe-url.pipe"; +import {ScrobbleProvider} from "../../_services/scrobbling.service"; +import {ConfirmService} from "../../shared/confirm.service"; +import {FileSystemFileEntry, NgxFileDropEntry, NgxFileDropModule} from "ngx-file-drop"; +import {ReactiveFormsModule} from "@angular/forms"; +import {Select2Module} from "ng-select2-component"; +import {LoadingComponent} from "../../shared/loading/loading.component"; + +interface ThemeContainer { + downloadable?: DownloadableSiteTheme; + site?: SiteTheme; + isSiteTheme: boolean; + name: string; +} @Component({ selector: 'app-theme-manager', @@ -24,51 +43,125 @@ import {tap} from "rxjs/operators"; styleUrls: ['./theme-manager.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [NgIf, NgFor, AsyncPipe, SentenceCasePipe, SiteThemeProviderPipe, TranslocoDirective] + imports: [NgIf, NgFor, AsyncPipe, SentenceCasePipe, SiteThemeProviderPipe, TranslocoDirective, CarouselReelComponent, SeriesCardComponent, ImageComponent, DefaultValuePipe, NgTemplateOutlet, SafeUrlPipe, NgxFileDropModule, ReactiveFormsModule, Select2Module, LoadingComponent] }) export class ThemeManagerComponent { + private readonly destroyRef = inject(DestroyRef); + protected readonly themeService = inject(ThemeService); + private readonly accountService = inject(AccountService); + private readonly toastr = inject(ToastrService); + private readonly cdRef = inject(ChangeDetectorRef); + private readonly confirmService = inject(ConfirmService); + + protected readonly ThemeProvider = ThemeProvider; + protected readonly ScrobbleProvider = ScrobbleProvider; currentTheme: SiteTheme | undefined; - isAdmin: boolean = false; user: User | undefined; - private readonly destroyRef = inject(DestroyRef); - private readonly translocService = inject(TranslocoService); + selectedTheme: ThemeContainer | undefined; + downloadableThemes: Array = []; + hasAdmin$ = this.accountService.currentUser$.pipe( + takeUntilDestroyed(this.destroyRef), shareReplay({refCount: true, bufferSize: 1}), + map(c => c && this.accountService.hasAdminRole(c)) + ); + + files: NgxFileDropEntry[] = []; + acceptableExtensions = ['.css'].join(','); + isUploadingTheme: boolean = false; - constructor(public themeService: ThemeService, private accountService: AccountService, - private toastr: ToastrService, private readonly cdRef: ChangeDetectorRef) { - themeService.currentTheme$.pipe(takeUntilDestroyed(this.destroyRef), distinctUntilChanged()).subscribe(theme => { + constructor() { + + this.loadDownloadableThemes(); + + this.themeService.currentTheme$.pipe(takeUntilDestroyed(this.destroyRef), distinctUntilChanged()).subscribe(theme => { this.currentTheme = theme; this.cdRef.markForCheck(); }); - accountService.currentUser$.pipe(take(1)).subscribe(user => { - if (user) { - this.user = user; - this.isAdmin = accountService.hasAdminRole(user); - this.cdRef.markForCheck(); - } + } + + loadDownloadableThemes() { + this.themeService.getDownloadableThemes().subscribe(d => { + this.downloadableThemes = d; + this.cdRef.markForCheck(); }); } - applyTheme(theme: SiteTheme) { - if (!this.user) return; + async deleteTheme(theme: SiteTheme) { + if (!await this.confirmService.confirm(translate('toasts.confirm-delete-theme'))) { + return; + } - const pref = Object.assign({}, this.user.preferences); - pref.theme = theme; - this.accountService.updatePreferences(pref).subscribe(); + this.themeService.deleteTheme(theme.id).subscribe(_ => { + this.removeDownloadedTheme(theme); + this.loadDownloadableThemes(); + }); + } + + removeDownloadedTheme(theme: SiteTheme) { + this.selectedTheme = undefined; + this.downloadableThemes = this.downloadableThemes.filter(d => d.name !== theme.name); + this.cdRef.markForCheck(); + } + + applyTheme(theme: SiteTheme) { + this.accountService.currentUser$.pipe(take(1)).subscribe(user => { + if (!user) return; + const pref = Object.assign({}, user.preferences); + pref.theme = theme; + this.accountService.updatePreferences(pref).subscribe(); + // Updating theme emits the new theme to load on the themes$ + + }); } updateDefault(theme: SiteTheme) { this.themeService.setDefault(theme.id).subscribe(() => { - this.toastr.success(this.translocService.translate('theme-manager.updated-toastr', {name: theme.name})); + this.toastr.success(translate('theme-manager.updated-toastr', {name: theme.name})); }); } - scan() { - this.themeService.scan().subscribe(() => { - this.toastr.info(this.translocService.translate('theme-manager.scan-queued')); + selectTheme(theme: SiteTheme | DownloadableSiteTheme) { + if (theme.hasOwnProperty('provider')) { + this.selectedTheme = { + isSiteTheme: true, + site: theme as SiteTheme, + name: theme.name + }; + } else { + this.selectedTheme = { + isSiteTheme: false, + downloadable: theme as DownloadableSiteTheme, + name: theme.name + }; + } + + this.cdRef.markForCheck(); + } + + downloadTheme(theme: DownloadableSiteTheme) { + this.themeService.downloadTheme(theme).subscribe(theme => { + this.removeDownloadedTheme(theme); }); } + + public dropped(files: NgxFileDropEntry[]) { + this.files = files; + for (const droppedFile of files) { + // Is it a file? + if (droppedFile.fileEntry.isFile) { + const fileEntry = droppedFile.fileEntry as FileSystemFileEntry; + fileEntry.file((file: File) => { + this.themeService.uploadTheme(file, droppedFile).subscribe(t => { + this.isUploadingTheme = false; + this.cdRef.markForCheck(); + }); + }); + } + } + this.isUploadingTheme = true; + this.cdRef.markForCheck(); + } } diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index 78a055278..1e6e61bc8 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -182,15 +182,22 @@ "theme-manager": { "title": "Theme Manager", - "looking-for-theme": "Looking for a light or e-ink theme? We have some custom themes you can use on our ", - "looking-for-theme-continued": "theme github.", - "scan": "Scan", + "description": "Kavita comes in my colors, find a color scheme that meets your needs or build one yourself and share it. Themes may be applied for your account or applied to all accounts.", "site-themes": "Site Themes", "set-default": "Set Default", + "default-theme": "Default theme", + "download": "{{changelog.download}}", "apply": "{{common.apply}}", "applied": "Applied", + "active-theme": "Active", "updated-toastr": "Site default has been updated to {{name}}", - "scan-queued": "A site theme scan has been queued" + "scan-queued": "A site theme scan has been queued", + "delete": "{{common.delete}}", + "drag-n-drop": "{{cover-image-chooser.drag-n-drop}}", + "upload": "{{cover-image-chooser.upload}}", + "upload-continued": "a css file", + "preview-default": "Select a theme first", + "preview-default-admin": "Select a theme first or upload one manually" }, "theme": { @@ -212,7 +219,7 @@ "site-theme-provider-pipe": { "system": "System", - "user": "User" + "custom": "{{device-platform-pipe.custom}}" }, "manage-devices": { @@ -1583,14 +1590,17 @@ "promote-tooltip": "Promotion means that the collection can be seen server-wide, not just for you. All series within this collection will still have user-access restrictions placed on them." }, - + "browse-themes-modal": { + "title": "Browse Themes" + }, "import-mal-collection-modal": { "close": "{{common.close}}", "title": "MAL Interest Stack Import", "description": "Import your MAL Interest Stacks and create Collections within Kavita", "series-count": "{{common.series-count}}", - "restack-count": "{{num}} Restacks" + "restack-count": "{{num}} Restacks", + "nothing-found": "" }, "edit-chapter-progress": { @@ -1946,7 +1956,10 @@ "rejected-cover-upload": "The image could not be fetched due to server refusing request. Please download and upload from file instead.", "invalid-confirmation-url": "Invalid confirmation url", "invalid-confirmation-email": "Invalid confirmation email", - "invalid-password-reset-url": "Invalid reset password url" + "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" }, "metadata-builder": { @@ -2185,7 +2198,8 @@ "confirm-delete-collections": "Are you sure you want to delete multiple collections?", "collections-deleted": "Collections deleted", "pdf-book-mode-screen-size": "Screen too small for Book mode", - "stack-imported": "Stack Imported" + "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" }, diff --git a/openapi.json b/openapi.json index 7c21d4a6d..2315d6b7d 100644 --- a/openapi.json +++ b/openapi.json @@ -7,7 +7,7 @@ "name": "GPL-3.0", "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" }, - "version": "0.8.1.0" + "version": "0.8.1.3" }, "servers": [ { @@ -11968,16 +11968,52 @@ } } } - } - }, - "/api/Theme/scan": { - "post": { + }, + "delete": { "tags": [ "Theme" ], + "summary": "Attempts to delete a theme. If already in use by users, will not allow", + "parameters": [ + { + "name": "themeId", + "in": "query", + "description": "", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], "responses": { "200": { - "description": "Success" + "description": "Success", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DownloadableSiteThemeDto" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DownloadableSiteThemeDto" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DownloadableSiteThemeDto" + } + } + } + } } } } @@ -12053,6 +12089,145 @@ } } }, + "/api/Theme/browse": { + "get": { + "tags": [ + "Theme" + ], + "summary": "Browse themes that can be used on this server", + "responses": { + "200": { + "description": "Success", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DownloadableSiteThemeDto" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DownloadableSiteThemeDto" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DownloadableSiteThemeDto" + } + } + } + } + } + } + } + }, + "/api/Theme/download-theme": { + "post": { + "tags": [ + "Theme" + ], + "summary": "Downloads a SiteTheme from upstream", + "requestBody": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DownloadableSiteThemeDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/DownloadableSiteThemeDto" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/DownloadableSiteThemeDto" + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/SiteThemeDto" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/SiteThemeDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/SiteThemeDto" + } + } + } + } + } + } + }, + "/api/Theme/upload-theme": { + "post": { + "tags": [ + "Theme" + ], + "summary": "Uploads a new theme file", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "formFile": { + "type": "string", + "format": "binary" + } + } + }, + "encoding": { + "formFile": { + "style": "form" + } + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/SiteThemeDto" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/SiteThemeDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/SiteThemeDto" + } + } + } + } + } + } + }, "/api/Upload/upload-by-url": { "post": { "tags": [ @@ -15777,6 +15952,67 @@ }, "additionalProperties": false }, + "DownloadableSiteThemeDto": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Theme Name", + "nullable": true + }, + "cssUrl": { + "type": "string", + "description": "Url to download css file", + "nullable": true + }, + "cssFile": { + "type": "string", + "nullable": true + }, + "previewUrls": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Url to preview image", + "nullable": true + }, + "alreadyDownloaded": { + "type": "boolean", + "description": "If Already downloaded" + }, + "sha": { + "type": "string", + "description": "Sha of the file", + "nullable": true + }, + "path": { + "type": "string", + "description": "Path of the Folder the files reside in", + "nullable": true + }, + "author": { + "type": "string", + "description": "Author of the theme", + "nullable": true + }, + "lastCompatibleVersion": { + "type": "string", + "description": "Last version tested against", + "nullable": true + }, + "isCompatible": { + "type": "boolean", + "description": "If version compatible with version" + }, + "description": { + "type": "string", + "description": "Small blurb about the Theme", + "nullable": true + } + }, + "additionalProperties": false + }, "EmailTestResultDto": { "type": "object", "properties": { @@ -20117,6 +20353,33 @@ "lastModifiedUtc": { "type": "string", "format": "date-time" + }, + "gitHubPath": { + "type": "string", + "description": "The Url on the repo to download the file from", + "nullable": true + }, + "shaHash": { + "type": "string", + "description": "Hash of the Css File", + "nullable": true + }, + "previewUrls": { + "type": "string", + "description": "Pipe (|) separated urls of the images. Empty string if", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "author": { + "type": "string", + "nullable": true + }, + "compatibleVersion": { + "type": "string", + "nullable": true } }, "additionalProperties": false, @@ -20157,6 +20420,28 @@ "description": "Where did the theme come from", "format": "int32" }, + "previewUrls": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "description": { + "type": "string", + "description": "Information about the theme", + "nullable": true + }, + "author": { + "type": "string", + "description": "Author of the Theme (only applies to non-system provided themes)", + "nullable": true + }, + "compatibleVersion": { + "type": "string", + "description": "Last compatible version. System provided will always be most current", + "nullable": true + }, "selector": { "type": "string", "nullable": true, @@ -20239,7 +20524,8 @@ 5, 6, 7, - 8 + 8, + 9 ], "type": "integer", "format": "int32" @@ -21303,7 +21589,7 @@ "format": "int32" }, "theme": { - "$ref": "#/components/schemas/SiteTheme" + "$ref": "#/components/schemas/SiteThemeDto" }, "bookReaderThemeName": { "minLength": 1,