diff --git a/.gitignore b/.gitignore index 8bc302ff8..c8d68977f 100644 --- a/.gitignore +++ b/.gitignore @@ -510,11 +510,13 @@ UI/Web/dist/ /API/config/backups/ /API/config/cache/ /API/config/temp/ +/API/config/themes/ /API/config/stats/ /API/config/bookmarks/ /API/config/kavita.db /API/config/kavita.db-shm /API/config/kavita.db-wal +/API/config/kavita.db-journal /API/config/Hangfire.db /API/config/Hangfire-log.db API/config/covers/ diff --git a/API.Tests/Services/SiteThemeServiceTests.cs b/API.Tests/Services/SiteThemeServiceTests.cs new file mode 100644 index 000000000..a9198f26f --- /dev/null +++ b/API.Tests/Services/SiteThemeServiceTests.cs @@ -0,0 +1,264 @@ +using System.Collections.Generic; +using System.Data.Common; +using System.IO.Abstractions.TestingHelpers; +using System.Linq; +using System.Threading.Tasks; +using API.Data; +using API.Entities; +using API.Entities.Enums; +using API.Entities.Enums.Theme; +using API.Helpers; +using API.Services; +using API.Services.Tasks; +using API.SignalR; +using AutoMapper; +using Kavita.Common; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace API.Tests.Services; + +public class SiteThemeServiceTests +{ + private readonly ILogger _logger = Substitute.For>(); + private readonly IHubContext _messageHub = Substitute.For>(); + + private readonly DbConnection _connection; + private readonly DataContext _context; + private readonly IUnitOfWork _unitOfWork; + + private const string CacheDirectory = "C:/kavita/config/cache/"; + private const string CoverImageDirectory = "C:/kavita/config/covers/"; + private const string BackupDirectory = "C:/kavita/config/backups/"; + private const string BookmarkDirectory = "C:/kavita/config/bookmarks/"; + private const string SiteThemeDirectory = "C:/kavita/config/themes/"; + + public SiteThemeServiceTests() + { + var contextOptions = new DbContextOptionsBuilder() + .UseSqlite(CreateInMemoryDatabase()) + .Options; + _connection = RelationalOptionsExtension.Extract(contextOptions).Connection; + + _context = new DataContext(contextOptions); + Task.Run(SeedDb).GetAwaiter().GetResult(); + + var config = new MapperConfiguration(cfg => cfg.AddProfile()); + var mapper = config.CreateMapper(); + _unitOfWork = new UnitOfWork(_context, mapper, null); + } + + #region Setup + + private static DbConnection CreateInMemoryDatabase() + { + var connection = new SqliteConnection("Filename=:memory:"); + + connection.Open(); + + return connection; + } + + private async Task SeedDb() + { + await _context.Database.MigrateAsync(); + var filesystem = CreateFileSystem(); + + await Seed.SeedSettings(_context, new DirectoryService(Substitute.For>(), filesystem)); + + var setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.CacheDirectory).SingleAsync(); + setting.Value = CacheDirectory; + + setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BackupDirectory).SingleAsync(); + setting.Value = BackupDirectory; + + setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BookmarkDirectory).SingleAsync(); + setting.Value = BookmarkDirectory; + + _context.ServerSetting.Update(setting); + + _context.AppUser.Add(new AppUser() + { + UserName = "Joe", + UserPreferences = new AppUserPreferences + { + Theme = Seed.DefaultThemes[1] + } + }); + + _context.Library.Add(new Library() + { + Name = "Manga", + Folders = new List() + { + new FolderPath() + { + Path = "C:/data/" + } + } + }); + return await _context.SaveChangesAsync() > 0; + } + + private static MockFileSystem CreateFileSystem() + { + var fileSystem = new MockFileSystem(); + fileSystem.Directory.SetCurrentDirectory("C:/kavita/"); + fileSystem.AddDirectory("C:/kavita/config/"); + fileSystem.AddDirectory(CacheDirectory); + fileSystem.AddDirectory(CoverImageDirectory); + fileSystem.AddDirectory(BackupDirectory); + fileSystem.AddDirectory(BookmarkDirectory); + fileSystem.AddDirectory(SiteThemeDirectory); + fileSystem.AddDirectory("C:/data/"); + + return fileSystem; + } + + private async Task ResetDb() + { + _context.SiteTheme.RemoveRange(_context.SiteTheme); + await _context.SaveChangesAsync(); + } + + #endregion + + [Fact] + public async Task Scan_ShouldFindCustomFile() + { + await ResetDb(); + var filesystem = CreateFileSystem(); + filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("")); + var ds = new DirectoryService(Substitute.For>(), filesystem); + var siteThemeService = new SiteThemeService(ds, _unitOfWork, _messageHub); + await siteThemeService.Scan(); + + Assert.NotNull(await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("custom")); + } + + [Fact] + public async Task Scan_ShouldOnlyInsertOnceOnSecondScan() + { + await ResetDb(); + var filesystem = CreateFileSystem(); + filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("")); + var ds = new DirectoryService(Substitute.For>(), filesystem); + var siteThemeService = new SiteThemeService(ds, _unitOfWork, _messageHub); + await siteThemeService.Scan(); + + Assert.NotNull(await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("custom")); + + await siteThemeService.Scan(); + + var customThemes = (await _unitOfWork.SiteThemeRepository.GetThemeDtos()).Where(t => + API.Parser.Parser.Normalize(t.Name).Equals(API.Parser.Parser.Normalize("custom"))); + Assert.Single(customThemes); + } + + [Fact] + public async Task Scan_ShouldDeleteWhenFileDoesntExistOnSecondScan() + { + await ResetDb(); + var filesystem = CreateFileSystem(); + filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("")); + var ds = new DirectoryService(Substitute.For>(), filesystem); + var siteThemeService = new SiteThemeService(ds, _unitOfWork, _messageHub); + await siteThemeService.Scan(); + + Assert.NotNull(await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("custom")); + + filesystem.RemoveFile($"{SiteThemeDirectory}custom.css"); + await siteThemeService.Scan(); + + var customThemes = (await _unitOfWork.SiteThemeRepository.GetThemeDtos()).Where(t => + API.Parser.Parser.Normalize(t.Name).Equals(API.Parser.Parser.Normalize("custom"))); + + Assert.Empty(customThemes); + } + + [Fact] + public async Task GetContent_ShouldReturnContent() + { + await ResetDb(); + var filesystem = CreateFileSystem(); + filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123")); + var ds = new DirectoryService(Substitute.For>(), filesystem); + var siteThemeService = new SiteThemeService(ds, _unitOfWork, _messageHub); + + _context.SiteTheme.Add(new SiteTheme() + { + Name = "Custom", + NormalizedName = API.Parser.Parser.Normalize("Custom"), + Provider = ThemeProvider.User, + FileName = "custom.css", + IsDefault = false + }); + await _context.SaveChangesAsync(); + + var content = await siteThemeService.GetContent((await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("Custom")).Id); + Assert.NotNull(content); + Assert.NotEmpty(content); + Assert.Equal("123", content); + } + + [Fact] + public async Task UpdateDefault_ShouldHaveOneDefault() + { + await ResetDb(); + var filesystem = CreateFileSystem(); + filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123")); + var ds = new DirectoryService(Substitute.For>(), filesystem); + var siteThemeService = new SiteThemeService(ds, _unitOfWork, _messageHub); + + _context.SiteTheme.Add(new SiteTheme() + { + Name = "Custom", + NormalizedName = API.Parser.Parser.Normalize("Custom"), + Provider = ThemeProvider.User, + FileName = "custom.css", + IsDefault = false + }); + await _context.SaveChangesAsync(); + + var customTheme = (await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("Custom")); + + await siteThemeService.UpdateDefault(customTheme.Id); + + + + Assert.Equal(customTheme.Id, (await _unitOfWork.SiteThemeRepository.GetDefaultTheme()).Id); + } + + [Fact] + public async Task UpdateDefault_ShouldThrowOnInvalidId() + { + await ResetDb(); + var filesystem = CreateFileSystem(); + filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123")); + var ds = new DirectoryService(Substitute.For>(), filesystem); + var siteThemeService = new SiteThemeService(ds, _unitOfWork, _messageHub); + + _context.SiteTheme.Add(new SiteTheme() + { + Name = "Custom", + NormalizedName = API.Parser.Parser.Normalize("Custom"), + Provider = ThemeProvider.User, + FileName = "custom.css", + IsDefault = false + }); + await _context.SaveChangesAsync(); + + + + var ex = await Assert.ThrowsAsync(async () => await siteThemeService.UpdateDefault(10)); + Assert.Equal("Theme file missing or invalid", ex.Message); + + } + + +} diff --git a/API/API.csproj b/API/API.csproj index 42e0d1107..a95863aa7 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -310,4 +310,8 @@ + + + + diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index 9765f700e..913f53133 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -106,7 +106,10 @@ namespace API.Controllers { UserName = registerDto.Username, Email = registerDto.Email, - UserPreferences = new AppUserPreferences(), + UserPreferences = new AppUserPreferences + { + Theme = await _unitOfWork.SiteThemeRepository.GetDefaultTheme() + }, ApiKey = HashUtil.ApiKey() }; @@ -179,22 +182,23 @@ namespace API.Controllers // Update LastActive on account user.LastActive = DateTime.Now; - user.UserPreferences ??= new AppUserPreferences(); + user.UserPreferences ??= new AppUserPreferences + { + Theme = await _unitOfWork.SiteThemeRepository.GetDefaultTheme() + }; _unitOfWork.UserRepository.Update(user); await _unitOfWork.CommitAsync(); _logger.LogInformation("{UserName} logged in at {Time}", user.UserName, user.LastActive); - return new UserDto - { - Username = user.UserName, - Email = user.Email, - Token = await _tokenService.CreateToken(user), - RefreshToken = await _tokenService.CreateRefreshToken(user), - ApiKey = user.ApiKey, - Preferences = _mapper.Map(user.UserPreferences) - }; + var dto = _mapper.Map(user); + dto.Token = await _tokenService.CreateToken(user); + dto.RefreshToken = await _tokenService.CreateRefreshToken(user); + var pref = await _unitOfWork.UserRepository.GetPreferencesAsync(user.UserName); + pref.Theme ??= await _unitOfWork.SiteThemeRepository.GetDefaultTheme(); + dto.Preferences = _mapper.Map(pref); + return dto; } [HttpPost("refresh-token")] @@ -358,7 +362,10 @@ namespace API.Controllers UserName = dto.Email, Email = dto.Email, ApiKey = HashUtil.ApiKey(), - UserPreferences = new AppUserPreferences() + UserPreferences = new AppUserPreferences + { + Theme = await _unitOfWork.SiteThemeRepository.GetDefaultTheme() + } }; try diff --git a/API/Controllers/ThemeController.cs b/API/Controllers/ThemeController.cs new file mode 100644 index 000000000..f6775d2dc --- /dev/null +++ b/API/Controllers/ThemeController.cs @@ -0,0 +1,64 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using API.Data; +using API.DTOs.Theme; +using API.Services; +using API.Services.Tasks; +using Kavita.Common; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace API.Controllers; + +public class ThemeController : BaseApiController +{ + private readonly IUnitOfWork _unitOfWork; + private readonly ISiteThemeService _siteThemeService; + private readonly ITaskScheduler _taskScheduler; + + public ThemeController(IUnitOfWork unitOfWork, ISiteThemeService siteThemeService, ITaskScheduler taskScheduler) + { + _unitOfWork = unitOfWork; + _siteThemeService = siteThemeService; + _taskScheduler = taskScheduler; + } + + [HttpGet] + public async Task>> GetThemes() + { + return Ok(await _unitOfWork.SiteThemeRepository.GetThemeDtos()); + } + + [Authorize("RequireAdminRole")] + [HttpPost("scan")] + public ActionResult Scan() + { + _taskScheduler.ScanSiteThemes(); + return Ok(); + } + + [Authorize("RequireAdminRole")] + [HttpPost("update-default")] + public async Task UpdateDefault(UpdateDefaultSiteThemeDto dto) + { + await _siteThemeService.UpdateDefault(dto.ThemeId); + return Ok(); + } + + /// + /// Returns css content to the UI. UI is expected to escape the content + /// + /// + [HttpGet("download-content")] + public async Task> GetThemeContent(int themeId) + { + try + { + return Ok(await _siteThemeService.GetContent(themeId)); + } + catch (KavitaException ex) + { + return BadRequest(ex.Message); + } + } +} diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs index dd6e975ab..3569aca2a 100644 --- a/API/Controllers/UsersController.cs +++ b/API/Controllers/UsersController.cs @@ -78,7 +78,8 @@ namespace API.Controllers existingPreferences.BookReaderDarkMode = preferencesDto.BookReaderDarkMode; existingPreferences.BookReaderFontSize = preferencesDto.BookReaderFontSize; existingPreferences.BookReaderTapToPaginate = preferencesDto.BookReaderTapToPaginate; - existingPreferences.SiteDarkMode = preferencesDto.SiteDarkMode; + existingPreferences.BookReaderReadingDirection = preferencesDto.BookReaderReadingDirection; + existingPreferences.Theme = await _unitOfWork.SiteThemeRepository.GetThemeById(preferencesDto.Theme.Id); _unitOfWork.UserRepository.Update(existingPreferences); diff --git a/API/DTOs/Theme/SiteThemeDto.cs b/API/DTOs/Theme/SiteThemeDto.cs new file mode 100644 index 000000000..e8b0460f9 --- /dev/null +++ b/API/DTOs/Theme/SiteThemeDto.cs @@ -0,0 +1,30 @@ +using System; +using API.Entities.Enums.Theme; +using API.Services; + +namespace API.DTOs.Theme; + +public class SiteThemeDto +{ + public int Id { get; set; } + /// + /// Name of the Theme + /// + public string Name { get; set; } + /// + /// File path to the content. Stored under . + /// Must be a .css file + /// + public string FileName { get; set; } + /// + /// Only one theme can have this. Will auto-set this as default for new user accounts + /// + public bool IsDefault { get; set; } + /// + /// Where did the theme come from + /// + public ThemeProvider Provider { get; set; } + public DateTime Created { get; set; } + public DateTime LastModified { get; set; } + public string Selector => "bg-" + Name.ToLower(); +} diff --git a/API/DTOs/Theme/UpdateDefaultSiteThemeDto.cs b/API/DTOs/Theme/UpdateDefaultSiteThemeDto.cs new file mode 100644 index 000000000..d4bdb8e09 --- /dev/null +++ b/API/DTOs/Theme/UpdateDefaultSiteThemeDto.cs @@ -0,0 +1,6 @@ +namespace API.DTOs.Theme; + +public class UpdateDefaultSiteThemeDto +{ + public int ThemeId { get; set; } +} diff --git a/API/DTOs/UserDto.cs b/API/DTOs/UserDto.cs index 7a7a234e7..dc6fc8b43 100644 --- a/API/DTOs/UserDto.cs +++ b/API/DTOs/UserDto.cs @@ -5,8 +5,8 @@ namespace API.DTOs { public string Username { get; init; } public string Email { get; init; } - public string Token { get; init; } - public string RefreshToken { get; init; } + public string Token { get; set; } + public string RefreshToken { get; set; } public string ApiKey { get; init; } public UserPreferencesDto Preferences { get; set; } } diff --git a/API/DTOs/UserPreferencesDto.cs b/API/DTOs/UserPreferencesDto.cs index c36c9d146..6881cd0ae 100644 --- a/API/DTOs/UserPreferencesDto.cs +++ b/API/DTOs/UserPreferencesDto.cs @@ -1,4 +1,5 @@ -using API.Entities.Enums; +using API.Entities; +using API.Entities.Enums; namespace API.DTOs { @@ -16,6 +17,6 @@ namespace API.DTOs public string BookReaderFontFamily { get; set; } public bool BookReaderTapToPaginate { get; set; } public ReadingDirection BookReaderReadingDirection { get; set; } - public bool SiteDarkMode { get; set; } + public SiteTheme Theme { get; set; } } } diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index c1e100d48..6822467a8 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -40,6 +40,7 @@ namespace API.Data public DbSet Person { get; set; } public DbSet Genre { get; set; } public DbSet Tag { get; set; } + public DbSet SiteTheme { get; set; } protected override void OnModelCreating(ModelBuilder builder) diff --git a/API/Data/Migrations/20220215163317_SiteTheme.Designer.cs b/API/Data/Migrations/20220215163317_SiteTheme.Designer.cs new file mode 100644 index 000000000..43b538c9a --- /dev/null +++ b/API/Data/Migrations/20220215163317_SiteTheme.Designer.cs @@ -0,0 +1,1391 @@ +// +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("20220215163317_SiteTheme")] + partial class SiteTheme + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.1"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BookReaderDarkMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.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.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId", "Format") + .IsUnique(); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20220215163317_SiteTheme.cs b/API/Data/Migrations/20220215163317_SiteTheme.cs new file mode 100644 index 000000000..e2f519f8b --- /dev/null +++ b/API/Data/Migrations/20220215163317_SiteTheme.cs @@ -0,0 +1,79 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class SiteTheme : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "SiteDarkMode", + table: "AppUserPreferences"); + + migrationBuilder.AddColumn( + name: "ThemeId", + table: "AppUserPreferences", + type: "INTEGER", + nullable: true); + + migrationBuilder.CreateTable( + name: "SiteTheme", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", nullable: true), + NormalizedName = table.Column(type: "TEXT", nullable: true), + FileName = table.Column(type: "TEXT", nullable: true), + IsDefault = table.Column(type: "INTEGER", nullable: false), + Provider = table.Column(type: "INTEGER", nullable: false), + Created = table.Column(type: "TEXT", nullable: false), + LastModified = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SiteTheme", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_AppUserPreferences_ThemeId", + table: "AppUserPreferences", + column: "ThemeId"); + + migrationBuilder.AddForeignKey( + name: "FK_AppUserPreferences_SiteTheme_ThemeId", + table: "AppUserPreferences", + column: "ThemeId", + principalTable: "SiteTheme", + principalColumn: "Id"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_AppUserPreferences_SiteTheme_ThemeId", + table: "AppUserPreferences"); + + migrationBuilder.DropTable( + name: "SiteTheme"); + + migrationBuilder.DropIndex( + name: "IX_AppUserPreferences_ThemeId", + table: "AppUserPreferences"); + + migrationBuilder.DropColumn( + name: "ThemeId", + table: "AppUserPreferences"); + + migrationBuilder.AddColumn( + name: "SiteDarkMode", + table: "AppUserPreferences", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index c0aefbcd2..ff8d50df9 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -15,7 +15,7 @@ namespace API.Data.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "6.0.0"); + modelBuilder.HasAnnotation("ProductVersion", "6.0.1"); modelBuilder.Entity("API.Entities.AppRole", b => { @@ -198,7 +198,7 @@ namespace API.Data.Migrations b.Property("ScalingOption") .HasColumnType("INTEGER"); - b.Property("SiteDarkMode") + b.Property("ThemeId") .HasColumnType("INTEGER"); b.HasKey("Id"); @@ -206,6 +206,8 @@ namespace API.Data.Migrations b.HasIndex("AppUserId") .IsUnique(); + b.HasIndex("ThemeId"); + b.ToTable("AppUserPreferences"); }); @@ -687,6 +689,38 @@ namespace API.Data.Migrations b.ToTable("ServerSetting"); }); + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + modelBuilder.Entity("API.Entities.Tag", b => { b.Property("Id") @@ -967,7 +1001,13 @@ namespace API.Data.Migrations .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 => diff --git a/API/Data/Repositories/SiteThemeRepository.cs b/API/Data/Repositories/SiteThemeRepository.cs new file mode 100644 index 000000000..a95fcda23 --- /dev/null +++ b/API/Data/Repositories/SiteThemeRepository.cs @@ -0,0 +1,107 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs.Theme; +using API.Entities; +using AutoMapper; +using AutoMapper.QueryableExtensions; +using Microsoft.EntityFrameworkCore; + +namespace API.Data.Repositories; + +public interface ISiteThemeRepository +{ + void Add(SiteTheme theme); + void Remove(SiteTheme theme); + void Update(SiteTheme siteTheme); + Task> GetThemeDtos(); + Task GetThemeDto(int themeId); + Task GetThemeDtoByName(string themeName); + Task GetDefaultTheme(); + Task> GetThemes(); + + Task GetThemeById(int themeId); +} + +public class SiteThemeRepository : ISiteThemeRepository +{ + private readonly DataContext _context; + private readonly IMapper _mapper; + + public SiteThemeRepository(DataContext context, IMapper mapper) + { + _context = context; + _mapper = mapper; + } + + public void Add(SiteTheme theme) + { + _context.Add(theme); + } + + public void Remove(SiteTheme theme) + { + _context.Remove(theme); + } + + public void Update(SiteTheme siteTheme) + { + _context.Entry(siteTheme).State = EntityState.Modified; + } + + public async Task> GetThemeDtos() + { + return await _context.SiteTheme + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } + + public async Task GetThemeDtoByName(string themeName) + { + return await _context.SiteTheme + .Where(t => t.Name.Equals(themeName)) + .ProjectTo(_mapper.ConfigurationProvider) + .SingleOrDefaultAsync(); + } + + /// + /// Returns default theme, if the default theme is not available, returns the dark theme + /// + /// + public async Task GetDefaultTheme() + { + var result = await _context.SiteTheme + .Where(t => t.IsDefault) + .SingleOrDefaultAsync(); + + if (result == null) + { + return await _context.SiteTheme + .Where(t => t.NormalizedName == "dark") + .SingleOrDefaultAsync(); + } + + return result; + } + + public async Task> GetThemes() + { + return await _context.SiteTheme + .ToListAsync(); + } + + public async Task GetThemeById(int themeId) + { + return await _context.SiteTheme + .Where(t => t.Id == themeId) + .SingleOrDefaultAsync(); + } + + public async Task GetThemeDto(int themeId) + { + return await _context.SiteTheme + .Where(t => t.Id == themeId) + .ProjectTo(_mapper.ConfigurationProvider) + .SingleOrDefaultAsync(); + } +} diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index b926abe9c..e41849c92 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -55,6 +55,7 @@ public interface IUserRepository Task GetUserByEmailAsync(string email); Task> GetAllUsers(); + Task> GetAllPreferencesByThemeAsync(int themeId); } public class UserRepository : IUserRepository @@ -227,6 +228,15 @@ public class UserRepository : IUserRepository return await _context.AppUser.ToListAsync(); } + public async Task> GetAllPreferencesByThemeAsync(int themeId) + { + return await _context.AppUserPreferences + .Include(p => p.Theme) + .Where(p => p.Theme.Id == themeId) + .AsSplitQuery() + .ToListAsync(); + } + public async Task> GetAdminUsersAsync() { return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole); @@ -244,7 +254,8 @@ public class UserRepository : IUserRepository public async Task GetUserRatingAsync(int seriesId, int userId) { - return await _context.AppUserRating.Where(r => r.SeriesId == seriesId && r.AppUserId == userId) + return await _context.AppUserRating + .Where(r => r.SeriesId == seriesId && r.AppUserId == userId) .SingleOrDefaultAsync(); } @@ -252,6 +263,8 @@ public class UserRepository : IUserRepository { return await _context.AppUserPreferences .Include(p => p.AppUser) + .Include(p => p.Theme) + .AsSplitQuery() .SingleOrDefaultAsync(p => p.AppUser.UserName == username); } diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index 3dd8ecc5f..071d1eaae 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Collections.Immutable; using System.IO; using System.Linq; using System.Reflection; @@ -6,6 +7,7 @@ using System.Threading.Tasks; using API.Constants; using API.Entities; using API.Entities.Enums; +using API.Entities.Enums.Theme; using API.Services; using Kavita.Common; using Kavita.Common.EnvironmentInfo; @@ -21,6 +23,34 @@ namespace API.Data /// public static IList DefaultSettings; + public static readonly IList DefaultThemes = new List + { + new() + { + Name = "Dark", + NormalizedName = Parser.Parser.Normalize("Dark"), + Provider = ThemeProvider.System, + FileName = "dark.scss", + IsDefault = true, + }, + new() + { + Name = "Light", + NormalizedName = Parser.Parser.Normalize("Light"), + Provider = ThemeProvider.System, + FileName = "light.scss", + IsDefault = false, + }, + new() + { + Name = "E-Ink", + NormalizedName = Parser.Parser.Normalize("E-Ink"), + Provider = ThemeProvider.System, + FileName = "eink.scss", + IsDefault = false, + }, + }; + public static async Task SeedRoles(RoleManager roleManager) { var roles = typeof(PolicyConstants) @@ -41,6 +71,22 @@ namespace API.Data } } + public static async Task SeedThemes(DataContext context) + { + await context.Database.EnsureCreatedAsync(); + + foreach (var theme in DefaultThemes) + { + var existing = context.SiteTheme.FirstOrDefault(s => s.Name.Equals(theme.Name)); + if (existing == null) + { + await context.SiteTheme.AddAsync(theme); + } + } + + await context.SaveChangesAsync(); + } + public static async Task SeedSettings(DataContext context, IDirectoryService directoryService) { await context.Database.EnsureCreatedAsync(); diff --git a/API/Data/UnitOfWork.cs b/API/Data/UnitOfWork.cs index 82046ca2a..fb3e28c07 100644 --- a/API/Data/UnitOfWork.cs +++ b/API/Data/UnitOfWork.cs @@ -21,6 +21,7 @@ public interface IUnitOfWork IPersonRepository PersonRepository { get; } IGenreRepository GenreRepository { get; } ITagRepository TagRepository { get; } + ISiteThemeRepository SiteThemeRepository { get; } bool Commit(); Task CommitAsync(); bool HasChanges(); @@ -56,6 +57,7 @@ public class UnitOfWork : IUnitOfWork public IPersonRepository PersonRepository => new PersonRepository(_context, _mapper); public IGenreRepository GenreRepository => new GenreRepository(_context, _mapper); public ITagRepository TagRepository => new TagRepository(_context, _mapper); + public ISiteThemeRepository SiteThemeRepository => new SiteThemeRepository(_context, _mapper); /// /// Commits changes to the DB. Completes the open transaction. diff --git a/API/Entities/AppUserPreferences.cs b/API/Entities/AppUserPreferences.cs index 01587431b..38f95cf42 100644 --- a/API/Entities/AppUserPreferences.cs +++ b/API/Entities/AppUserPreferences.cs @@ -58,11 +58,11 @@ namespace API.Entities /// Book Reader Option: What direction should the next/prev page buttons go /// public ReadingDirection BookReaderReadingDirection { get; set; } = ReadingDirection.LeftToRight; - /// - /// UI Site Global Setting: Whether the UI should render in Dark mode or not. + /// UI Site Global Setting: The UI theme the user should use. /// - public bool SiteDarkMode { get; set; } = true; + /// Should default to Dark + public SiteTheme Theme { get; set; } diff --git a/API/Entities/Enums/Theme/ThemeProvider.cs b/API/Entities/Enums/Theme/ThemeProvider.cs new file mode 100644 index 000000000..45af2d94b --- /dev/null +++ b/API/Entities/Enums/Theme/ThemeProvider.cs @@ -0,0 +1,17 @@ +using System.ComponentModel; + +namespace API.Entities.Enums.Theme; + +public enum ThemeProvider +{ + /// + /// Theme is provided by System + /// + [Description("System")] + System = 1, + /// + /// Theme is provided by the User (ie it's custom) + /// + [Description("User")] + User = 2 +} diff --git a/API/Entities/SiteTheme.cs b/API/Entities/SiteTheme.cs new file mode 100644 index 000000000..87ebe95b1 --- /dev/null +++ b/API/Entities/SiteTheme.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using API.Entities.Enums.Theme; +using API.Entities.Interfaces; +using API.Services; + +namespace API.Entities; +/// +/// Represents a set of css overrides the user can upload to Kavita and will load into webui +/// +public class SiteTheme : IEntityDate +{ + public int Id { get; set; } + /// + /// Name of the Theme + /// + public string Name { get; set; } + /// + /// Normalized name for lookups + /// + public string NormalizedName { get; set; } + /// + /// File path to the content. Stored under . + /// Must be a .css file + /// + public string FileName { get; set; } + /// + /// Only one theme can have this. Will auto-set this as default for new user accounts + /// + public bool IsDefault { get; set; } + /// + /// Where did the theme come from + /// + public ThemeProvider Provider { get; set; } + public DateTime Created { get; set; } + public DateTime LastModified { get; set; } +} diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index f11f0a8d1..146647393 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -39,6 +39,7 @@ namespace API.Extensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index 6ac4aeee5..0b81f2d7f 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -7,6 +7,7 @@ using API.DTOs.Reader; using API.DTOs.ReadingLists; using API.DTOs.Search; using API.DTOs.Settings; +using API.DTOs.Theme; using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; @@ -119,10 +120,14 @@ namespace API.Helpers opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Editor))); + CreateMap(); + CreateMap(); + CreateMap() + .ForMember(dest => dest.Theme, + opt => + opt.MapFrom(src => src.Theme)); - CreateMap(); - CreateMap(); CreateMap(); @@ -146,6 +151,7 @@ namespace API.Helpers CreateMap(); + CreateMap, ServerSettingDto>() .ConvertUsing(); } diff --git a/API/Program.cs b/API/Program.cs index 3a0d9ab25..b81fe61d5 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -77,6 +77,7 @@ namespace API await Seed.SeedRoles(roleManager); await Seed.SeedSettings(context, directoryService); + await Seed.SeedThemes(context); await Seed.SeedUserApiKeys(context); diff --git a/API/Services/DirectoryService.cs b/API/Services/DirectoryService.cs index 0edf51ffc..89981d43f 100644 --- a/API/Services/DirectoryService.cs +++ b/API/Services/DirectoryService.cs @@ -19,6 +19,7 @@ namespace API.Services string LogDirectory { get; } string TempDirectory { get; } string ConfigDirectory { get; } + string SiteThemeDirectory { get; } /// /// Original BookmarkDirectory. Only used for resetting directory. Use for actual path. /// @@ -64,6 +65,7 @@ namespace API.Services public string TempDirectory { get; } public string ConfigDirectory { get; } public string BookmarkDirectory { get; } + public string SiteThemeDirectory { get; } private readonly ILogger _logger; private static readonly Regex ExcludeDirectories = new Regex( @@ -81,6 +83,7 @@ namespace API.Services TempDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "temp"); ConfigDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config"); BookmarkDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "bookmarks"); + SiteThemeDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "themes"); } /// diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index 6c1d914cf..35dc332c6 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -22,6 +22,7 @@ public interface ITaskScheduler void ScanSeries(int libraryId, int seriesId, bool forceUpdate = false); void CancelStatsTasks(); Task RunStatCollection(); + void ScanSiteThemes(); } public class TaskScheduler : ITaskScheduler { @@ -35,6 +36,7 @@ public class TaskScheduler : ITaskScheduler private readonly IStatsService _statsService; private readonly IVersionUpdaterService _versionUpdaterService; + private readonly ISiteThemeService _siteThemeService; public static BackgroundJobServer Client => new BackgroundJobServer(); private static readonly Random Rnd = new Random(); @@ -42,7 +44,8 @@ public class TaskScheduler : ITaskScheduler public TaskScheduler(ICacheService cacheService, ILogger logger, IScannerService scannerService, IUnitOfWork unitOfWork, IMetadataService metadataService, IBackupService backupService, - ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService) + ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService, + ISiteThemeService siteThemeService) { _cacheService = cacheService; _logger = logger; @@ -53,6 +56,7 @@ public class TaskScheduler : ITaskScheduler _cleanupService = cleanupService; _statsService = statsService; _versionUpdaterService = versionUpdaterService; + _siteThemeService = siteThemeService; } public async Task ScheduleTasks() @@ -124,6 +128,12 @@ public class TaskScheduler : ITaskScheduler BackgroundJob.Enqueue(() => _statsService.Send()); } + public void ScanSiteThemes() + { + _logger.LogInformation("Starting Site Theme scan"); + BackgroundJob.Enqueue(() => _siteThemeService.Scan()); + } + #endregion #region UpdateTasks diff --git a/API/Services/Tasks/SiteThemeService.cs b/API/Services/Tasks/SiteThemeService.cs new file mode 100644 index 000000000..e474adf06 --- /dev/null +++ b/API/Services/Tasks/SiteThemeService.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.Data; +using API.Entities; +using API.Entities.Enums.Theme; +using API.SignalR; +using Kavita.Common; +using Microsoft.AspNetCore.SignalR; + +namespace API.Services.Tasks; + +public interface ISiteThemeService +{ + Task GetContent(int themeId); + Task Scan(); + Task UpdateDefault(int themeId); +} + +public class SiteThemeService : ISiteThemeService +{ + private readonly IDirectoryService _directoryService; + private readonly IUnitOfWork _unitOfWork; + private readonly IHubContext _messageHub; + + public SiteThemeService(IDirectoryService directoryService, IUnitOfWork unitOfWork, IHubContext messageHub) + { + _directoryService = directoryService; + _unitOfWork = unitOfWork; + _messageHub = messageHub; + } + + /// + /// Given a themeId, return the content inside that file + /// + /// + /// + /// + public async Task GetContent(int themeId) + { + var theme = await _unitOfWork.SiteThemeRepository.GetThemeDto(themeId); + if (theme == null) throw new KavitaException("Theme file missing or invalid"); + var themeFile = _directoryService.FileSystem.Path.Join(_directoryService.SiteThemeDirectory, theme.FileName); + if (string.IsNullOrEmpty(themeFile) || !_directoryService.FileSystem.File.Exists(themeFile)) + throw new KavitaException("Theme file missing or invalid"); + + 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() + { + _directoryService.ExistOrCreate(_directoryService.SiteThemeDirectory); + var reservedNames = Seed.DefaultThemes.Select(t => t.NormalizedName).ToList(); + var themeFiles = _directoryService.GetFilesWithExtension(Parser.Parser.NormalizePath(_directoryService.SiteThemeDirectory), @"\.css") + .Where(name => !reservedNames.Contains(Parser.Parser.Normalize(name))).ToList(); + + var allThemes = (await _unitOfWork.SiteThemeRepository.GetThemes()).ToList(); + var totalThemesToIterate = themeFiles.Count; + var themeIteratedCount = 0; + + // 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) + { + var filepath = Parser.Parser.NormalizePath( + _directoryService.FileSystem.Path.Join(_directoryService.SiteThemeDirectory, userTheme.FileName)); + if (!_directoryService.FileSystem.File.Exists(filepath)) + { + // I need to do the removal different. I need to update all userpreferences to use DefaultTheme + allThemes.Remove(userTheme); + await RemoveTheme(userTheme); + + await _messageHub.Clients.All.SendAsync(SignalREvents.SiteThemeProgress, + MessageFactory.SiteThemeProgressEvent(1, totalThemesToIterate, userTheme.FileName, 0F)); + } + } + + // Add new custom themes + var allThemeNames = allThemes.Select(t => t.NormalizedName).ToList(); + foreach (var themeFile in themeFiles) + { + var themeName = + Parser.Parser.Normalize(_directoryService.FileSystem.Path.GetFileNameWithoutExtension(themeFile)); + if (allThemeNames.Contains(themeName)) + { + themeIteratedCount += 1; + continue; + } + _unitOfWork.SiteThemeRepository.Add(new SiteTheme() + { + Name = _directoryService.FileSystem.Path.GetFileNameWithoutExtension(themeFile), + NormalizedName = themeName, + FileName = _directoryService.FileSystem.Path.GetFileName(themeFile), + Provider = ThemeProvider.User, + IsDefault = false, + }); + await _messageHub.Clients.All.SendAsync(SignalREvents.SiteThemeProgress, + MessageFactory.SiteThemeProgressEvent(themeIteratedCount, totalThemesToIterate, themeName, themeIteratedCount / (totalThemesToIterate * 1.0f))); + themeIteratedCount += 1; + } + + + if (_unitOfWork.HasChanges()) + { + await _unitOfWork.CommitAsync(); + } + + await _messageHub.Clients.All.SendAsync(SignalREvents.SiteThemeProgress, + MessageFactory.SiteThemeProgressEvent(totalThemesToIterate, totalThemesToIterate, "", 1F)); + + } + + /// + /// Removes the theme and any references to it from Pref and sets them to the default at the time. + /// This commits to DB. + /// + /// + private async Task RemoveTheme(SiteTheme theme) + { + var prefs = await _unitOfWork.UserRepository.GetAllPreferencesByThemeAsync(theme.Id); + var defaultTheme = await _unitOfWork.SiteThemeRepository.GetDefaultTheme(); + foreach (var pref in prefs) + { + pref.Theme = defaultTheme; + _unitOfWork.UserRepository.Update(pref); + } + _unitOfWork.SiteThemeRepository.Remove(theme); + await _unitOfWork.CommitAsync(); + } + + /// + /// Updates the themeId to the default theme, all others are marked as non-default + /// + /// + /// + /// If theme does not exist + public async Task UpdateDefault(int themeId) + { + try + { + var theme = await _unitOfWork.SiteThemeRepository.GetThemeDto(themeId); + if (theme == null) throw new KavitaException("Theme file missing or invalid"); + + foreach (var siteTheme in await _unitOfWork.SiteThemeRepository.GetThemes()) + { + siteTheme.IsDefault = (siteTheme.Id == themeId); + _unitOfWork.SiteThemeRepository.Update(siteTheme); + } + + if (!_unitOfWork.HasChanges()) return; + await _unitOfWork.CommitAsync(); + } + catch (Exception) + { + await _unitOfWork.RollbackAsync(); + throw; + } + } +} diff --git a/API/SignalR/MessageFactory.cs b/API/SignalR/MessageFactory.cs index bf7c649bf..c2f661973 100644 --- a/API/SignalR/MessageFactory.cs +++ b/API/SignalR/MessageFactory.cs @@ -1,4 +1,5 @@ using System; +using System.Threading; using API.DTOs.Update; namespace API.SignalR @@ -160,5 +161,20 @@ namespace API.SignalR } }; } + + public static SignalRMessage SiteThemeProgressEvent(int themeIteratedCount, int totalThemesToIterate, string themeName, float progress) + { + return new SignalRMessage() + { + Name = SignalREvents.SiteThemeProgress, + Body = new + { + TotalUpdates = totalThemesToIterate, + CurrentCount = themeIteratedCount, + ThemeName = themeName, + Progress = progress + } + }; + } } } diff --git a/API/SignalR/SignalREvents.cs b/API/SignalR/SignalREvents.cs index 1da613455..7f9f44cf9 100644 --- a/API/SignalR/SignalREvents.cs +++ b/API/SignalR/SignalREvents.cs @@ -54,5 +54,10 @@ /// A cover was updated /// public const string CoverUpdate = "CoverUpdate"; + /// + /// A custom site theme was removed or added + /// + public const string SiteThemeProgress = "SiteThemeProgress"; + } } diff --git a/INSTALL.txt b/INSTALL.txt index 9119da82c..753236e9a 100644 --- a/INSTALL.txt +++ b/INSTALL.txt @@ -4,4 +4,5 @@ 3. Run Kavita executable. 4. Open localhost:5000 and setup your account and libraries in the UI. -If updating, copy everything but the config/ directory over. Restart Kavita. +How to Update +1. Copy everything but the config/ directory over. Restart Kavita. diff --git a/UI/Web/angular.json b/UI/Web/angular.json index 10aefdb56..56bc8a3e1 100644 --- a/UI/Web/angular.json +++ b/UI/Web/angular.json @@ -49,7 +49,7 @@ "vendorChunk": true, "extractLicenses": false, "buildOptimizer": false, - "optimization": true, + "optimization": false, "namedChunks": true }, "configurations": { diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index fe67970cf..85739725d 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -2405,18 +2405,11 @@ } }, "@ng-bootstrap/ng-bootstrap": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-11.0.0.tgz", - "integrity": "sha512-qDnB0+jbpQ4wjXpM4NPRAtwmgTDUCjGavoeRDZHOvFfYvx/MBf1RTjZEqTJ1Yqq1pKP4BWpzxCgVTunfnpmsjA==", + "version": "12.0.0-beta.4", + "resolved": "https://registry.npmjs.org/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-12.0.0-beta.4.tgz", + "integrity": "sha512-iOXZT4FLouAGJDRw4ruogyR+lg648nywNWKUxW7l+mtMC9i4kdpfo4beQ/nqb4Uq2zMDs9zj4MbKVI391+kMnA==", "requires": { "tslib": "^2.3.0" - }, - "dependencies": { - "tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==" - } } }, "@ngtools/webpack": { @@ -2581,6 +2574,11 @@ "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.21.tgz", "integrity": "sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==" }, + "@popperjs/core": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.2.tgz", + "integrity": "sha512-92FRmppjjqz29VMJ2dn+xdyXZBrMlE42AV6Kq6BwjWV7CNUW1hs2FtxSNLQE+gJhaZ6AAmYuO9y8dshhcBl7vA==" + }, "@schematics/angular": { "version": "13.2.3", "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-13.2.3.tgz", @@ -3841,9 +3839,9 @@ "dev": true }, "bootstrap": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.6.1.tgz", - "integrity": "sha512-0dj+VgI9Ecom+rvvpNZ4MUZJz8dcX7WCX+eTID9+/8HgOkv3dsRzi8BGeZJCQU6flWQVYxwTQnEZFrmJSEO7og==" + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.1.3.tgz", + "integrity": "sha512-fcQztozJ8jToQWXxVuEyXWW+dSo8AiXWKwiSSrKWsRB/Qt+Ewwza+JWoLKiTuQLaEPhdNAJ7+Dosc9DOIqNy7Q==" }, "bowser": { "version": "2.11.0", @@ -11848,9 +11846,9 @@ "dev": true }, "swiper": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/swiper/-/swiper-8.0.3.tgz", - "integrity": "sha512-mpw7v/Lkh48LQUxtJuFD+3Lls8LViNi3j1fbk45fNo9DXZxXK/e7NMixxS27OxvC5wx+5H3bet1O2pdjk7akBA==", + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/swiper/-/swiper-8.0.6.tgz", + "integrity": "sha512-Ssyu1+FeNATF/G8e84QG+ZUNtUOAZ5vngdgxzczh0oWZPhGUVgkdv+BoePUuaCXLAFXnwVpNjgLIcGnxMdmWPA==", "requires": { "dom7": "^4.0.4", "ssr-window": "^4.0.2" diff --git a/UI/Web/package.json b/UI/Web/package.json index 4eb73bd67..fd021a1c8 100644 --- a/UI/Web/package.json +++ b/UI/Web/package.json @@ -28,11 +28,12 @@ "@angular/router": "~13.2.2", "@fortawesome/fontawesome-free": "^6.0.0", "@microsoft/signalr": "^6.0.2", - "@ng-bootstrap/ng-bootstrap": "^11.0.0", + "@ng-bootstrap/ng-bootstrap": "^12.0.0-beta.4", "@ngx-lite/nav-drawer": "^0.4.7", "@ngx-lite/util": "0.0.1", + "@popperjs/core": "^2.11.2", "@types/file-saver": "^2.0.5", - "bootstrap": "^4.6.1", + "bootstrap": "^5.1.2", "bowser": "^2.11.0", "file-saver": "^2.0.5", "lazysizes": "^5.3.2", @@ -41,7 +42,7 @@ "ngx-file-drop": "^13.0.0", "ngx-toastr": "^14.2.1", "rxjs": "~7.5.4", - "swiper": "^8.0.3", + "swiper": "^8.0.6", "tslib": "^2.3.1", "webpack-bundle-analyzer": "^4.5.0", "zone.js": "~0.11.4" diff --git a/UI/Web/src/app/_modals/review-series-modal/review-series-modal.component.html b/UI/Web/src/app/_modals/review-series-modal/review-series-modal.component.html index b6fbedd6c..cfb00e0a8 100644 --- a/UI/Web/src/app/_modals/review-series-modal/review-series-modal.component.html +++ b/UI/Web/src/app/_modals/review-series-modal/review-series-modal.component.html @@ -2,29 +2,29 @@