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.
This commit is contained in:
Joe Milazzo 2023-01-11 08:12:31 -06:00 committed by GitHub
parent 84b7978587
commit 5613d1a954
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 2234 additions and 103 deletions

View File

@ -41,7 +41,7 @@ public class StatsController : BaseApiController
[Authorize("RequireAdminRole")]
[HttpGet("server/stats")]
[ResponseCache(CacheProfileName = "Statistics")]
public async Task<ActionResult<ServerStatistics>> GetHighLevelStats()
public async Task<ActionResult<ServerStatisticsDto>> GetHighLevelStats()
{
return Ok(await _statService.GetServerStatistics());
}
@ -141,4 +141,34 @@ public class StatsController : BaseApiController
return Ok(await _statService.GetReadingHistory(userId));
}
/// <summary>
/// Returns a count of pages read per year for a given userId.
/// </summary>
/// <param name="userId">If userId is 0 and user is not an admin, API will default to userId</param>
/// <returns></returns>
[HttpGet("pages-per-year")]
[ResponseCache(CacheProfileName = "Statistics")]
public async Task<ActionResult<IEnumerable<StatCount<int>>>> GetPagesReadPerYear(int userId = 0)
{
var isAdmin = User.IsInRole(PolicyConstants.AdminRole);
if (!isAdmin) userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(_statService.GetPagesReadCountByYear(userId));
}
/// <summary>
/// Returns a count of words read per year for a given userId.
/// </summary>
/// <param name="userId">If userId is 0 and user is not an admin, API will default to userId</param>
/// <returns></returns>
[HttpGet("words-per-year")]
[ResponseCache(CacheProfileName = "Statistics")]
public async Task<ActionResult<IEnumerable<StatCount<int>>>> GetWordsReadPerYear(int userId = 0)
{
var isAdmin = User.IsInRole(PolicyConstants.AdminRole);
if (!isAdmin) userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(_statService.GetWordsReadCountByYear(userId));
}
}

View File

@ -42,7 +42,7 @@ public class WantToReadController : BaseApiController
}
[HttpGet]
public async Task<ActionResult<bool>> GetWantToRead([FromQuery] int seriesId)
public async Task<ActionResult<bool>> IsSeriesInWantToRead([FromQuery] int seriesId)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.SeriesRepository.IsSeriesInWantToRead(user.Id, seriesId));

View File

@ -3,5 +3,5 @@
public class StatCount<T> : ICount<T>
{
public T Value { get; set; }
public int Count { get; set; }
public long Count { get; set; }
}

View File

@ -3,5 +3,5 @@
public interface ICount<T>
{
public T Value { get; set; }
public int Count { get; set; }
public long Count { get; set; }
}

View File

@ -12,10 +12,9 @@ public class PagesReadOnADayCount<T> : ICount<T>
/// <summary>
/// Number of pages read
/// </summary>
public int Count { get; set; }
public long Count { get; set; }
/// <summary>
/// Format of those files
/// </summary>
public MangaFormat Format { get; set; }
}

View File

@ -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; }

View File

@ -45,6 +45,7 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
public DbSet<SeriesRelation> SeriesRelation { get; set; }
public DbSet<FolderPath> FolderPath { get; set; }
public DbSet<Device> Device { get; set; }
public DbSet<ServerStatistics> ServerStatistics { get; set; }
protected override void OnModelCreating(ModelBuilder builder)

File diff suppressed because it is too large Load Diff

View File

@ -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<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Year = table.Column<int>(type: "INTEGER", nullable: false),
SeriesCount = table.Column<long>(type: "INTEGER", nullable: false),
VolumeCount = table.Column<long>(type: "INTEGER", nullable: false),
ChapterCount = table.Column<long>(type: "INTEGER", nullable: false),
FileCount = table.Column<long>(type: "INTEGER", nullable: false),
UserCount = table.Column<long>(type: "INTEGER", nullable: false),
GenreCount = table.Column<long>(type: "INTEGER", nullable: false),
PersonCount = table.Column<long>(type: "INTEGER", nullable: false),
TagCount = table.Column<long>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ServerStatistics", x => x.Id);
});
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ServerStatistics");
}
}
}

View File

@ -949,6 +949,44 @@ namespace API.Data.Migrations
b.ToTable("ServerSetting");
});
modelBuilder.Entity("API.Entities.ServerStatistics", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("ChapterCount")
.HasColumnType("INTEGER");
b.Property<long>("FileCount")
.HasColumnType("INTEGER");
b.Property<long>("GenreCount")
.HasColumnType("INTEGER");
b.Property<long>("PersonCount")
.HasColumnType("INTEGER");
b.Property<long>("SeriesCount")
.HasColumnType("INTEGER");
b.Property<long>("TagCount")
.HasColumnType("INTEGER");
b.Property<long>("UserCount")
.HasColumnType("INTEGER");
b.Property<long>("VolumeCount")
.HasColumnType("INTEGER");
b.Property<int>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("ServerStatistics");
});
modelBuilder.Entity("API.Entities.SiteTheme", b =>
{
b.Property<int>("Id")

View File

@ -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; }
}

