mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
Holiday Changes (#1706)
* Fixed a bug on bookmark mode not finding correct image for prefetcher. * Fixed up the edit series relationship modal on tablet viewports. * On double page mode, only bookmark 1 page if only 1 pages is renderered on screen. * Added percentage read of a given library and average hours read per week to user stats. * Fixed a bug in the reader with paging in bookmark mode * Added a "This Week" option to top readers history * Added date ranges for reading time. Added dates that don't have anything, but might remove. * On phone, when applying a metadata filter, when clicking apply, collapse the filter automatically. * Disable jump bar and the resuming from last spot when a custom sort is applied. * Ensure all Regex.Replace or Matches have timeouts set
This commit is contained in:
parent
13c1787165
commit
7b51fdfb2e
@ -108,16 +108,17 @@ public class StatsController : BaseApiController
|
|||||||
/// Returns reading history events for a give or all users, broken up by day, and format
|
/// Returns reading history events for a give or all users, broken up by day, and format
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="userId">If 0, defaults to all users, else just userId</param>
|
/// <param name="userId">If 0, defaults to all users, else just userId</param>
|
||||||
|
/// <param name="days">If 0, defaults to all time, else just those days asked for</param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[HttpGet("reading-count-by-day")]
|
[HttpGet("reading-count-by-day")]
|
||||||
[ResponseCache(CacheProfileName = "Statistics")]
|
[ResponseCache(CacheProfileName = "Statistics")]
|
||||||
public async Task<ActionResult<IEnumerable<PagesReadOnADayCount<DateTime>>>> ReadCountByDay(int userId = 0)
|
public async Task<ActionResult<IEnumerable<PagesReadOnADayCount<DateTime>>>> ReadCountByDay(int userId = 0, int days = 0)
|
||||||
{
|
{
|
||||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||||
var isAdmin = User.IsInRole(PolicyConstants.AdminRole);
|
var isAdmin = User.IsInRole(PolicyConstants.AdminRole);
|
||||||
if (!isAdmin && userId != user.Id) return BadRequest();
|
if (!isAdmin && userId != user.Id) return BadRequest();
|
||||||
|
|
||||||
return Ok(await _statService.ReadCountByDay(userId));
|
return Ok(await _statService.ReadCountByDay(userId, days));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -13,12 +13,9 @@ public class UserReadStatistics
|
|||||||
/// Total time spent reading based on estimates
|
/// Total time spent reading based on estimates
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public long TimeSpentReading { get; set; }
|
public long TimeSpentReading { get; set; }
|
||||||
/// <summary>
|
|
||||||
/// A list of genres mapped with genre and number of series that fall into said genre
|
|
||||||
/// </summary>
|
|
||||||
public ICollection<Tuple<string, long>> FavoriteGenres { get; set; }
|
|
||||||
|
|
||||||
public long ChaptersRead { get; set; }
|
public long ChaptersRead { get; set; }
|
||||||
public DateTime LastActive { get; set; }
|
public DateTime LastActive { get; set; }
|
||||||
public long AvgHoursPerWeekSpentReading { get; set; }
|
public double AvgHoursPerWeekSpentReading { get; set; }
|
||||||
|
public IEnumerable<StatCount<float>> PercentReadPerLibrary { get; set; }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -322,7 +322,7 @@ public class SeriesRepository : ISeriesRepository
|
|||||||
.ProjectTo<LibraryDto>(_mapper.ConfigurationProvider)
|
.ProjectTo<LibraryDto>(_mapper.ConfigurationProvider)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var justYear = Regex.Match(searchQuery, @"\d{4}").Value;
|
var justYear = Regex.Match(searchQuery, @"\d{4}", RegexOptions.None, Services.Tasks.Scanner.Parser.Parser.RegexTimeout).Value;
|
||||||
var hasYearInQuery = !string.IsNullOrEmpty(justYear);
|
var hasYearInQuery = !string.IsNullOrEmpty(justYear);
|
||||||
var yearComparison = hasYearInQuery ? int.Parse(justYear) : 0;
|
var yearComparison = hasYearInQuery ? int.Parse(justYear) : 0;
|
||||||
|
|
||||||
|
@ -535,8 +535,8 @@ public class BookService : IBookService
|
|||||||
|
|
||||||
private static string EscapeTags(string content)
|
private static string EscapeTags(string content)
|
||||||
{
|
{
|
||||||
content = Regex.Replace(content, @"<script(.*)(/>)", "<script$1></script>");
|
content = Regex.Replace(content, @"<script(.*)(/>)", "<script$1></script>", RegexOptions.None, Tasks.Scanner.Parser.Parser.RegexTimeout);
|
||||||
content = Regex.Replace(content, @"<title(.*)(/>)", "<title$1></title>");
|
content = Regex.Replace(content, @"<title(.*)(/>)", "<title$1></title>", RegexOptions.None, Tasks.Scanner.Parser.Parser.RegexTimeout);
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -995,12 +995,12 @@ public class BookService : IBookService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Remove comments from CSS
|
// Remove comments from CSS
|
||||||
body = Regex.Replace(body, @"/\*[\d\D]*?\*/", string.Empty);
|
body = Regex.Replace(body, @"/\*[\d\D]*?\*/", string.Empty, RegexOptions.None, Tasks.Scanner.Parser.Parser.RegexTimeout);
|
||||||
|
|
||||||
body = Regex.Replace(body, @"[a-zA-Z]+#", "#");
|
body = Regex.Replace(body, @"[a-zA-Z]+#", "#", RegexOptions.None, Tasks.Scanner.Parser.Parser.RegexTimeout);
|
||||||
body = Regex.Replace(body, @"[\n\r]+\s*", string.Empty);
|
body = Regex.Replace(body, @"[\n\r]+\s*", string.Empty, RegexOptions.None, Tasks.Scanner.Parser.Parser.RegexTimeout);
|
||||||
body = Regex.Replace(body, @"\s+", " ");
|
body = Regex.Replace(body, @"\s+", " ", RegexOptions.None, Tasks.Scanner.Parser.Parser.RegexTimeout);
|
||||||
body = Regex.Replace(body, @"\s?([:,;{}])\s?", "$1");
|
body = Regex.Replace(body, @"\s?([:,;{}])\s?", "$1", RegexOptions.None, Tasks.Scanner.Parser.Parser.RegexTimeout);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
body = body.Replace(";}", "}");
|
body = body.Replace(";}", "}");
|
||||||
@ -1010,7 +1010,7 @@ public class BookService : IBookService
|
|||||||
//Swallow exception. Some css don't have style rules ending in ';'
|
//Swallow exception. Some css don't have style rules ending in ';'
|
||||||
}
|
}
|
||||||
|
|
||||||
body = Regex.Replace(body, @"([\s:]0)(px|pt|%|em)", "$1");
|
body = Regex.Replace(body, @"([\s:]0)(px|pt|%|em)", "$1", RegexOptions.None, Tasks.Scanner.Parser.Parser.RegexTimeout);
|
||||||
|
|
||||||
|
|
||||||
return body;
|
return body;
|
||||||
|
@ -26,7 +26,7 @@ public interface IStatisticService
|
|||||||
Task<FileExtensionBreakdownDto> GetFileBreakdown();
|
Task<FileExtensionBreakdownDto> GetFileBreakdown();
|
||||||
Task<IEnumerable<TopReadDto>> GetTopUsers(int days);
|
Task<IEnumerable<TopReadDto>> GetTopUsers(int days);
|
||||||
Task<IEnumerable<ReadHistoryEvent>> GetReadingHistory(int userId);
|
Task<IEnumerable<ReadHistoryEvent>> GetReadingHistory(int userId);
|
||||||
Task<IEnumerable<PagesReadOnADayCount<DateTime>>> ReadCountByDay(int userId = 0);
|
Task<IEnumerable<PagesReadOnADayCount<DateTime>>> ReadCountByDay(int userId = 0, int days = 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -81,7 +81,34 @@ public class StatisticService : IStatisticService
|
|||||||
.Select(p => p.LastModified)
|
.Select(p => p.LastModified)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
//var
|
// Reading Progress by Library Name
|
||||||
|
|
||||||
|
// First get the total pages per library
|
||||||
|
var totalPageCountByLibrary = _context.Chapter
|
||||||
|
.Join(_context.Volume, c => c.VolumeId, v => v.Id, (chapter, volume) => new { chapter, volume })
|
||||||
|
.Join(_context.Series, g => g.volume.SeriesId, s => s.Id, (g, series) => new { g.chapter, series })
|
||||||
|
.AsEnumerable()
|
||||||
|
.GroupBy(g => g.series.LibraryId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.Sum(c => c.chapter.Pages));
|
||||||
|
//
|
||||||
|
//
|
||||||
|
var totalProgressByLibrary = await _context.AppUserProgresses
|
||||||
|
.Where(p => p.AppUserId == userId)
|
||||||
|
.Where(p => p.LibraryId > 0)
|
||||||
|
.GroupBy(p => p.LibraryId)
|
||||||
|
.Select(g => new StatCount<float>
|
||||||
|
{
|
||||||
|
Count = g.Key,
|
||||||
|
Value = g.Sum(p => p.PagesRead) / (float) totalPageCountByLibrary[g.Key]
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
|
||||||
|
var averageReadingTimePerWeek = _context.AppUserProgresses
|
||||||
|
.Where(p => p.AppUserId == userId)
|
||||||
|
.Join(_context.Chapter, p => p.ChapterId, c => c.Id,
|
||||||
|
(p, c) => (p.PagesRead / (float) c.Pages) * c.AvgHoursToRead)
|
||||||
|
.Average() / 7;
|
||||||
|
|
||||||
return new UserReadStatistics()
|
return new UserReadStatistics()
|
||||||
{
|
{
|
||||||
@ -89,6 +116,8 @@ public class StatisticService : IStatisticService
|
|||||||
TimeSpentReading = timeSpentReading,
|
TimeSpentReading = timeSpentReading,
|
||||||
ChaptersRead = chaptersRead,
|
ChaptersRead = chaptersRead,
|
||||||
LastActive = lastActive,
|
LastActive = lastActive,
|
||||||
|
PercentReadPerLibrary = totalProgressByLibrary,
|
||||||
|
AvgHoursPerWeekSpentReading = averageReadingTimePerWeek
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -297,7 +326,7 @@ public class StatisticService : IStatisticService
|
|||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IEnumerable<PagesReadOnADayCount<DateTime>>> ReadCountByDay(int userId = 0)
|
public async Task<IEnumerable<PagesReadOnADayCount<DateTime>>> ReadCountByDay(int userId = 0, int days = 0)
|
||||||
{
|
{
|
||||||
var query = _context.AppUserProgresses
|
var query = _context.AppUserProgresses
|
||||||
.AsSplitQuery()
|
.AsSplitQuery()
|
||||||
@ -314,7 +343,13 @@ public class StatisticService : IStatisticService
|
|||||||
query = query.Where(x => x.appUserProgresses.AppUserId == userId);
|
query = query.Where(x => x.appUserProgresses.AppUserId == userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await query.GroupBy(x => new
|
if (days > 0)
|
||||||
|
{
|
||||||
|
var date = DateTime.Now.AddDays(days * -1);
|
||||||
|
query = query.Where(x => x.appUserProgresses.LastModified >= date && x.appUserProgresses.Created >= date);
|
||||||
|
}
|
||||||
|
|
||||||
|
var results = await query.GroupBy(x => new
|
||||||
{
|
{
|
||||||
Day = x.appUserProgresses.Created.Date,
|
Day = x.appUserProgresses.Created.Date,
|
||||||
x.series.Format
|
x.series.Format
|
||||||
@ -327,6 +362,23 @@ public class StatisticService : IStatisticService
|
|||||||
})
|
})
|
||||||
.OrderBy(d => d.Value)
|
.OrderBy(d => d.Value)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
|
if (results.Count > 0)
|
||||||
|
{
|
||||||
|
var minDay = results.Min(d => d.Value);
|
||||||
|
for (var date = minDay; date < DateTime.Now; date = date.AddDays(1))
|
||||||
|
{
|
||||||
|
if (results.Any(d => d.Value == date)) continue;
|
||||||
|
results.Add(new PagesReadOnADayCount<DateTime>()
|
||||||
|
{
|
||||||
|
Format = MangaFormat.Unknown,
|
||||||
|
Value = date,
|
||||||
|
Count = 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IEnumerable<TopReadDto>> GetTopUsers(int days)
|
public async Task<IEnumerable<TopReadDto>> GetTopUsers(int days)
|
||||||
|
@ -911,7 +911,7 @@ public static class Parser
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!Regex.IsMatch(range, @"^[\d\-.]+$"))
|
if (!Regex.IsMatch(range, @"^[\d\-.]+$", MatchOptions, RegexTimeout))
|
||||||
{
|
{
|
||||||
return (float) 0.0;
|
return (float) 0.0;
|
||||||
}
|
}
|
||||||
@ -929,7 +929,7 @@ public static class Parser
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!Regex.IsMatch(range, @"^[\d\-.]+$"))
|
if (!Regex.IsMatch(range, @"^[\d\-.]+$", MatchOptions, RegexTimeout))
|
||||||
{
|
{
|
||||||
return (float) 0.0;
|
return (float) 0.0;
|
||||||
}
|
}
|
||||||
|
@ -82,7 +82,7 @@ export class StatisticsService {
|
|||||||
return this.httpClient.get<FileExtensionBreakdown>(this.baseUrl + 'stats/server/file-breakdown');
|
return this.httpClient.get<FileExtensionBreakdown>(this.baseUrl + 'stats/server/file-breakdown');
|
||||||
}
|
}
|
||||||
|
|
||||||
getReadCountByDay(userId: number = 0) {
|
getReadCountByDay(userId: number = 0, days: number = 0) {
|
||||||
return this.httpClient.get<Array<any>>(this.baseUrl + 'stats/reading-count-by-day?userId=' + userId);
|
return this.httpClient.get<Array<any>>(this.baseUrl + 'stats/reading-count-by-day?userId=' + userId + '&days=' + days);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -56,7 +56,7 @@
|
|||||||
<ng-template #jumpBar>
|
<ng-template #jumpBar>
|
||||||
<div class="jump-bar">
|
<div class="jump-bar">
|
||||||
<ng-container *ngFor="let jumpKey of jumpBarKeysToRender; let i = index;">
|
<ng-container *ngFor="let jumpKey of jumpBarKeysToRender; let i = index;">
|
||||||
<button class="btn btn-link" (click)="scrollTo(jumpKey)">
|
<button class="btn btn-link" [ngClass]="{'disabled': hasCustomSort()}" (click)="scrollTo(jumpKey)">
|
||||||
{{jumpKey.title}}
|
{{jumpKey.title}}
|
||||||
</button>
|
</button>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
@ -83,6 +83,11 @@
|
|||||||
.active {
|
.active {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
color: lightgrey;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -116,14 +116,17 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, OnChanges {
|
|||||||
this.jumpBarKeysToRender = [...this.jumpBarKeys];
|
this.jumpBarKeysToRender = [...this.jumpBarKeys];
|
||||||
this.resizeJumpBar();
|
this.resizeJumpBar();
|
||||||
|
|
||||||
if (!this.hasResumedJumpKey && this.jumpBarKeysToRender.length > 0) {
|
// Don't resume jump key when there is a custom sort order, as it won't work
|
||||||
const resumeKey = this.jumpbarService.getResumeKey(this.router.url);
|
if (this.hasCustomSort()) {
|
||||||
if (resumeKey === '') return;
|
if (!this.hasResumedJumpKey && this.jumpBarKeysToRender.length > 0) {
|
||||||
const keys = this.jumpBarKeysToRender.filter(k => k.key === resumeKey);
|
const resumeKey = this.jumpbarService.getResumeKey(this.router.url);
|
||||||
if (keys.length < 1) return;
|
if (resumeKey === '') return;
|
||||||
|
const keys = this.jumpBarKeysToRender.filter(k => k.key === resumeKey);
|
||||||
this.hasResumedJumpKey = true;
|
if (keys.length < 1) return;
|
||||||
setTimeout(() => this.scrollTo(keys[0]), 100);
|
|
||||||
|
this.hasResumedJumpKey = true;
|
||||||
|
setTimeout(() => this.scrollTo(keys[0]), 100);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -133,6 +136,10 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, OnChanges {
|
|||||||
this.onDestory.complete();
|
this.onDestory.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasCustomSort() {
|
||||||
|
return this.filter.sortOptions !== null || this.filterSettings.presets?.sortOptions !== null;
|
||||||
|
}
|
||||||
|
|
||||||
performAction(action: ActionItem<any>) {
|
performAction(action: ActionItem<any>) {
|
||||||
if (typeof action.callback === 'function') {
|
if (typeof action.callback === 'function') {
|
||||||
action.callback(action, undefined);
|
action.callback(action, undefined);
|
||||||
@ -147,6 +154,8 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, OnChanges {
|
|||||||
|
|
||||||
|
|
||||||
scrollTo(jumpKey: JumpKey) {
|
scrollTo(jumpKey: JumpKey) {
|
||||||
|
if (this.hasCustomSort()) return;
|
||||||
|
|
||||||
let targetIndex = 0;
|
let targetIndex = 0;
|
||||||
for(var i = 0; i < this.jumpBarKeys.length; i++) {
|
for(var i = 0; i < this.jumpBarKeys.length; i++) {
|
||||||
if (this.jumpBarKeys[i].key === jumpKey.key) break;
|
if (this.jumpBarKeys[i].key === jumpKey.key) break;
|
||||||
|
@ -10,8 +10,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form>
|
<form>
|
||||||
<div class="row g-0 mb-3" *ngFor="let relation of relations; let idx = index; let isLast = last;">
|
<div class="row g-0" *ngFor="let relation of relations; let idx = index; let isLast = last;">
|
||||||
<div class="col-sm-12 col-md-7">
|
<div class="col-sm-12 col-md-12 col-lg-7 mb-3">
|
||||||
<app-typeahead (selectedData)="updateSeries($event, relation)" [settings]="relation.typeaheadSettings" id="relation--{{idx}}" [focus]="focusTypeahead">
|
<app-typeahead (selectedData)="updateSeries($event, relation)" [settings]="relation.typeaheadSettings" id="relation--{{idx}}" [focus]="focusTypeahead">
|
||||||
<ng-template #badgeItem let-item let-position="idx">
|
<ng-template #badgeItem let-item let-position="idx">
|
||||||
{{item.name}} ({{libraryNames[item.libraryId]}})
|
{{item.name}} ({{libraryNames[item.libraryId]}})
|
||||||
@ -21,13 +21,13 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
</app-typeahead>
|
</app-typeahead>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm-auto col-md-3">
|
<div class="col-sm-12 col-md-10 col-lg-3 mb-3">
|
||||||
<select class="form-select" [formControl]="relation.formControl">
|
<select class="form-select" [formControl]="relation.formControl">
|
||||||
<option [value]="RelationKind.Parent" disabled>Parent</option>
|
<option [value]="RelationKind.Parent" disabled>Parent</option>
|
||||||
<option *ngFor="let opt of relationOptions" [value]="opt.value">{{opt.text}}</option>
|
<option *ngFor="let opt of relationOptions" [value]="opt.value">{{opt.text}}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button class="col-sm-auto col-md-2 btn btn-outline-secondary" (click)="removeRelation(idx)">Remove</button>
|
<button class="col-sm-auto col-md-2 mb-3 btn btn-outline-secondary" (click)="removeRelation(idx)"><i class="fa fa-trash"></i><span class="visually-hidden">Remove</span></button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
@ -251,4 +251,8 @@ export class CanvasRendererComponent implements OnInit, AfterViewInit, OnDestroy
|
|||||||
this.imageHeight.emit(this.canvas.nativeElement.height);
|
this.imageHeight.emit(this.canvas.nativeElement.height);
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getBookmarkPageCount(): number {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -282,6 +282,10 @@ export class DoubleRendererComponent implements OnInit, OnDestroy, ImageRenderer
|
|||||||
}
|
}
|
||||||
reset(): void {}
|
reset(): void {}
|
||||||
|
|
||||||
|
getBookmarkPageCount(): number {
|
||||||
|
return this.shouldRenderDouble() ? 2 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
debugLog(message: string, extraData?: any) {
|
debugLog(message: string, extraData?: any) {
|
||||||
if (!(this.debugMode & DEBUG_MODES.Logs)) return;
|
if (!(this.debugMode & DEBUG_MODES.Logs)) return;
|
||||||
|
|
||||||
|
@ -296,6 +296,10 @@ export class DoubleReverseRendererComponent implements OnInit, OnDestroy, ImageR
|
|||||||
}
|
}
|
||||||
reset(): void {}
|
reset(): void {}
|
||||||
|
|
||||||
|
getBookmarkPageCount(): number {
|
||||||
|
return this.shouldRenderDouble() ? 2 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
debugLog(message: string, extraData?: any) {
|
debugLog(message: string, extraData?: any) {
|
||||||
if (!(this.debugMode & DEBUG_MODES.Logs)) return;
|
if (!(this.debugMode & DEBUG_MODES.Logs)) return;
|
||||||
|
|
||||||
|
@ -627,11 +627,14 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
* Gets a page from cache else gets a brand new Image
|
* Gets a page from cache else gets a brand new Image
|
||||||
* @param pageNum Page Number to load
|
* @param pageNum Page Number to load
|
||||||
* @param forceNew Forces to fetch a new image
|
* @param forceNew Forces to fetch a new image
|
||||||
* @param chapterId ChapterId to fetch page from. Defaults to current chapterId
|
* @param chapterId ChapterId to fetch page from. Defaults to current chapterId. Not used when in bookmark mode
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
getPage(pageNum: number, chapterId: number = this.chapterId, forceNew: boolean = false) {
|
getPage(pageNum: number, chapterId: number = this.chapterId, forceNew: boolean = false) {
|
||||||
let img = this.cachedImages.find(img => this.readerService.imageUrlToPageNum(img.src) === pageNum
|
|
||||||
|
let img = undefined;
|
||||||
|
if (this.bookmarkMode) img = this.cachedImages.find(img => this.readerService.imageUrlToPageNum(img.src) === pageNum);
|
||||||
|
else img = this.cachedImages.find(img => this.readerService.imageUrlToPageNum(img.src) === pageNum
|
||||||
&& (this.readerService.imageUrlToChapterId(img.src) == chapterId || this.readerService.imageUrlToChapterId(img.src) === -1)
|
&& (this.readerService.imageUrlToChapterId(img.src) == chapterId || this.readerService.imageUrlToChapterId(img.src) === -1)
|
||||||
);
|
);
|
||||||
if (!img || forceNew) {
|
if (!img || forceNew) {
|
||||||
@ -1273,7 +1276,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
if (this.bookmarkMode) return;
|
if (this.bookmarkMode) return;
|
||||||
|
|
||||||
const pageNum = this.pageNum;
|
const pageNum = this.pageNum;
|
||||||
const isDouble = this.layoutMode === LayoutMode.Double || this.layoutMode === LayoutMode.DoubleReversed;
|
const isDouble = Math.max(this.canvasRenderer.getBookmarkPageCount(), this.singleRenderer.getBookmarkPageCount(),
|
||||||
|
this.doubleRenderer.getBookmarkPageCount(), this.doubleReverseRenderer.getBookmarkPageCount()) > 1;
|
||||||
|
|
||||||
if (this.CurrentPageBookmarked) {
|
if (this.CurrentPageBookmarked) {
|
||||||
let apis = [this.readerService.unbookmark(this.seriesId, this.volumeId, this.chapterId, pageNum)];
|
let apis = [this.readerService.unbookmark(this.seriesId, this.volumeId, this.chapterId, pageNum)];
|
||||||
|
@ -143,4 +143,8 @@ export class SingleRendererComponent implements OnInit, OnDestroy, ImageRenderer
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
reset(): void {}
|
reset(): void {}
|
||||||
|
|
||||||
|
getBookmarkPageCount(): number {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -55,4 +55,8 @@ export interface ImageRenderer {
|
|||||||
* This should reset any needed state, but not unset the image.
|
* This should reset any needed state, but not unset the image.
|
||||||
*/
|
*/
|
||||||
reset(): void;
|
reset(): void;
|
||||||
|
/**
|
||||||
|
* Returns the number of pages that are currently rendererd on screen and thus should be bookmarked.
|
||||||
|
*/
|
||||||
|
getBookmarkPageCount(): number;
|
||||||
}
|
}
|
@ -3,7 +3,7 @@ import { FormControl, FormGroup, Validators } from '@angular/forms';
|
|||||||
import { NgbCollapse } from '@ng-bootstrap/ng-bootstrap';
|
import { NgbCollapse } from '@ng-bootstrap/ng-bootstrap';
|
||||||
import { distinctUntilChanged, forkJoin, map, Observable, of, ReplaySubject, Subject, takeUntil } from 'rxjs';
|
import { distinctUntilChanged, forkJoin, map, Observable, of, ReplaySubject, Subject, takeUntil } from 'rxjs';
|
||||||
import { FilterUtilitiesService } from '../shared/_services/filter-utilities.service';
|
import { FilterUtilitiesService } from '../shared/_services/filter-utilities.service';
|
||||||
import { UtilityService } from '../shared/_services/utility.service';
|
import { Breakpoint, UtilityService } from '../shared/_services/utility.service';
|
||||||
import { TypeaheadSettings } from '../typeahead/_models/typeahead-settings';
|
import { TypeaheadSettings } from '../typeahead/_models/typeahead-settings';
|
||||||
import { CollectionTag } from '../_models/collection-tag';
|
import { CollectionTag } from '../_models/collection-tag';
|
||||||
import { Genre } from '../_models/metadata/genre';
|
import { Genre } from '../_models/metadata/genre';
|
||||||
@ -630,6 +630,11 @@ export class MetadataFilterComponent implements OnInit, OnDestroy {
|
|||||||
apply() {
|
apply() {
|
||||||
this.applyFilter.emit({filter: this.filter, isFirst: this.updateApplied === 0});
|
this.applyFilter.emit({filter: this.filter, isFirst: this.updateApplied === 0});
|
||||||
this.updateApplied++;
|
this.updateApplied++;
|
||||||
|
|
||||||
|
if (this.utilityService.getActiveBreakpoint() === Breakpoint.Mobile) {
|
||||||
|
this.toggleSelected();
|
||||||
|
}
|
||||||
|
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4,8 +4,8 @@
|
|||||||
<h4>Top Readers</h4>
|
<h4>Top Readers</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-8">
|
<div class="col-8">
|
||||||
<form [formGroup]="formGroup" class="d-inline-flex float-end" *ngIf="isAdmin">
|
<form [formGroup]="formGroup" class="d-inline-flex float-end">
|
||||||
<div class="d-flex">
|
<div class="d-flex me-1" *ngIf="isAdmin">
|
||||||
<label for="time-select-read-by-day" class="form-check-label"></label>
|
<label for="time-select-read-by-day" class="form-check-label"></label>
|
||||||
<select id="time-select-read-by-day" class="form-select" formControlName="users"
|
<select id="time-select-read-by-day" class="form-select" formControlName="users"
|
||||||
[class.is-invalid]="formGroup.get('users')?.invalid && formGroup.get('users')?.touched">
|
[class.is-invalid]="formGroup.get('users')?.invalid && formGroup.get('users')?.touched">
|
||||||
@ -13,6 +13,13 @@
|
|||||||
<option *ngFor="let item of users$ | async" [value]="item.id">{{item.username}}</option>
|
<option *ngFor="let item of users$ | async" [value]="item.id">{{item.username}}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="d-flex">
|
||||||
|
<label for="time-select-top-reads" class="form-check-label"></label>
|
||||||
|
<select id="time-select-top-reads" class="form-select" formControlName="days"
|
||||||
|
[class.is-invalid]="formGroup.get('days')?.invalid && formGroup.get('days')?.touched">
|
||||||
|
<option *ngFor="let item of timePeriods" [value]="item.value">{{item.title}}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -6,6 +6,7 @@ import { Member } from 'src/app/_models/auth/member';
|
|||||||
import { MemberService } from 'src/app/_services/member.service';
|
import { MemberService } from 'src/app/_services/member.service';
|
||||||
import { StatisticsService } from 'src/app/_services/statistics.service';
|
import { StatisticsService } from 'src/app/_services/statistics.service';
|
||||||
import { PieDataItem } from '../../_models/pie-data-item';
|
import { PieDataItem } from '../../_models/pie-data-item';
|
||||||
|
import { TimePeriods } from '../top-readers/top-readers.component';
|
||||||
|
|
||||||
const options: Intl.DateTimeFormatOptions = { month: "short", day: "numeric" };
|
const options: Intl.DateTimeFormatOptions = { month: "short", day: "numeric" };
|
||||||
const mangaFormatPipe = new MangaFormatPipe();
|
const mangaFormatPipe = new MangaFormatPipe();
|
||||||
@ -26,14 +27,16 @@ export class ReadByDayAndComponent implements OnInit, OnDestroy {
|
|||||||
view: [number, number] = [0, 400];
|
view: [number, number] = [0, 400];
|
||||||
formGroup: FormGroup = new FormGroup({
|
formGroup: FormGroup = new FormGroup({
|
||||||
'users': new FormControl(-1, []),
|
'users': new FormControl(-1, []),
|
||||||
|
'days': new FormControl(TimePeriods[0].value, []),
|
||||||
});
|
});
|
||||||
users$: Observable<Member[]> | undefined;
|
users$: Observable<Member[]> | undefined;
|
||||||
data$: Observable<Array<PieDataItem>>;
|
data$: Observable<Array<PieDataItem>>;
|
||||||
|
timePeriods = TimePeriods;
|
||||||
private readonly onDestroy = new Subject<void>();
|
private readonly onDestroy = new Subject<void>();
|
||||||
|
|
||||||
constructor(private statService: StatisticsService, private memberService: MemberService) {
|
constructor(private statService: StatisticsService, private memberService: MemberService) {
|
||||||
this.data$ = this.formGroup.get('users')!.valueChanges.pipe(
|
this.data$ = this.formGroup.valueChanges.pipe(
|
||||||
switchMap(uId => this.statService.getReadCountByDay(uId)),
|
switchMap(_ => this.statService.getReadCountByDay(this.formGroup.get('users')!.value, this.formGroup.get('days')!.value)),
|
||||||
map(data => {
|
map(data => {
|
||||||
const gList = data.reduce((formats, entry) => {
|
const gList = data.reduce((formats, entry) => {
|
||||||
const formatTranslated = mangaFormatPipe.transform(entry.format);
|
const formatTranslated = mangaFormatPipe.transform(entry.format);
|
||||||
|
@ -4,6 +4,8 @@ import { Observable, Subject, takeUntil, switchMap, shareReplay } from 'rxjs';
|
|||||||
import { StatisticsService } from 'src/app/_services/statistics.service';
|
import { StatisticsService } from 'src/app/_services/statistics.service';
|
||||||
import { TopUserRead } from '../../_models/top-reads';
|
import { TopUserRead } from '../../_models/top-reads';
|
||||||
|
|
||||||
|
export const TimePeriods: Array<{title: string, value: number}> = [{title: 'This Week', value: new Date().getDay() || 1}, {title: 'Last 7 Days', value: 7}, {title: 'Last 30 Days', value: 30}, {title: 'Last 90 Days', value: 90}, {title: 'Last Year', value: 365}, {title: 'All Time', value: 0}];
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-top-readers',
|
selector: 'app-top-readers',
|
||||||
templateUrl: './top-readers.component.html',
|
templateUrl: './top-readers.component.html',
|
||||||
@ -13,7 +15,7 @@ import { TopUserRead } from '../../_models/top-reads';
|
|||||||
export class TopReadersComponent implements OnInit, OnDestroy {
|
export class TopReadersComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
formGroup: FormGroup;
|
formGroup: FormGroup;
|
||||||
timePeriods: Array<{title: string, value: number}> = [{title: 'Last 7 Days', value: 7}, {title: 'Last 30 Days', value: 30}, {title: 'Last 90 Days', value: 90}, {title: 'Last Year', value: 365}, {title: 'All Time', value: 0}];
|
timePeriods = TimePeriods;
|
||||||
|
|
||||||
users$: Observable<TopUserRead[]>;
|
users$: Observable<TopUserRead[]>;
|
||||||
private readonly onDestroy = new Subject<void>();
|
private readonly onDestroy = new Subject<void>();
|
||||||
|
@ -17,6 +17,15 @@
|
|||||||
<div class="vr d-none d-lg-block m-2"></div>
|
<div class="vr d-none d-lg-block m-2"></div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container>
|
||||||
|
<div class="col-auto mb-2">
|
||||||
|
<app-icon-and-title label="Average Hours Read / Week" [clickable]="false" fontClasses="fas fa-eye" title="Average Hours Read / Week">
|
||||||
|
{{avgHoursPerWeekSpentReading | compactNumber}} hours
|
||||||
|
</app-icon-and-title>
|
||||||
|
</div>
|
||||||
|
<div class="vr d-none d-lg-block m-2"></div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
<ng-container>
|
<ng-container>
|
||||||
<div class="col-auto mb-2">
|
<div class="col-auto mb-2">
|
||||||
<app-icon-and-title label="Chapters Read" [clickable]="false" fontClasses="fa-regular fa-file-lines" title="Chapters Read">
|
<app-icon-and-title label="Chapters Read" [clickable]="false" fontClasses="fa-regular fa-file-lines" title="Chapters Read">
|
||||||
|
@ -14,6 +14,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-0 pt-4 pb-2 " style="height: 242px">
|
||||||
|
<app-stat-list [data$]="precentageRead$" label="% Read" title="Library Read Progress"></app-stat-list>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- <div class="row g-0">
|
<!-- <div class="row g-0">
|
||||||
Books Read (this can be chapters read fully)
|
Books Read (this can be chapters read fully)
|
||||||
Number of bookmarks
|
Number of bookmarks
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnDestroy, OnInit, QueryList, ViewChildren } from '@angular/core';
|
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnDestroy, OnInit, QueryList, ViewChildren } from '@angular/core';
|
||||||
import { map, Observable, of, Subject, takeUntil } from 'rxjs';
|
import { map, Observable, of, shareReplay, Subject, takeUntil } from 'rxjs';
|
||||||
import { FilterUtilitiesService } from 'src/app/shared/_services/filter-utilities.service';
|
import { FilterUtilitiesService } from 'src/app/shared/_services/filter-utilities.service';
|
||||||
import { Series } from 'src/app/_models/series';
|
import { Series } from 'src/app/_models/series';
|
||||||
import { UserReadStatistics } from 'src/app/statistics/_models/user-read-statistics';
|
import { UserReadStatistics } from 'src/app/statistics/_models/user-read-statistics';
|
||||||
@ -9,6 +9,10 @@ import { SortableHeader, SortEvent } from 'src/app/_single-module/table/_directi
|
|||||||
import { ReadHistoryEvent } from '../../_models/read-history-event';
|
import { ReadHistoryEvent } from '../../_models/read-history-event';
|
||||||
import { MemberService } from 'src/app/_services/member.service';
|
import { MemberService } from 'src/app/_services/member.service';
|
||||||
import { AccountService } from 'src/app/_services/account.service';
|
import { AccountService } from 'src/app/_services/account.service';
|
||||||
|
import { PieDataItem } from '../../_models/pie-data-item';
|
||||||
|
import { LibraryTypePipe } from 'src/app/pipe/library-type.pipe';
|
||||||
|
import { LibraryService } from 'src/app/_services/library.service';
|
||||||
|
import { PercentPipe } from '@angular/common';
|
||||||
|
|
||||||
type SeriesWithProgress = Series & {progress: number};
|
type SeriesWithProgress = Series & {progress: number};
|
||||||
|
|
||||||
@ -24,11 +28,13 @@ export class UserStatsComponent implements OnInit, OnDestroy {
|
|||||||
userStats$!: Observable<UserReadStatistics>;
|
userStats$!: Observable<UserReadStatistics>;
|
||||||
readSeries$!: Observable<ReadHistoryEvent[]>;
|
readSeries$!: Observable<ReadHistoryEvent[]>;
|
||||||
isAdmin$: Observable<boolean>;
|
isAdmin$: Observable<boolean>;
|
||||||
|
precentageRead$!: Observable<PieDataItem[]>;
|
||||||
|
|
||||||
private readonly onDestroy = new Subject<void>();
|
private readonly onDestroy = new Subject<void>();
|
||||||
|
|
||||||
constructor(private readonly cdRef: ChangeDetectorRef, private statService: StatisticsService,
|
constructor(private readonly cdRef: ChangeDetectorRef, private statService: StatisticsService,
|
||||||
private filterService: FilterUtilitiesService, private accountService: AccountService, private memberService: MemberService) {
|
private filterService: FilterUtilitiesService, private accountService: AccountService, private memberService: MemberService,
|
||||||
|
private libraryService: LibraryService) {
|
||||||
this.isAdmin$ = this.accountService.currentUser$.pipe(takeUntil(this.onDestroy), map(u => {
|
this.isAdmin$ = this.accountService.currentUser$.pipe(takeUntil(this.onDestroy), map(u => {
|
||||||
if (!u) return false;
|
if (!u) return false;
|
||||||
return this.accountService.hasAdminRole(u);
|
return this.accountService.hasAdminRole(u);
|
||||||
@ -43,10 +49,18 @@ export class UserStatsComponent implements OnInit, OnDestroy {
|
|||||||
this.userId = me.id;
|
this.userId = me.id;
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
|
|
||||||
this.userStats$ = this.statService.getUserStatistics(this.userId).pipe(takeUntil(this.onDestroy));
|
this.userStats$ = this.statService.getUserStatistics(this.userId).pipe(takeUntil(this.onDestroy), shareReplay());
|
||||||
this.readSeries$ = this.statService.getReadingHistory(this.userId).pipe(
|
this.readSeries$ = this.statService.getReadingHistory(this.userId).pipe(
|
||||||
takeUntil(this.onDestroy),
|
takeUntil(this.onDestroy),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const pipe = new PercentPipe('en-US');
|
||||||
|
this.libraryService.getLibraryNames().subscribe(names => {
|
||||||
|
this.precentageRead$ = this.userStats$.pipe(takeUntil(this.onDestroy), map(d => d.percentReadPerLibrary.map(l => {
|
||||||
|
return {name: names[l.count], value: parseFloat((pipe.transform(l.value, '1.1-1') || '0').replace('%', ''))};
|
||||||
|
}).sort((a: PieDataItem, b: PieDataItem) => b.value - a.value)));
|
||||||
|
})
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
|
import { StatCount } from "./stat-count";
|
||||||
|
|
||||||
export interface UserReadStatistics {
|
export interface UserReadStatistics {
|
||||||
totalPagesRead: number;
|
totalPagesRead: number;
|
||||||
timeSpentReading: number;
|
timeSpentReading: number;
|
||||||
favoriteGenres: Array<any>;
|
|
||||||
chaptersRead: number;
|
chaptersRead: number;
|
||||||
lastActive: string;
|
lastActive: string;
|
||||||
avgHoursPerWeekSpentReading: number;
|
avgHoursPerWeekSpentReading: number;
|
||||||
|
percentReadPerLibrary: Array<StatCount<number>>;
|
||||||
}
|
}
|
64
openapi.json
64
openapi.json
@ -7,7 +7,7 @@
|
|||||||
"name": "GPL-3.0",
|
"name": "GPL-3.0",
|
||||||
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
||||||
},
|
},
|
||||||
"version": "0.6.1.17"
|
"version": "0.6.1.18"
|
||||||
},
|
},
|
||||||
"servers": [
|
"servers": [
|
||||||
{
|
{
|
||||||
@ -7975,6 +7975,16 @@
|
|||||||
"format": "int32",
|
"format": "int32",
|
||||||
"default": 0
|
"default": 0
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "days",
|
||||||
|
"in": "query",
|
||||||
|
"description": "If 0, defaults to all time, else just those days asked for",
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"default": 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@ -11431,6 +11441,11 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "For Book reader, this can be an optional string of the id of a part marker, to help resume reading position\r\non pages that combine multiple \"chapters\".",
|
"description": "For Book reader, this can be an optional string of the id of a part marker, to help resume reading position\r\non pages that combine multiple \"chapters\".",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
|
},
|
||||||
|
"lastModified": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Last time the progress was synced from UI or external app",
|
||||||
|
"format": "date-time"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
@ -13152,6 +13167,20 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
"SingleStatCount": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"value": {
|
||||||
|
"type": "number",
|
||||||
|
"format": "float"
|
||||||
|
},
|
||||||
|
"count": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
"SiteTheme": {
|
"SiteTheme": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -13259,20 +13288,6 @@
|
|||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"description": "Sorting Options for a query"
|
"description": "Sorting Options for a query"
|
||||||
},
|
},
|
||||||
"StringInt64Tuple": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"item1": {
|
|
||||||
"type": "string",
|
|
||||||
"nullable": true
|
|
||||||
},
|
|
||||||
"item2": {
|
|
||||||
"type": "integer",
|
|
||||||
"format": "int64"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"additionalProperties": false
|
|
||||||
},
|
|
||||||
"Tag": {
|
"Tag": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -14140,14 +14155,6 @@
|
|||||||
"description": "Total time spent reading based on estimates",
|
"description": "Total time spent reading based on estimates",
|
||||||
"format": "int64"
|
"format": "int64"
|
||||||
},
|
},
|
||||||
"favoriteGenres": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/StringInt64Tuple"
|
|
||||||
},
|
|
||||||
"description": "A list of genres mapped with genre and number of series that fall into said genre",
|
|
||||||
"nullable": true
|
|
||||||
},
|
|
||||||
"chaptersRead": {
|
"chaptersRead": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int64"
|
"format": "int64"
|
||||||
@ -14157,8 +14164,15 @@
|
|||||||
"format": "date-time"
|
"format": "date-time"
|
||||||
},
|
},
|
||||||
"avgHoursPerWeekSpentReading": {
|
"avgHoursPerWeekSpentReading": {
|
||||||
"type": "integer",
|
"type": "number",
|
||||||
"format": "int64"
|
"format": "double"
|
||||||
|
},
|
||||||
|
"percentReadPerLibrary": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/SingleStatCount"
|
||||||
|
},
|
||||||
|
"nullable": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
|
Loading…
x
Reference in New Issue
Block a user