From 5613d1a954e1f9480c67e0f27cdd47015e18d0eb Mon Sep 17 00:00:00 2001 From: Joe Milazzo Date: Wed, 11 Jan 2023 08:12:31 -0600 Subject: [PATCH] Better Themes, Stats, and bugfixes (#1740) * Fixed a bug where when clicking on a series rating for first time, the rating wasn't populating in the modal. * Fixed a bug on Scroll mode with immersive mode, the bottom bar could clip with the book body. * Cleanup some uses of var * Refactored text as json into a type so I don't have to copy/paste everywhere * Theme styles now override the defaults and theme owners no longer need to maintain all the variables themselves. Themes can now override the color of the header on mobile devices via --theme-color and Kavita will now update both theme color as well as color scheme. * Fixed a bug where last active on user stats wasn't for the particular user. * Added a more accurate word count calculation and the ability to see the word counts year over year. * Added a new table for long term statistics, like number of files over the years. No views are present for this data, I will add them later. --- API/Controllers/StatsController.cs | 32 +- API/Controllers/WantToReadController.cs | 2 +- API/DTOs/Statistics/Count.cs | 2 +- API/DTOs/Statistics/ICount.cs | 2 +- API/DTOs/Statistics/PagesReadOnADayCount.cs | 3 +- ...erStatistics.cs => ServerStatisticsDto.cs} | 2 +- API/Data/DataContext.cs | 1 + .../20230111014852_YearlyStats.Designer.cs | 1743 +++++++++++++++++ .../Migrations/20230111014852_YearlyStats.cs | 39 + .../Migrations/DataContextModelSnapshot.cs | 38 + API/Entities/ServerStatistics.cs | 15 + API/Services/StatisticService.cs | 114 +- API/Services/TaskScheduler.cs | 6 +- UI/Web/src/app/_services/account.service.ts | 18 +- .../app/_services/collection-tag.service.ts | 7 +- UI/Web/src/app/_services/device.service.ts | 7 +- UI/Web/src/app/_services/metadata.service.ts | 5 +- UI/Web/src/app/_services/reader.service.ts | 5 +- .../src/app/_services/reading-list.service.ts | 21 +- UI/Web/src/app/_services/series.service.ts | 5 +- .../src/app/_services/statistics.service.ts | 19 +- UI/Web/src/app/_services/theme.service.ts | 29 +- UI/Web/src/app/_services/upload.service.ts | 3 +- UI/Web/src/app/_types/text-response.ts | 4 + UI/Web/src/app/admin/settings.service.ts | 3 +- .../book-reader/book-reader.component.html | 3 +- .../book-reader/book-reader.component.scss | 9 +- .../book-reader/book-reader.component.ts | 10 +- .../app/book-reader/_services/book.service.ts | 3 +- .../double-no-cover-renderer.component.ts | 7 + .../series-detail/series-detail.component.ts | 3 +- .../generic-list-modal.component.html | 2 +- .../user-stats-info-cards.component.html | 4 +- .../user-stats-info-cards.component.ts | 25 +- UI/Web/src/index.html | 2 +- UI/Web/src/styles.scss | 1 - UI/Web/src/theme/_variables.scss | 6 +- UI/Web/src/theme/themes/dark.scss | 7 +- openapi.json | 130 +- 39 files changed, 2234 insertions(+), 103 deletions(-) rename API/DTOs/Statistics/{ServerStatistics.cs => ServerStatisticsDto.cs} (96%) create mode 100644 API/Data/Migrations/20230111014852_YearlyStats.Designer.cs create mode 100644 API/Data/Migrations/20230111014852_YearlyStats.cs create mode 100644 API/Entities/ServerStatistics.cs create mode 100644 UI/Web/src/app/_types/text-response.ts diff --git a/API/Controllers/StatsController.cs b/API/Controllers/StatsController.cs index d16293d0e..f315b6f3f 100644 --- a/API/Controllers/StatsController.cs +++ b/API/Controllers/StatsController.cs @@ -41,7 +41,7 @@ public class StatsController : BaseApiController [Authorize("RequireAdminRole")] [HttpGet("server/stats")] [ResponseCache(CacheProfileName = "Statistics")] - public async Task> GetHighLevelStats() + public async Task> GetHighLevelStats() { return Ok(await _statService.GetServerStatistics()); } @@ -141,4 +141,34 @@ public class StatsController : BaseApiController return Ok(await _statService.GetReadingHistory(userId)); } + /// + /// Returns a count of pages read per year for a given userId. + /// + /// If userId is 0 and user is not an admin, API will default to userId + /// + [HttpGet("pages-per-year")] + [ResponseCache(CacheProfileName = "Statistics")] + public async Task>>> GetPagesReadPerYear(int userId = 0) + { + var isAdmin = User.IsInRole(PolicyConstants.AdminRole); + if (!isAdmin) userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + return Ok(_statService.GetPagesReadCountByYear(userId)); + } + + /// + /// Returns a count of words read per year for a given userId. + /// + /// If userId is 0 and user is not an admin, API will default to userId + /// + [HttpGet("words-per-year")] + [ResponseCache(CacheProfileName = "Statistics")] + public async Task>>> GetWordsReadPerYear(int userId = 0) + { + var isAdmin = User.IsInRole(PolicyConstants.AdminRole); + if (!isAdmin) userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + return Ok(_statService.GetWordsReadCountByYear(userId)); + } + + + } diff --git a/API/Controllers/WantToReadController.cs b/API/Controllers/WantToReadController.cs index 30b8cb05d..12bbeaa85 100644 --- a/API/Controllers/WantToReadController.cs +++ b/API/Controllers/WantToReadController.cs @@ -42,7 +42,7 @@ public class WantToReadController : BaseApiController } [HttpGet] - public async Task> GetWantToRead([FromQuery] int seriesId) + public async Task> IsSeriesInWantToRead([FromQuery] int seriesId) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); return Ok(await _unitOfWork.SeriesRepository.IsSeriesInWantToRead(user.Id, seriesId)); diff --git a/API/DTOs/Statistics/Count.cs b/API/DTOs/Statistics/Count.cs index b9f797574..dc9803a6f 100644 --- a/API/DTOs/Statistics/Count.cs +++ b/API/DTOs/Statistics/Count.cs @@ -3,5 +3,5 @@ public class StatCount : ICount { public T Value { get; set; } - public int Count { get; set; } + public long Count { get; set; } } diff --git a/API/DTOs/Statistics/ICount.cs b/API/DTOs/Statistics/ICount.cs index c38f8895e..7f8b5b2ed 100644 --- a/API/DTOs/Statistics/ICount.cs +++ b/API/DTOs/Statistics/ICount.cs @@ -3,5 +3,5 @@ public interface ICount { public T Value { get; set; } - public int Count { get; set; } + public long Count { get; set; } } diff --git a/API/DTOs/Statistics/PagesReadOnADayCount.cs b/API/DTOs/Statistics/PagesReadOnADayCount.cs index f2bfab74b..c38a775c7 100644 --- a/API/DTOs/Statistics/PagesReadOnADayCount.cs +++ b/API/DTOs/Statistics/PagesReadOnADayCount.cs @@ -12,10 +12,9 @@ public class PagesReadOnADayCount : ICount /// /// Number of pages read /// - public int Count { get; set; } + public long Count { get; set; } /// /// Format of those files /// public MangaFormat Format { get; set; } - } diff --git a/API/DTOs/Statistics/ServerStatistics.cs b/API/DTOs/Statistics/ServerStatisticsDto.cs similarity index 96% rename from API/DTOs/Statistics/ServerStatistics.cs rename to API/DTOs/Statistics/ServerStatisticsDto.cs index 76dbd94e0..3bdeef9f3 100644 --- a/API/DTOs/Statistics/ServerStatistics.cs +++ b/API/DTOs/Statistics/ServerStatisticsDto.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; namespace API.DTOs.Statistics; -public class ServerStatistics +public class ServerStatisticsDto { public long ChapterCount { get; set; } public long VolumeCount { get; set; } diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index a5cb6b191..e39b09a45 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -45,6 +45,7 @@ public sealed class DataContext : IdentityDbContext SeriesRelation { get; set; } public DbSet FolderPath { get; set; } public DbSet Device { get; set; } + public DbSet ServerStatistics { get; set; } protected override void OnModelCreating(ModelBuilder builder) diff --git a/API/Data/Migrations/20230111014852_YearlyStats.Designer.cs b/API/Data/Migrations/20230111014852_YearlyStats.Designer.cs new file mode 100644 index 000000000..2a34ad07b --- /dev/null +++ b/API/Data/Migrations/20230111014852_YearlyStats.Designer.cs @@ -0,0 +1,1743 @@ +// +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("20230111014852_YearlyStats")] + partial class YearlyStats + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.10"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .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("Created") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .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("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("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .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("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("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("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("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + 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.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("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .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("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("FolderWatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IncludeInDashboard") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IncludeInRecommended") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IncludeInSearch") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + 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("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + 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("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("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .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("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + 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("LastModified") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .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("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NameLocked") + .HasColumnType("INTEGER"); + + 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("AppUserId"); + + 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("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("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + 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("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.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.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.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.Series", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany("WantToRead") + .HasForeignKey("AppUserId"); + + 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("Devices"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + 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("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/20230111014852_YearlyStats.cs b/API/Data/Migrations/20230111014852_YearlyStats.cs new file mode 100644 index 000000000..c2ec76e3b --- /dev/null +++ b/API/Data/Migrations/20230111014852_YearlyStats.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class YearlyStats : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ServerStatistics", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Year = table.Column(type: "INTEGER", nullable: false), + SeriesCount = table.Column(type: "INTEGER", nullable: false), + VolumeCount = table.Column(type: "INTEGER", nullable: false), + ChapterCount = table.Column(type: "INTEGER", nullable: false), + FileCount = table.Column(type: "INTEGER", nullable: false), + UserCount = table.Column(type: "INTEGER", nullable: false), + GenreCount = table.Column(type: "INTEGER", nullable: false), + PersonCount = table.Column(type: "INTEGER", nullable: false), + TagCount = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ServerStatistics", x => x.Id); + }); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ServerStatistics"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index c474690ce..3ebad1105 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -949,6 +949,44 @@ namespace API.Data.Migrations 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") diff --git a/API/Entities/ServerStatistics.cs b/API/Entities/ServerStatistics.cs new file mode 100644 index 000000000..159b7ef4c --- /dev/null +++ b/API/Entities/ServerStatistics.cs @@ -0,0 +1,15 @@ +namespace API.Entities; + +public class ServerStatistics +{ + public int Id { get; set; } + public int Year { get; set; } + public long SeriesCount { get; set; } + public long VolumeCount { get; set; } + public long ChapterCount { get; set; } + public long FileCount { get; set; } + public long UserCount { get; set; } + public long GenreCount { get; set; } + public long PersonCount { get; set; } + public long TagCount { get; set; } +} diff --git a/API/Services/StatisticService.cs b/API/Services/StatisticService.cs index f301718e3..c770c4dce 100644 --- a/API/Services/StatisticService.cs +++ b/API/Services/StatisticService.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using API.Data; using API.DTOs; using API.DTOs.Statistics; +using API.Entities; using API.Entities.Enums; using API.Extensions; using AutoMapper; @@ -17,7 +18,7 @@ namespace API.Services; public interface IStatisticService { - Task GetServerStatistics(); + Task GetServerStatistics(); Task GetUserReadStatistics(int userId, IList libraryIds); Task>> GetYearCount(); Task>> GetTopYears(); @@ -28,6 +29,9 @@ public interface IStatisticService Task> GetReadingHistory(int userId); Task>> ReadCountByDay(int userId = 0, int days = 0); IEnumerable> GetDayBreakdown(); + IEnumerable> GetPagesReadCountByYear(int userId = 0); + IEnumerable> GetWordsReadCountByYear(int userId = 0); + Task UpdateServerStatistics(); } /// @@ -71,9 +75,12 @@ public class StatisticService : IStatisticService .Where(c => chapterIds.Contains(c.Id)) .SumAsync(c => c.AvgHoursToRead); - var totalWordsRead = await _context.Chapter - .Where(c => chapterIds.Contains(c.Id)) - .SumAsync(c => c.WordCount); + var totalWordsRead = (long) Math.Round(await _context.AppUserProgresses + .Where(p => p.AppUserId == userId) + .Where(p => libraryIds.Contains(p.LibraryId)) + .Join(_context.Chapter, p => p.ChapterId, c => c.Id, (progress, chapter) => new {chapter, progress}) + .Where(p => p.chapter.WordCount > 0) + .SumAsync(p => p.chapter.WordCount * (p.progress.PagesRead / (1.0f * p.chapter.Pages)))); var chaptersRead = await _context.AppUserProgresses .Where(p => p.AppUserId == userId) @@ -83,10 +90,10 @@ public class StatisticService : IStatisticService var lastActive = await _context.AppUserProgresses .OrderByDescending(p => p.LastModified) + .Where(p => p.AppUserId == userId) .Select(p => p.LastModified) .FirstOrDefaultAsync(); - // Reading Progress by Library Name // First get the total pages per library var totalPageCountByLibrary = _context.Chapter @@ -190,7 +197,7 @@ public class StatisticService : IStatisticService } - public async Task GetServerStatistics() + public async Task GetServerStatistics() { var mostActiveUsers = _context.AppUserProgresses .AsSplitQuery() @@ -268,7 +275,7 @@ public class StatisticService : IStatisticService .Distinct() .Count(); - return new ServerStatistics() + return new ServerStatisticsDto() { ChapterCount = await _context.Chapter.CountAsync(), SeriesCount = await _context.Series.CountAsync(), @@ -397,6 +404,85 @@ public class StatisticService : IStatisticService .AsEnumerable(); } + /// + /// Return a list of years for the given userId + /// + /// + /// + public IEnumerable> GetPagesReadCountByYear(int userId = 0) + { + var query = _context.AppUserProgresses + .AsSplitQuery() + .AsNoTracking(); + + if (userId > 0) + { + query = query.Where(p => p.AppUserId == userId); + } + + return query.GroupBy(p => p.LastModified.Year) + .OrderBy(g => g.Key) + .Select(g => new StatCount {Value = g.Key, Count = g.Sum(x => x.PagesRead)}) + .AsEnumerable(); + } + + public IEnumerable> GetWordsReadCountByYear(int userId = 0) + { + var query = _context.AppUserProgresses + .AsSplitQuery() + .AsNoTracking(); + + if (userId > 0) + { + query = query.Where(p => p.AppUserId == userId); + } + + return query + .Join(_context.Chapter, p => p.ChapterId, c => c.Id, (progress, chapter) => new {chapter, progress}) + .Where(p => p.chapter.WordCount > 0) + .GroupBy(p => p.progress.LastModified.Year) + .Select(g => new StatCount{ + Value = g.Key, + Count = (long) Math.Round(g.Sum(p => p.chapter.WordCount * ((1.0f * p.progress.PagesRead) / p.chapter.Pages))) + }) + .AsEnumerable(); + } + + /// + /// Updates the ServerStatistics table for the current year + /// + /// This commits + /// + public async Task UpdateServerStatistics() + { + var year = DateTime.Today.Year; + + var existingRecord = await _context.ServerStatistics.SingleOrDefaultAsync(s => s.Year == year) ?? new ServerStatistics(); + + existingRecord.Year = year; + existingRecord.ChapterCount = await _context.Chapter.CountAsync(); + existingRecord.VolumeCount = await _context.Volume.CountAsync(); + existingRecord.FileCount = await _context.MangaFile.CountAsync(); + existingRecord.SeriesCount = await _context.Series.CountAsync(); + existingRecord.UserCount = await _context.Users.CountAsync(); + existingRecord.GenreCount = await _context.Genre.CountAsync(); + existingRecord.TagCount = await _context.Tag.CountAsync(); + existingRecord.PersonCount = _context.Person + .AsSplitQuery() + .AsEnumerable() + .GroupBy(sm => sm.NormalizedName) + .Select(sm => sm.Key) + .Distinct() + .Count(); + + _context.ServerStatistics.Attach(existingRecord); + if (existingRecord.Id > 0) + { + _context.Entry(existingRecord).State = EntityState.Modified; + } + await _unitOfWork.CommitAsync(); + } + public async Task> GetTopUsers(int days) { var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); @@ -434,9 +520,8 @@ public class StatisticService : IStatisticService .ToList(); var chapterLibLookup = new Dictionary(); - foreach (var cl in chapterIdWithLibraryId) + foreach (var cl in chapterIdWithLibraryId.Where(cl => !chapterLibLookup.ContainsKey(cl.ChapterId))) { - if (chapterLibLookup.ContainsKey(cl.ChapterId)) continue; chapterLibLookup.Add(cl.ChapterId, cl.LibraryId); } @@ -457,19 +542,14 @@ public class StatisticService : IStatisticService user[userChapter.User.Id] = libraryTimes; } - var ret = new List(); - foreach (var userId in user.Keys) - { - ret.Add(new TopReadDto() + return user.Keys.Select(userId => new TopReadDto() { UserId = userId, Username = users.First(u => u.Id == userId).UserName, BooksTime = user[userId].ContainsKey(LibraryType.Book) ? user[userId][LibraryType.Book] : 0, ComicsTime = user[userId].ContainsKey(LibraryType.Comic) ? user[userId][LibraryType.Comic] : 0, MangaTime = user[userId].ContainsKey(LibraryType.Manga) ? user[userId][LibraryType.Manga] : 0, - }); - } - - return ret; + }) + .ToList(); } } diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index 072f9bfbf..bdd7069bc 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -46,11 +46,13 @@ public class TaskScheduler : ITaskScheduler private readonly IVersionUpdaterService _versionUpdaterService; private readonly IThemeService _themeService; private readonly IWordCountAnalyzerService _wordCountAnalyzerService; + private readonly IStatisticService _statisticService; public static BackgroundJobServer Client => new BackgroundJobServer(); public const string ScanQueue = "scan"; public const string DefaultQueue = "default"; public const string RemoveFromWantToReadTaskId = "remove-from-want-to-read"; + public const string UpdateYearlyStatsTaskId = "update-yearly-stats"; public const string CleanupDbTaskId = "cleanup-db"; public const string CleanupTaskId = "cleanup"; public const string BackupTaskId = "backup"; @@ -65,7 +67,7 @@ public class TaskScheduler : ITaskScheduler public TaskScheduler(ICacheService cacheService, ILogger logger, IScannerService scannerService, IUnitOfWork unitOfWork, IMetadataService metadataService, IBackupService backupService, ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService, - IThemeService themeService, IWordCountAnalyzerService wordCountAnalyzerService) + IThemeService themeService, IWordCountAnalyzerService wordCountAnalyzerService, IStatisticService statisticService) { _cacheService = cacheService; _logger = logger; @@ -78,6 +80,7 @@ public class TaskScheduler : ITaskScheduler _versionUpdaterService = versionUpdaterService; _themeService = themeService; _wordCountAnalyzerService = wordCountAnalyzerService; + _statisticService = statisticService; } public async Task ScheduleTasks() @@ -111,6 +114,7 @@ public class TaskScheduler : ITaskScheduler RecurringJob.AddOrUpdate(CleanupTaskId, () => _cleanupService.Cleanup(), Cron.Daily, TimeZoneInfo.Local); RecurringJob.AddOrUpdate(CleanupDbTaskId, () => _cleanupService.CleanupDbEntries(), Cron.Daily, TimeZoneInfo.Local); RecurringJob.AddOrUpdate(RemoveFromWantToReadTaskId, () => _cleanupService.CleanupWantToRead(), Cron.Daily, TimeZoneInfo.Local); + RecurringJob.AddOrUpdate(UpdateYearlyStatsTaskId, () => _statisticService.UpdateServerStatistics(), Cron.Monthly, TimeZoneInfo.Local); } #region StatsTasks diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index 7f3e07fa5..299925020 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -13,6 +13,7 @@ import { UserUpdateEvent } from '../_models/events/user-update-event'; import { UpdateEmailResponse } from '../_models/auth/update-email-response'; import { AgeRating } from '../_models/metadata/age-rating'; import { AgeRestriction } from '../_models/metadata/age-restriction'; +import { TextResonse } from '../_types/text-response'; export enum Role { Admin = 'Admin', @@ -151,7 +152,7 @@ export class AccountService implements OnDestroy { } migrateUser(model: {email: string, username: string, password: string, sendEmail: boolean}) { - return this.httpClient.post(this.baseUrl + 'account/migrate-email', model, {responseType: 'text' as 'json'}); + return this.httpClient.post(this.baseUrl + 'account/migrate-email', model, TextResonse); } confirmMigrationEmail(model: {email: string, token: string}) { @@ -159,7 +160,7 @@ export class AccountService implements OnDestroy { } resendConfirmationEmail(userId: number) { - return this.httpClient.post(this.baseUrl + 'account/resend-confirmation-email?userId=' + userId, {}, {responseType: 'text' as 'json'}); + return this.httpClient.post(this.baseUrl + 'account/resend-confirmation-email?userId=' + userId, {}, TextResonse); } inviteUser(model: {email: string, roles: Array, libraries: Array, ageRestriction: AgeRestriction}) { @@ -180,7 +181,7 @@ export class AccountService implements OnDestroy { * @returns */ getInviteUrl(userId: number, withBaseUrl: boolean = true) { - return this.httpClient.get(this.baseUrl + 'account/invite-url?userId=' + userId + '&withBaseUrl=' + withBaseUrl, {responseType: 'text' as 'json'}); + return this.httpClient.get(this.baseUrl + 'account/invite-url?userId=' + userId + '&withBaseUrl=' + withBaseUrl, TextResonse); } getDecodedToken(token: string) { @@ -188,15 +189,15 @@ export class AccountService implements OnDestroy { } requestResetPasswordEmail(email: string) { - return this.httpClient.post(this.baseUrl + 'account/forgot-password?email=' + encodeURIComponent(email), {}, {responseType: 'text' as 'json'}); + return this.httpClient.post(this.baseUrl + 'account/forgot-password?email=' + encodeURIComponent(email), {}, TextResonse); } confirmResetPasswordEmail(model: {email: string, token: string, password: string}) { - return this.httpClient.post(this.baseUrl + 'account/confirm-password-reset', model, {responseType: 'text' as 'json'}); + return this.httpClient.post(this.baseUrl + 'account/confirm-password-reset', model, TextResonse); } resetPassword(username: string, password: string, oldPassword: string) { - return this.httpClient.post(this.baseUrl + 'account/reset-password', {username, password, oldPassword}, {responseType: 'json' as 'text'}); + return this.httpClient.post(this.baseUrl + 'account/reset-password', {username, password, oldPassword}, TextResonse); } update(model: {email: string, roles: Array, libraries: Array, userId: number, ageRestriction: AgeRestriction}) { @@ -247,7 +248,7 @@ export class AccountService implements OnDestroy { } resetApiKey() { - return this.httpClient.post(this.baseUrl + 'account/reset-api-key', {}, {responseType: 'text' as 'json'}).pipe(map(key => { + return this.httpClient.post(this.baseUrl + 'account/reset-api-key', {}, TextResonse).pipe(map(key => { const user = this.getUserFromLocalStorage(); if (user) { user.apiKey = key; @@ -264,7 +265,8 @@ export class AccountService implements OnDestroy { private refreshToken() { if (this.currentUser === null || this.currentUser === undefined) return of(); - return this.httpClient.post<{token: string, refreshToken: string}>(this.baseUrl + 'account/refresh-token', {token: this.currentUser.token, refreshToken: this.currentUser.refreshToken}).pipe(map(user => { + return this.httpClient.post<{token: string, refreshToken: string}>(this.baseUrl + 'account/refresh-token', + {token: this.currentUser.token, refreshToken: this.currentUser.refreshToken}).pipe(map(user => { if (this.currentUser) { this.currentUser.token = user.token; this.currentUser.refreshToken = user.refreshToken; diff --git a/UI/Web/src/app/_services/collection-tag.service.ts b/UI/Web/src/app/_services/collection-tag.service.ts index b53f4d5b7..2f19352ab 100644 --- a/UI/Web/src/app/_services/collection-tag.service.ts +++ b/UI/Web/src/app/_services/collection-tag.service.ts @@ -3,6 +3,7 @@ import { Injectable } from '@angular/core'; import { map } from 'rxjs/operators'; import { environment } from 'src/environments/environment'; import { CollectionTag } from '../_models/collection-tag'; +import { TextResonse } from '../_types/text-response'; import { ImageService } from './image.service'; @Injectable({ @@ -26,15 +27,15 @@ export class CollectionTagService { } updateTag(tag: CollectionTag) { - return this.httpClient.post(this.baseUrl + 'collection/update', tag, {responseType: 'text' as 'json'}); + return this.httpClient.post(this.baseUrl + 'collection/update', tag, TextResonse); } updateSeriesForTag(tag: CollectionTag, seriesIdsToRemove: Array) { - return this.httpClient.post(this.baseUrl + 'collection/update-series', {tag, seriesIdsToRemove}, {responseType: 'text' as 'json'}); + return this.httpClient.post(this.baseUrl + 'collection/update-series', {tag, seriesIdsToRemove}, TextResonse); } addByMultiple(tagId: number, seriesIds: Array, tagTitle: string = '') { - return this.httpClient.post(this.baseUrl + 'collection/update-for-series', {collectionTagId: tagId, collectionTagTitle: tagTitle, seriesIds}, {responseType: 'text' as 'json'}); + return this.httpClient.post(this.baseUrl + 'collection/update-for-series', {collectionTagId: tagId, collectionTagTitle: tagTitle, seriesIds}, TextResonse); } tagNameExists(name: string) { diff --git a/UI/Web/src/app/_services/device.service.ts b/UI/Web/src/app/_services/device.service.ts index 52e1c5aad..c7b062cc6 100644 --- a/UI/Web/src/app/_services/device.service.ts +++ b/UI/Web/src/app/_services/device.service.ts @@ -4,6 +4,7 @@ import { ReplaySubject, shareReplay, tap } from 'rxjs'; import { environment } from 'src/environments/environment'; import { Device } from '../_models/device/device'; import { DevicePlatform } from '../_models/device/device-platform'; +import { TextResonse } from '../_types/text-response'; import { AccountService } from './account.service'; @Injectable({ @@ -32,11 +33,11 @@ export class DeviceService { } createDevice(name: string, platform: DevicePlatform, emailAddress: string) { - return this.httpClient.post(this.baseUrl + 'device/create', {name, platform, emailAddress}, {responseType: 'text' as 'json'}); + return this.httpClient.post(this.baseUrl + 'device/create', {name, platform, emailAddress}, TextResonse); } updateDevice(id: number, name: string, platform: DevicePlatform, emailAddress: string) { - return this.httpClient.post(this.baseUrl + 'device/update', {id, name, platform, emailAddress}, {responseType: 'text' as 'json'}); + return this.httpClient.post(this.baseUrl + 'device/update', {id, name, platform, emailAddress}, TextResonse); } deleteDevice(id: number) { @@ -50,7 +51,7 @@ export class DeviceService { } sendTo(chapterIds: Array, deviceId: number) { - return this.httpClient.post(this.baseUrl + 'device/send-to', {deviceId, chapterIds}, {responseType: 'text' as 'json'}); + return this.httpClient.post(this.baseUrl + 'device/send-to', {deviceId, chapterIds}, TextResonse); } diff --git a/UI/Web/src/app/_services/metadata.service.ts b/UI/Web/src/app/_services/metadata.service.ts index 7d0e115b8..80b46ff09 100644 --- a/UI/Web/src/app/_services/metadata.service.ts +++ b/UI/Web/src/app/_services/metadata.service.ts @@ -11,6 +11,7 @@ import { Language } from '../_models/metadata/language'; import { PublicationStatusDto } from '../_models/metadata/publication-status-dto'; import { Person } from '../_models/metadata/person'; import { Tag } from '../_models/tag'; +import { TextResonse } from '../_types/text-response'; @Injectable({ providedIn: 'root' @@ -28,7 +29,7 @@ export class MetadataService { if (this.ageRatingTypes != undefined && this.ageRatingTypes.hasOwnProperty(ageRating)) { return of(this.ageRatingTypes[ageRating]); } - return this.httpClient.get(this.baseUrl + 'series/age-rating?ageRating=' + ageRating, {responseType: 'text' as 'json'}).pipe(map(ratingString => { + return this.httpClient.get(this.baseUrl + 'series/age-rating?ageRating=' + ageRating, TextResonse).pipe(map(ratingString => { if (this.ageRatingTypes === undefined) { this.ageRatingTypes = {}; } @@ -97,6 +98,6 @@ export class MetadataService { } getChapterSummary(chapterId: number) { - return this.httpClient.get(this.baseUrl + 'metadata/chapter-summary?chapterId=' + chapterId, {responseType: 'text' as 'json'}); + return this.httpClient.get(this.baseUrl + 'metadata/chapter-summary?chapterId=' + chapterId, TextResonse); } } diff --git a/UI/Web/src/app/_services/reader.service.ts b/UI/Web/src/app/_services/reader.service.ts index dcca5e522..51fe7ed90 100644 --- a/UI/Web/src/app/_services/reader.service.ts +++ b/UI/Web/src/app/_services/reader.service.ts @@ -15,6 +15,7 @@ import { UtilityService } from '../shared/_services/utility.service'; import { FilterUtilitiesService } from '../shared/_services/filter-utilities.service'; import { FileDimension } from '../manga-reader/_models/file-dimension'; import screenfull from 'screenfull'; +import { TextResonse } from '../_types/text-response'; export const CHAPTER_ID_DOESNT_EXIST = -1; export const CHAPTER_ID_NOT_FETCHED = -2; @@ -78,10 +79,10 @@ export class ReaderService { } clearBookmarks(seriesId: number) { - return this.httpClient.post(this.baseUrl + 'reader/remove-bookmarks', {seriesId}, {responseType: 'text' as 'json'}); + return this.httpClient.post(this.baseUrl + 'reader/remove-bookmarks', {seriesId}, TextResonse); } clearMultipleBookmarks(seriesIds: Array) { - return this.httpClient.post(this.baseUrl + 'reader/bulk-remove-bookmarks', {seriesIds}, {responseType: 'text' as 'json'}); + return this.httpClient.post(this.baseUrl + 'reader/bulk-remove-bookmarks', {seriesIds}, TextResonse); } /** diff --git a/UI/Web/src/app/_services/reading-list.service.ts b/UI/Web/src/app/_services/reading-list.service.ts index 486c51f0f..b626d1a72 100644 --- a/UI/Web/src/app/_services/reading-list.service.ts +++ b/UI/Web/src/app/_services/reading-list.service.ts @@ -5,6 +5,7 @@ import { environment } from 'src/environments/environment'; import { UtilityService } from '../shared/_services/utility.service'; import { PaginatedResult } from '../_models/pagination'; import { ReadingList, ReadingListItem } from '../_models/reading-list'; +import { TextResonse } from '../_types/text-response'; import { ActionItem } from './action-factory.service'; @Injectable({ @@ -44,43 +45,43 @@ export class ReadingListService { } update(model: {readingListId: number, title?: string, summary?: string, promoted: boolean}) { - return this.httpClient.post(this.baseUrl + 'readinglist/update', model, { responseType: 'text' as 'json' }); + return this.httpClient.post(this.baseUrl + 'readinglist/update', model, TextResonse); } updateByMultiple(readingListId: number, seriesId: number, volumeIds: Array, chapterIds?: Array) { - return this.httpClient.post(this.baseUrl + 'readinglist/update-by-multiple', {readingListId, seriesId, volumeIds, chapterIds}, { responseType: 'text' as 'json' }); + return this.httpClient.post(this.baseUrl + 'readinglist/update-by-multiple', {readingListId, seriesId, volumeIds, chapterIds}, TextResonse); } updateByMultipleSeries(readingListId: number, seriesIds: Array) { - return this.httpClient.post(this.baseUrl + 'readinglist/update-by-multiple-series', {readingListId, seriesIds}, { responseType: 'text' as 'json' }); + return this.httpClient.post(this.baseUrl + 'readinglist/update-by-multiple-series', {readingListId, seriesIds}, TextResonse); } updateBySeries(readingListId: number, seriesId: number) { - return this.httpClient.post(this.baseUrl + 'readinglist/update-by-series', {readingListId, seriesId}, { responseType: 'text' as 'json' }); + return this.httpClient.post(this.baseUrl + 'readinglist/update-by-series', {readingListId, seriesId}, TextResonse); } updateByVolume(readingListId: number, seriesId: number, volumeId: number) { - return this.httpClient.post(this.baseUrl + 'readinglist/update-by-volume', {readingListId, seriesId, volumeId}, { responseType: 'text' as 'json' }); + return this.httpClient.post(this.baseUrl + 'readinglist/update-by-volume', {readingListId, seriesId, volumeId}, TextResonse); } updateByChapter(readingListId: number, seriesId: number, chapterId: number) { - return this.httpClient.post(this.baseUrl + 'readinglist/update-by-chapter', {readingListId, seriesId, chapterId}, { responseType: 'text' as 'json' }); + return this.httpClient.post(this.baseUrl + 'readinglist/update-by-chapter', {readingListId, seriesId, chapterId}, TextResonse); } delete(readingListId: number) { - return this.httpClient.delete(this.baseUrl + 'readinglist?readingListId=' + readingListId, { responseType: 'text' as 'json' }); + return this.httpClient.delete(this.baseUrl + 'readinglist?readingListId=' + readingListId, TextResonse); } updatePosition(readingListId: number, readingListItemId: number, fromPosition: number, toPosition: number) { - return this.httpClient.post(this.baseUrl + 'readinglist/update-position', {readingListId, readingListItemId, fromPosition, toPosition}, { responseType: 'text' as 'json' }); + return this.httpClient.post(this.baseUrl + 'readinglist/update-position', {readingListId, readingListItemId, fromPosition, toPosition}, TextResonse); } deleteItem(readingListId: number, readingListItemId: number) { - return this.httpClient.post(this.baseUrl + 'readinglist/delete-item', {readingListId, readingListItemId}, { responseType: 'text' as 'json' }); + return this.httpClient.post(this.baseUrl + 'readinglist/delete-item', {readingListId, readingListItemId}, TextResonse); } removeRead(readingListId: number) { - return this.httpClient.post(this.baseUrl + 'readinglist/remove-read?readingListId=' + readingListId, {}, { responseType: 'text' as 'json' }); + return this.httpClient.post(this.baseUrl + 'readinglist/remove-read?readingListId=' + readingListId, {}, TextResonse); } actionListFilter(action: ActionItem, readingList: ReadingList, isAdmin: boolean) { diff --git a/UI/Web/src/app/_services/series.service.ts b/UI/Web/src/app/_services/series.service.ts index 94227379a..680688cff 100644 --- a/UI/Web/src/app/_services/series.service.ts +++ b/UI/Web/src/app/_services/series.service.ts @@ -17,6 +17,7 @@ import { SeriesGroup } from '../_models/series-group'; import { SeriesMetadata } from '../_models/metadata/series-metadata'; import { Volume } from '../_models/volume'; import { ImageService } from './image.service'; +import { TextResonse } from '../_types/text-response'; @Injectable({ providedIn: 'root' @@ -131,7 +132,7 @@ export class SeriesService { } isWantToRead(seriesId: number) { - return this.httpClient.get(this.baseUrl + 'want-to-read?seriesId=' + seriesId, {responseType: 'text' as 'json'}) + return this.httpClient.get(this.baseUrl + 'want-to-read?seriesId=' + seriesId, TextResonse) .pipe(map(val => { return val === 'true'; })); @@ -174,7 +175,7 @@ export class SeriesService { seriesMetadata, collectionTags, }; - return this.httpClient.post(this.baseUrl + 'series/metadata', data, {responseType: 'text' as 'json'}); + return this.httpClient.post(this.baseUrl + 'series/metadata', data, TextResonse); } getSeriesForTag(collectionTagId: number, pageNum?: number, itemsPerPage?: number) { diff --git a/UI/Web/src/app/_services/statistics.service.ts b/UI/Web/src/app/_services/statistics.service.ts index 9b14c9d30..e8d1e2b7f 100644 --- a/UI/Web/src/app/_services/statistics.service.ts +++ b/UI/Web/src/app/_services/statistics.service.ts @@ -12,6 +12,7 @@ import { ServerStatistics } from '../statistics/_models/server-statistics'; import { StatCount } from '../statistics/_models/stat-count'; import { PublicationStatus } from '../_models/metadata/publication-status'; import { MangaFormat } from '../_models/manga-format'; +import { TextResonse } from '../_types/text-response'; export enum DayOfWeek { @@ -58,7 +59,21 @@ export class StatisticsService { getTopYears() { return this.httpClient.get[]>(this.baseUrl + 'stats/server/top/years').pipe( map(spreads => spreads.map(spread => { - return {name: spread.value + '', value: spread.count}; + return {name: spread.value + '', value: spread.count}; + }))); + } + + getPagesPerYear(userId = 0) { + return this.httpClient.get[]>(this.baseUrl + 'stats/pages-per-year?userId=' + userId).pipe( + map(spreads => spreads.map(spread => { + return {name: spread.value + '', value: spread.count}; + }))); + } + + getWordsPerYear(userId = 0) { + return this.httpClient.get[]>(this.baseUrl + 'stats/words-per-year?userId=' + userId).pipe( + map(spreads => spreads.map(spread => { + return {name: spread.value + '', value: spread.count}; }))); } @@ -85,7 +100,7 @@ export class StatisticsService { } getTotalSize() { - return this.httpClient.get(this.baseUrl + 'stats/server/file-size', { responseType: 'text' as 'json'}); + return this.httpClient.get(this.baseUrl + 'stats/server/file-size', TextResonse); } getFileBreakdown() { diff --git a/UI/Web/src/app/_services/theme.service.ts b/UI/Web/src/app/_services/theme.service.ts index 8414150cc..a877a0df8 100644 --- a/UI/Web/src/app/_services/theme.service.ts +++ b/UI/Web/src/app/_services/theme.service.ts @@ -3,12 +3,12 @@ import { HttpClient } from '@angular/common/http'; import { Inject, Injectable, OnDestroy, Renderer2, RendererFactory2, SecurityContext } from '@angular/core'; import { DomSanitizer } from '@angular/platform-browser'; import { ToastrService } from 'ngx-toastr'; -import { map, ReplaySubject, Subject, takeUntil, take, distinctUntilChanged, Observable } from 'rxjs'; +import { map, ReplaySubject, Subject, takeUntil, 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 { AccountService } from './account.service'; +import { TextResonse } from '../_types/text-response'; import { EVENTS, MessageHubService } from './message-hub.service'; @@ -65,6 +65,14 @@ export class ThemeService implements OnDestroy { return getComputedStyle(this.document.body).getPropertyValue('--color-scheme').trim(); } + /** + * --theme-color from theme. Updates the meta tag + * @returns + */ + getThemeColor() { + return getComputedStyle(this.document.body).getPropertyValue('--theme-color').trim(); + } + getCssVariable(variable: string) { return getComputedStyle(this.document.body).getPropertyValue(variable).trim(); } @@ -137,11 +145,23 @@ export class ThemeService implements OnDestroy { this.setTheme('dark'); return; } - const styleElem = document.createElement('style'); + const styleElem = this.document.createElement('style'); styleElem.id = 'theme-' + theme.name; styleElem.appendChild(this.document.createTextNode(content)); this.renderer.appendChild(this.document.head, styleElem); + + // Check if the theme has --theme-color and apply it to meta tag + const themeColor = this.getThemeColor(); + if (themeColor) { + this.document.querySelector('meta[name="theme-color"]')?.setAttribute('content', themeColor); + } + + const colorScheme = this.getColorScheme(); + if (themeColor) { + this.document.querySelector('body')?.setAttribute('theme', colorScheme); + } + this.currentThemeSource.next(theme); }); } else { @@ -161,8 +181,7 @@ export class ThemeService implements OnDestroy { } private fetchThemeContent(themeId: number) { - // TODO: Refactor {responseType: 'text' as 'json'} into a type so i don't have to retype it - return this.httpClient.get(this.baseUrl + 'theme/download-content?themeId=' + themeId, {responseType: 'text' as 'json'}).pipe(map(encodedCss => { + return this.httpClient.get(this.baseUrl + 'theme/download-content?themeId=' + themeId, TextResonse).pipe(map(encodedCss => { return this.domSantizer.sanitize(SecurityContext.STYLE, encodedCss); })); } diff --git a/UI/Web/src/app/_services/upload.service.ts b/UI/Web/src/app/_services/upload.service.ts index 77fde32aa..8c3d6295a 100644 --- a/UI/Web/src/app/_services/upload.service.ts +++ b/UI/Web/src/app/_services/upload.service.ts @@ -1,6 +1,7 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { environment } from 'src/environments/environment'; +import { TextResonse } from '../_types/text-response'; @Injectable({ providedIn: 'root' @@ -13,7 +14,7 @@ export class UploadService { uploadByUrl(url: string) { - return this.httpClient.post(this.baseUrl + 'upload/upload-by-url', {url}, {responseType: 'text' as 'json'}); + return this.httpClient.post(this.baseUrl + 'upload/upload-by-url', {url}, TextResonse); } /** diff --git a/UI/Web/src/app/_types/text-response.ts b/UI/Web/src/app/_types/text-response.ts new file mode 100644 index 000000000..19e2117d3 --- /dev/null +++ b/UI/Web/src/app/_types/text-response.ts @@ -0,0 +1,4 @@ +/** + * Use when httpClient is expected to return just a string/variable and not json + */ +export const TextResonse = {responseType: 'text' as 'json'}; \ No newline at end of file diff --git a/UI/Web/src/app/admin/settings.service.ts b/UI/Web/src/app/admin/settings.service.ts index ab29e4f86..b6cf59cfe 100644 --- a/UI/Web/src/app/admin/settings.service.ts +++ b/UI/Web/src/app/admin/settings.service.ts @@ -1,6 +1,7 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { environment } from 'src/environments/environment'; +import { TextResonse } from '../_types/text-response'; import { ServerSettings } from './_models/server-settings'; /** @@ -53,6 +54,6 @@ export class SettingsService { } getOpdsEnabled() { - return this.http.get(this.baseUrl + 'settings/opds-enabled', {responseType: 'text' as 'json'}); + return this.http.get(this.baseUrl + 'settings/opds-enabled', TextResonse); } } diff --git a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.html b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.html index 57c90a119..d894c5bd8 100644 --- a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.html +++ b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.html @@ -91,7 +91,8 @@ [innerHtml]="page" *ngIf="page !== undefined" (click)="toggleMenu($event)" (mousedown)="mouseDown($event)"> -
+
diff --git a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss index 2738346e7..10012af05 100644 --- a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss +++ b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss @@ -47,7 +47,7 @@ $primary-color: #0062cc; $action-bar-height: 38px; -// Drawer +// Drawer .control-container { padding-bottom: 5px; } @@ -86,10 +86,6 @@ $action-bar-height: 38px; opacity: 0; } -::ng-deep .bg-warning { - background-color: yellow; -} - .action-bar { background-color: var(--br-actionbar-bg-color); @@ -196,7 +192,8 @@ $action-bar-height: 38px; } &.immersive { - height: calc((var(--vh, 1vh) * 100) - $action-bar-height); + // Note: I removed this for bug: https://github.com/Kareadita/Kavita/issues/1726 + //height: calc((var(--vh, 1vh) * 100) - $action-bar-height); } a, :link { diff --git a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts index 37dca4c42..ac2086c7f 100644 --- a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts +++ b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts @@ -262,9 +262,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { @ViewChild('reader', {static: true}) reader!: ElementRef; - - - get BookPageLayoutMode() { return BookPageLayoutMode; } @@ -722,16 +719,17 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { * from 'kavita-part', which will cause the reader to scroll to the marker. */ addLinkClickHandlers() { - var links = this.readingSectionElemRef.nativeElement.querySelectorAll('a'); + const links = this.readingSectionElemRef.nativeElement.querySelectorAll('a'); links.forEach((link: any) => { link.addEventListener('click', (e: any) => { + console.log('Link clicked: ', e); if (!e.target.attributes.hasOwnProperty('kavita-page')) { return; } - var page = parseInt(e.target.attributes['kavita-page'].value, 10); + const page = parseInt(e.target.attributes['kavita-page'].value, 10); if (this.adhocPageHistory.peek()?.page !== this.pageNum) { this.adhocPageHistory.push({page: this.pageNum, scrollPart: this.lastSeenScrollPartPath}); } - var partValue = e.target.attributes.hasOwnProperty('kavita-part') ? e.target.attributes['kavita-part'].value : undefined; + const partValue = e.target.attributes.hasOwnProperty('kavita-part') ? e.target.attributes['kavita-part'].value : undefined; if (partValue && page === this.pageNum) { this.scrollTo(e.target.attributes['kavita-part'].value); return; diff --git a/UI/Web/src/app/book-reader/_services/book.service.ts b/UI/Web/src/app/book-reader/_services/book.service.ts index 3dd579662..4ca377636 100644 --- a/UI/Web/src/app/book-reader/_services/book.service.ts +++ b/UI/Web/src/app/book-reader/_services/book.service.ts @@ -1,5 +1,6 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; +import { TextResonse } from 'src/app/_types/text-response'; import { environment } from 'src/environments/environment'; import { BookChapterItem } from '../_models/book-chapter-item'; import { BookInfo } from '../_models/book-info'; @@ -41,7 +42,7 @@ export class BookService { } getBookPage(chapterId: number, page: number) { - return this.http.get(this.baseUrl + 'book/' + chapterId + '/book-page?page=' + page, {responseType: 'text' as 'json'}); + return this.http.get(this.baseUrl + 'book/' + chapterId + '/book-page?page=' + page, TextResonse); } getBookInfo(chapterId: number) { diff --git a/UI/Web/src/app/manga-reader/_components/double-renderer-no-cover/double-no-cover-renderer.component.ts b/UI/Web/src/app/manga-reader/_components/double-renderer-no-cover/double-no-cover-renderer.component.ts index 66953d028..5b0e614d7 100644 --- a/UI/Web/src/app/manga-reader/_components/double-renderer-no-cover/double-no-cover-renderer.component.ts +++ b/UI/Web/src/app/manga-reader/_components/double-renderer-no-cover/double-no-cover-renderer.component.ts @@ -252,6 +252,13 @@ export class DoubleNoCoverRendererComponent implements OnInit { this.debugLog('Moving forward 2 pages'); return 2; case PAGING_DIRECTION.BACKWARDS: + + if (this.mangaReaderService.isCoverImage(this.pageNum - 1)) { + // TODO: If we are moving back and prev page is cover and we are not showing on right side, then move back twice as if we did once, we would show pageNum twice + this.debugLog('Moving back 1 page as on cover image'); + return 2; + } + if (this.mangaReaderService.isCoverImage(this.pageNum)) { this.debugLog('Moving back 1 page as on cover image'); return 2; diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts index 14eca4e73..0da5f8472 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts @@ -670,7 +670,8 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe return; } - this.seriesService.updateRating(this.series?.id, this.series?.userRating, this.series?.userReview).subscribe(() => { + this.seriesService.updateRating(this.series?.id, rating, this.series?.userReview).subscribe(() => { + this.series.userRating = rating; this.createHTML(); }); } diff --git a/UI/Web/src/app/statistics/_components/_modals/generic-list-modal/generic-list-modal.component.html b/UI/Web/src/app/statistics/_components/_modals/generic-list-modal/generic-list-modal.component.html index ddbc9eda2..8b1cdf1ac 100644 --- a/UI/Web/src/app/statistics/_components/_modals/generic-list-modal/generic-list-modal.component.html +++ b/UI/Web/src/app/statistics/_components/_modals/generic-list-modal/generic-list-modal.component.html @@ -15,7 +15,7 @@
  • {{item}} - diff --git a/UI/Web/src/app/statistics/_components/user-stats-info-cards/user-stats-info-cards.component.html b/UI/Web/src/app/statistics/_components/user-stats-info-cards/user-stats-info-cards.component.html index 0002d677a..13f79ccbf 100644 --- a/UI/Web/src/app/statistics/_components/user-stats-info-cards/user-stats-info-cards.component.html +++ b/UI/Web/src/app/statistics/_components/user-stats-info-cards/user-stats-info-cards.component.html @@ -1,7 +1,7 @@
    - + {{totalPagesRead | compactNumber}}
    @@ -10,7 +10,7 @@
    - + {{totalWordsRead | compactNumber}}
    diff --git a/UI/Web/src/app/statistics/_components/user-stats-info-cards/user-stats-info-cards.component.ts b/UI/Web/src/app/statistics/_components/user-stats-info-cards/user-stats-info-cards.component.ts index 9119c3796..c450cdf00 100644 --- a/UI/Web/src/app/statistics/_components/user-stats-info-cards/user-stats-info-cards.component.ts +++ b/UI/Web/src/app/statistics/_components/user-stats-info-cards/user-stats-info-cards.component.ts @@ -1,4 +1,9 @@ import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { CompactNumberPipe } from 'src/app/pipe/compact-number.pipe'; +import { FilterQueryParam } from 'src/app/shared/_services/filter-utilities.service'; +import { StatisticsService } from 'src/app/_services/statistics.service'; +import { GenericListModalComponent } from '../_modals/generic-list-modal/generic-list-modal.component'; @Component({ selector: 'app-user-stats-info-cards', @@ -15,9 +20,27 @@ export class UserStatsInfoCardsComponent implements OnInit { @Input() lastActive: string = ''; @Input() avgHoursPerWeekSpentReading: number = 0; - constructor() { } + constructor(private statsService: StatisticsService, private modalService: NgbModal) { } ngOnInit(): void { } + openPageByYearList() { + const numberPipe = new CompactNumberPipe(); + this.statsService.getPagesPerYear().subscribe(yearCounts => { + const ref = this.modalService.open(GenericListModalComponent, { scrollable: true }); + ref.componentInstance.items = yearCounts.map(t => `${t.name}: ${numberPipe.transform(t.value)} pages`); + ref.componentInstance.title = 'Pages Read By Year'; + }); + } + + openWordByYearList() { + const numberPipe = new CompactNumberPipe(); + this.statsService.getWordsPerYear().subscribe(yearCounts => { + const ref = this.modalService.open(GenericListModalComponent, { scrollable: true }); + ref.componentInstance.items = yearCounts.map(t => `${t.name}: ${numberPipe.transform(t.value)} pages`); + ref.componentInstance.title = 'Words Read By Year'; + }); + } + } diff --git a/UI/Web/src/index.html b/UI/Web/src/index.html index befd1b415..dfed2c574 100644 --- a/UI/Web/src/index.html +++ b/UI/Web/src/index.html @@ -19,7 +19,7 @@ - + diff --git a/UI/Web/src/styles.scss b/UI/Web/src/styles.scss index b1d3f2334..94a6c89bd 100644 --- a/UI/Web/src/styles.scss +++ b/UI/Web/src/styles.scss @@ -2,7 +2,6 @@ // Import themes which define the css variables we use to customize the app -@import './theme/themes/light'; @import './theme/themes/dark'; // Import colors for overrides of bootstrap theme diff --git a/UI/Web/src/theme/_variables.scss b/UI/Web/src/theme/_variables.scss index 26f9136e4..10628e73e 100644 --- a/UI/Web/src/theme/_variables.scss +++ b/UI/Web/src/theme/_variables.scss @@ -21,6 +21,6 @@ $grid-breakpoints-xl: 1200px; $grid-breakpoints: (xs: $grid-breakpoints-xs, sm: $grid-breakpoints-sm, md: $grid-breakpoints-md, lg: $grid-breakpoints-lg, xl: $grid-breakpoints-xl); // Override any bootstrap styles we don't want -:root { - --hr-color: transparent; -} +// :root { +// --hr-color: transparent; +// } diff --git a/UI/Web/src/theme/themes/dark.scss b/UI/Web/src/theme/themes/dark.scss index 0d5786d26..413735eea 100644 --- a/UI/Web/src/theme/themes/dark.scss +++ b/UI/Web/src/theme/themes/dark.scss @@ -1,4 +1,6 @@ -:root, :root .bg-dark { +// +:root, :root .default { + --theme-color: #000000; --color-scheme: dark; --primary-color: #4ac694; --primary-color-dark-shade: #3B9E76; @@ -240,4 +242,7 @@ /* List Card Item */ --card-list-item-bg-color: linear-gradient(180deg, rgba(0,0,0,0.15) 0%, rgba(0,0,0,0.15) 1%, rgba(0,0,0,0) 100%); + + /* Bootstrap overrides */ + --hr-color: transparent; } diff --git a/openapi.json b/openapi.json index e08a99e25..51847c981 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.6.1.24" + "version": "0.6.1.26" }, "servers": [ { @@ -7712,17 +7712,17 @@ "content": { "text/plain": { "schema": { - "$ref": "#/components/schemas/ServerStatistics" + "$ref": "#/components/schemas/ServerStatisticsDto" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ServerStatistics" + "$ref": "#/components/schemas/ServerStatisticsDto" } }, "text/json": { "schema": { - "$ref": "#/components/schemas/ServerStatistics" + "$ref": "#/components/schemas/ServerStatisticsDto" } } } @@ -8119,6 +8119,108 @@ } } }, + "/api/Stats/pages-per-year": { + "get": { + "tags": [ + "Stats" + ], + "summary": "Returns a count of pages read per year for a given userId.", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "If userId is 0 and user is not an admin, API will default to userId", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Int32StatCount" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Int32StatCount" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Int32StatCount" + } + } + } + } + } + } + } + }, + "/api/Stats/words-per-year": { + "get": { + "tags": [ + "Stats" + ], + "summary": "Returns a count of words read per year for a given userId.", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "If userId is 0 and user is not an admin, API will default to userId", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Int32StatCount" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Int32StatCount" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Int32StatCount" + } + } + } + } + } + } + } + }, "/api/Tachiyomi/latest-chapter": { "get": { "tags": [ @@ -10333,7 +10435,7 @@ "count": { "type": "integer", "description": "Number of pages read", - "format": "int32" + "format": "int64" }, "format": { "$ref": "#/components/schemas/MangaFormat" @@ -10362,7 +10464,7 @@ }, "count": { "type": "integer", - "format": "int32" + "format": "int64" } }, "additionalProperties": false @@ -10901,7 +11003,7 @@ }, "count": { "type": "integer", - "format": "int32" + "format": "int64" } }, "additionalProperties": false @@ -11145,7 +11247,7 @@ }, "count": { "type": "integer", - "format": "int32" + "format": "int64" } }, "additionalProperties": false @@ -11277,7 +11379,7 @@ }, "count": { "type": "integer", - "format": "int32" + "format": "int64" } }, "additionalProperties": false @@ -11563,7 +11665,7 @@ }, "count": { "type": "integer", - "format": "int32" + "format": "int64" } }, "additionalProperties": false @@ -12590,7 +12692,7 @@ }, "count": { "type": "integer", - "format": "int32" + "format": "int64" } }, "additionalProperties": false @@ -13186,7 +13288,7 @@ }, "additionalProperties": false }, - "ServerStatistics": { + "ServerStatisticsDto": { "type": "object", "properties": { "chapterCount": { @@ -13270,7 +13372,7 @@ }, "count": { "type": "integer", - "format": "int32" + "format": "int64" } }, "additionalProperties": false @@ -14113,7 +14215,7 @@ }, "count": { "type": "integer", - "format": "int32" + "format": "int64" } }, "additionalProperties": false