View File

@ -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<ServerStatistics> GetServerStatistics();
Task<ServerStatisticsDto> GetServerStatistics();
Task<UserReadStatistics> GetUserReadStatistics(int userId, IList<int> libraryIds);
Task<IEnumerable<StatCount<int>>> GetYearCount();
Task<IEnumerable<StatCount<int>>> GetTopYears();
@ -28,6 +29,9 @@ public interface IStatisticService
Task<IEnumerable<ReadHistoryEvent>> GetReadingHistory(int userId);
Task<IEnumerable<PagesReadOnADayCount<DateTime>>> ReadCountByDay(int userId = 0, int days = 0);
IEnumerable<StatCount<DayOfWeek>> GetDayBreakdown();
IEnumerable<StatCount<int>> GetPagesReadCountByYear(int userId = 0);
IEnumerable<StatCount<int>> GetWordsReadCountByYear(int userId = 0);
Task UpdateServerStatistics();
}
/// <summary>
@ -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<ServerStatistics> GetServerStatistics()
public async Task<ServerStatisticsDto> 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();
}
/// <summary>
/// Return a list of years for the given userId
/// </summary>
/// <param name="userId"></param>
/// <returns></returns>
public IEnumerable<StatCount<int>> 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<int> {Value = g.Key, Count = g.Sum(x => x.PagesRead)})
.AsEnumerable();
}
public IEnumerable<StatCount<int>> 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<int>{
Value = g.Key,
Count = (long) Math.Round(g.Sum(p => p.chapter.WordCount * ((1.0f * p.progress.PagesRead) / p.chapter.Pages)))
})
.AsEnumerable();
}
/// <summary>
/// Updates the ServerStatistics table for the current year
/// </summary>
/// <remarks>This commits</remarks>
/// <returns></returns>
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<IEnumerable<TopReadDto>> GetTopUsers(int days)
{
var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList();
@ -434,9 +520,8 @@ public class StatisticService : IStatisticService
.ToList();
var chapterLibLookup = new Dictionary<int, int>();
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<TopReadDto>();
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();
}
}

View File

@ -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<TaskScheduler> 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

View File

@ -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<string>(this.baseUrl + 'account/migrate-email', model, {responseType: 'text' as 'json'});
return this.httpClient.post<string>(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<string>(this.baseUrl + 'account/resend-confirmation-email?userId=' + userId, {}, {responseType: 'text' as 'json'});
return this.httpClient.post<string>(this.baseUrl + 'account/resend-confirmation-email?userId=' + userId, {}, TextResonse);
}
inviteUser(model: {email: string, roles: Array<string>, libraries: Array<number>, ageRestriction: AgeRestriction}) {
@ -180,7 +181,7 @@ export class AccountService implements OnDestroy {
* @returns
*/
getInviteUrl(userId: number, withBaseUrl: boolean = true) {
return this.httpClient.get<string>(this.baseUrl + 'account/invite-url?userId=' + userId + '&withBaseUrl=' + withBaseUrl, {responseType: 'text' as 'json'});
return this.httpClient.get<string>(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<string>(this.baseUrl + 'account/forgot-password?email=' + encodeURIComponent(email), {}, {responseType: 'text' as 'json'});
return this.httpClient.post<string>(this.baseUrl + 'account/forgot-password?email=' + encodeURIComponent(email), {}, TextResonse);
}
confirmResetPasswordEmail(model: {email: string, token: string, password: string}) {
return this.httpClient.post<string>(this.baseUrl + 'account/confirm-password-reset', model, {responseType: 'text' as 'json'});
return this.httpClient.post<string>(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<string>, libraries: Array<number>, userId: number, ageRestriction: AgeRestriction}) {
@ -247,7 +248,7 @@ export class AccountService implements OnDestroy {
}
resetApiKey() {
return this.httpClient.post<string>(this.baseUrl + 'account/reset-api-key', {}, {responseType: 'text' as 'json'}).pipe(map(key => {
return this.httpClient.post<string>(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;

View File

@ -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<number>) {
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<number>, 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) {

View File

@ -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<number>, 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);
}

View File

@ -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<string>(this.baseUrl + 'series/age-rating?ageRating=' + ageRating, {responseType: 'text' as 'json'}).pipe(map(ratingString => {
return this.httpClient.get<string>(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<string>(this.baseUrl + 'metadata/chapter-summary?chapterId=' + chapterId, {responseType: 'text' as 'json'});
return this.httpClient.get<string>(this.baseUrl + 'metadata/chapter-summary?chapterId=' + chapterId, TextResonse);
}
}

View File

@ -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<number>) {
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);
}
/**

View File

@ -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<number>, chapterIds?: Array<number>) {
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<number>) {
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<string>(this.baseUrl + 'readinglist/remove-read?readingListId=' + readingListId, {}, { responseType: 'text' as 'json' });
return this.httpClient.post<string>(this.baseUrl + 'readinglist/remove-read?readingListId=' + readingListId, {}, TextResonse);
}
actionListFilter(action: ActionItem<ReadingList>, readingList: ReadingList, isAdmin: boolean) {

View File

@ -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<string>(this.baseUrl + 'want-to-read?seriesId=' + seriesId, {responseType: 'text' as 'json'})
return this.httpClient.get<string>(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) {

View File

@ -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
{
@ -62,6 +63,20 @@ export class StatisticsService {
})));
}
getPagesPerYear(userId = 0) {
return this.httpClient.get<StatCount<number>[]>(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<StatCount<number>[]>(this.baseUrl + 'stats/words-per-year?userId=' + userId).pipe(
map(spreads => spreads.map(spread => {
return {name: spread.value + '', value: spread.count};
})));
}
getTopUsers(days: number = 0) {
return this.httpClient.get<TopUserRead[]>(this.baseUrl + 'stats/server/top/users?days=' + days);
}
@ -85,7 +100,7 @@ export class StatisticsService {
}
getTotalSize() {
return this.httpClient.get<number>(this.baseUrl + 'stats/server/file-size', { responseType: 'text' as 'json'});
return this.httpClient.get<number>(this.baseUrl + 'stats/server/file-size', TextResonse);
}
getFileBreakdown() {

View File

@ -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<string>(this.baseUrl + 'theme/download-content?themeId=' + themeId, {responseType: 'text' as 'json'}).pipe(map(encodedCss => {
return this.httpClient.get<string>(this.baseUrl + 'theme/download-content?themeId=' + themeId, TextResonse).pipe(map(encodedCss => {
return this.domSantizer.sanitize(SecurityContext.STYLE, encodedCss);
}));
}

View File

@ -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<string>(this.baseUrl + 'upload/upload-by-url', {url}, {responseType: 'text' as 'json'});
return this.httpClient.post<string>(this.baseUrl + 'upload/upload-by-url', {url}, TextResonse);
}
/**

View File

@ -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'};

View File

@ -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<boolean>(this.baseUrl + 'settings/opds-enabled', {responseType: 'text' as 'json'});
return this.http.get<boolean>(this.baseUrl + 'settings/opds-enabled', TextResonse);
}
}

View File

@ -91,7 +91,8 @@
[innerHtml]="page" *ngIf="page !== undefined" (click)="toggleMenu($event)" (mousedown)="mouseDown($event)"></div>
<div *ngIf="page !== undefined && (scrollbarNeeded || layoutMode !== BookPageLayoutMode.Default)" (click)="$event.stopPropagation();" [ngClass]="{'bottom-bar': layoutMode !== BookPageLayoutMode.Default}">
<div *ngIf="page !== undefined && (scrollbarNeeded || layoutMode !== BookPageLayoutMode.Default)" (click)="$event.stopPropagation();"
[ngClass]="{'bottom-bar': layoutMode !== BookPageLayoutMode.Default}">
<ng-container [ngTemplateOutlet]="actionBar"></ng-container>
</div>
</div>

View File

@ -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 {

View File

@ -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;

View File

@ -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<string>(this.baseUrl + 'book/' + chapterId + '/book-page?page=' + page, {responseType: 'text' as 'json'});
return this.http.get<string>(this.baseUrl + 'book/' + chapterId + '/book-page?page=' + page, TextResonse);
}
getBookInfo(chapterId: number) {

View File

@ -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;

View File

@ -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();
});
}

View File

@ -15,7 +15,7 @@
<div class="list-group">
<li class="list-group-item d-flex justify-content-between align-items-center" *ngFor="let item of items | filter: filterList; let i = index">
{{item}}
<button class="btn btn-primary" [disabled]="clicked === undefined" (click)="handleClick(item)">
<button class="btn btn-primary" *ngIf="clicked !== undefined" (click)="handleClick(item)">
<i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i>
<span class="visually-hidden">Open a filtered search for {{item}}</span>
</button>

View File

@ -1,7 +1,7 @@
<div class="row g-0 mt-4 mb-3 d-flex justify-content-around">
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Total Pages Read" [clickable]="false" fontClasses="fa-regular fa-file-lines" title="Total Pages Read">
<app-icon-and-title label="Total Pages Read" [clickable]="true" fontClasses="fa-regular fa-file-lines" title="Total Pages Read" (click)="openPageByYearList();$event.stopPropagation();">
{{totalPagesRead | compactNumber}}
</app-icon-and-title>
</div>
@ -10,7 +10,7 @@
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Total Words Read" [clickable]="false" fontClasses="fa-regular fa-file-lines" title="Total Words Read">
<app-icon-and-title label="Total Words Read" [clickable]="false" fontClasses="fa-regular fa-file-lines" title="Total Words Read" (click)="openWordByYearList();$event.stopPropagation();">
{{totalWordsRead | compactNumber}}
</app-icon-and-title>
</div>

View File

@ -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';
});
}
}

View File

@ -19,7 +19,7 @@
<meta name="mobile-web-app-capable" content="yes">
</head>
<body class="mat-typography" theme="dark">
<body class="mat-typography default" theme="dark">
<app-root></app-root>
<noscript>Please enable JavaScript to continue using this application.</noscript>
</body>

View File

@ -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

View File

@ -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;
// }

View File

@ -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;
}

View File

@ -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