mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-07 18:24:13 -04:00
Reading List Polish (#1879)
* Use Reading Order to count epub pages rather than raw HTML files. * Send email on background thread for initial invite flow. * Reorder default writing style for new users so Horizontal is default * Changed reading activity to use average hours read rather than events to bring more meaningful data. * added ability to start reading incognito from the top of series detail, needs a bit of styling help though. * Refactored extensions out into their own package, added new fields for reading list to cover total run, cbl import now takes those dates and overrides on import. Replaced many instances of numbers to be comma separated. * Added ability to edit reading list run start and end year/month. Refactored some code for valid month/year into a helper method. * Added a way to see the reading list's release years. * Added some merged image code, but had to remove due to cover dimensions not fixed. * tweaked style for accessibility mode on reading list items * Tweaked css for non virtualized and virtualized containers * Fixed release updates failing * Commented out the merge code. * Typo on words read per year * Fixed unit tests * Fixed virtualized scroll * Cleanup CSS
This commit is contained in:
parent
266f302823
commit
fd6ee42f5f
@ -6,6 +6,7 @@ using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Metadata;
|
||||
using API.Extensions;
|
||||
using API.Extensions.QueryExtensions;
|
||||
using API.Helpers.Builders;
|
||||
using Xunit;
|
||||
|
||||
|
@ -16,6 +16,7 @@ using API.Extensions;
|
||||
using API.Services;
|
||||
using API.SignalR;
|
||||
using AutoMapper;
|
||||
using Hangfire;
|
||||
using Kavita.Common;
|
||||
using Kavita.Common.EnvironmentInfo;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@ -605,19 +606,14 @@ public class AccountController : BaseApiController
|
||||
var accessible = await _accountService.CheckIfAccessible(Request);
|
||||
if (accessible)
|
||||
{
|
||||
try
|
||||
// Do the email send on a background thread to ensure UI can move forward without having to wait for a timeout when users use fake emails
|
||||
BackgroundJob.Enqueue(() => _emailService.SendConfirmationEmail(new ConfirmationEmailDto()
|
||||
{
|
||||
await _emailService.SendConfirmationEmail(new ConfirmationEmailDto()
|
||||
{
|
||||
EmailAddress = dto.Email,
|
||||
InvitingUser = adminUser.UserName!,
|
||||
ServerConfirmationLink = emailLink
|
||||
});
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
/* Swallow exception */
|
||||
}
|
||||
EmailAddress = dto.Email,
|
||||
InvitingUser = adminUser.UserName!,
|
||||
ServerConfirmationLink = emailLink
|
||||
}));
|
||||
|
||||
}
|
||||
|
||||
return Ok(new InviteUserResponse
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Constants;
|
||||
using API.Data;
|
||||
@ -118,7 +119,10 @@ public class ImageController : BaseApiController
|
||||
public async Task<ActionResult> GetReadingListCoverImage(int readingListId)
|
||||
{
|
||||
var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.ReadingListRepository.GetCoverImageAsync(readingListId));
|
||||
if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"No cover image");
|
||||
if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path))
|
||||
{
|
||||
return BadRequest($"No cover image");
|
||||
}
|
||||
var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", string.Empty);
|
||||
|
||||
return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path));
|
||||
|
@ -33,28 +33,28 @@ public class CblReadingList
|
||||
/// </summary>
|
||||
/// <remarks>This is not a standard, adding based on discussion with CBL Maintainers</remarks>
|
||||
[XmlElement(ElementName="StartYear")]
|
||||
public int StartYear { get; set; }
|
||||
public int StartYear { get; set; } = -1;
|
||||
|
||||
/// <summary>
|
||||
/// Start Year of the Reading List. Overrides calculation
|
||||
/// </summary>
|
||||
/// <remarks>This is not a standard, adding based on discussion with CBL Maintainers</remarks>
|
||||
[XmlElement(ElementName="StartMonth")]
|
||||
public int StartMonth { get; set; }
|
||||
[XmlElement(ElementName = "StartMonth")]
|
||||
public int StartMonth { get; set; } = -1;
|
||||
|
||||
/// <summary>
|
||||
/// End Year of the Reading List. Overrides calculation
|
||||
/// </summary>
|
||||
/// <remarks>This is not a standard, adding based on discussion with CBL Maintainers</remarks>
|
||||
[XmlElement(ElementName="EndYear")]
|
||||
public int EndYear { get; set; }
|
||||
public int EndYear { get; set; } = -1;
|
||||
|
||||
/// <summary>
|
||||
/// End Year of the Reading List. Overrides calculation
|
||||
/// </summary>
|
||||
/// <remarks>This is not a standard, adding based on discussion with CBL Maintainers</remarks>
|
||||
[XmlElement(ElementName="EndMonth")]
|
||||
public int EndMonth { get; set; }
|
||||
public int EndMonth { get; set; } = -1;
|
||||
|
||||
/// <summary>
|
||||
/// Issues of the Reading List
|
||||
|
@ -1,4 +1,6 @@
|
||||
namespace API.DTOs.ReadingLists;
|
||||
using System;
|
||||
|
||||
namespace API.DTOs.ReadingLists;
|
||||
|
||||
public class ReadingListDto
|
||||
{
|
||||
@ -14,5 +16,21 @@ public class ReadingListDto
|
||||
/// This is used to tell the UI if it should request a Cover Image or not. If null or empty, it has not been set.
|
||||
/// </summary>
|
||||
public string CoverImage { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Minimum Year the Reading List starts
|
||||
/// </summary>
|
||||
public int StartingYear { get; set; }
|
||||
/// <summary>
|
||||
/// Minimum Month the Reading List starts
|
||||
/// </summary>
|
||||
public int StartingMonth { get; set; }
|
||||
/// <summary>
|
||||
/// Maximum Year the Reading List starts
|
||||
/// </summary>
|
||||
public int EndingYear { get; set; }
|
||||
/// <summary>
|
||||
/// Maximum Month the Reading List starts
|
||||
/// </summary>
|
||||
public int EndingMonth { get; set; }
|
||||
|
||||
}
|
||||
|
@ -10,4 +10,9 @@ public class UpdateReadingListDto
|
||||
public string Summary { get; set; } = string.Empty;
|
||||
public bool Promoted { get; set; }
|
||||
public bool CoverImageLocked { get; set; }
|
||||
public int StartingMonth { get; set; } = 0;
|
||||
public int StartingYear { get; set; } = 0;
|
||||
public int EndingMonth { get; set; } = 0;
|
||||
public int EndingYear { get; set; } = 0;
|
||||
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Enums.UserPreferences;
|
||||
using API.Entities.Interfaces;
|
||||
using API.Entities.Metadata;
|
||||
@ -86,10 +87,12 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
|
||||
builder.Entity<AppUserPreferences>()
|
||||
.Property(b => b.BackgroundColor)
|
||||
.HasDefaultValue("#000000");
|
||||
|
||||
builder.Entity<AppUserPreferences>()
|
||||
.Property(b => b.GlobalPageLayoutMode)
|
||||
.HasDefaultValue(PageLayoutMode.Cards);
|
||||
builder.Entity<AppUserPreferences>()
|
||||
.Property(b => b.BookReaderWritingStyle)
|
||||
.HasDefaultValue(WritingStyle.Horizontal);
|
||||
|
||||
|
||||
builder.Entity<Library>()
|
||||
|
1872
API/Data/Migrations/20230313125914_ReadingListDateRange.Designer.cs
generated
Normal file
1872
API/Data/Migrations/20230313125914_ReadingListDateRange.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
80
API/Data/Migrations/20230313125914_ReadingListDateRange.cs
Normal file
80
API/Data/Migrations/20230313125914_ReadingListDateRange.cs
Normal file
@ -0,0 +1,80 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ReadingListDateRange : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "EndingMonth",
|
||||
table: "ReadingList",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "EndingYear",
|
||||
table: "ReadingList",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "StartingMonth",
|
||||
table: "ReadingList",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "StartingYear",
|
||||
table: "ReadingList",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AlterColumn<int>(
|
||||
name: "BookReaderWritingStyle",
|
||||
table: "AppUserPreferences",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0,
|
||||
oldClrType: typeof(int),
|
||||
oldType: "INTEGER");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EndingMonth",
|
||||
table: "ReadingList");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EndingYear",
|
||||
table: "ReadingList");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "StartingMonth",
|
||||
table: "ReadingList");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "StartingYear",
|
||||
table: "ReadingList");
|
||||
|
||||
migrationBuilder.AlterColumn<int>(
|
||||
name: "BookReaderWritingStyle",
|
||||
table: "AppUserPreferences",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
oldClrType: typeof(int),
|
||||
oldType: "INTEGER",
|
||||
oldDefaultValue: 0);
|
||||
}
|
||||
}
|
||||
}
|
@ -225,7 +225,9 @@ namespace API.Data.Migrations
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BookReaderWritingStyle")
|
||||
.HasColumnType("INTEGER");
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.Property<string>("BookThemeName")
|
||||
.ValueGeneratedOnAdd()
|
||||
@ -871,6 +873,12 @@ namespace API.Data.Migrations
|
||||
b.Property<DateTime>("CreatedUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("EndingMonth")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("EndingYear")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("LastModified")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@ -883,6 +891,12 @@ namespace API.Data.Migrations
|
||||
b.Property<bool>("Promoted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("StartingMonth")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("StartingYear")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
@ -7,6 +7,7 @@ using API.DTOs.Metadata;
|
||||
using API.DTOs.Reader;
|
||||
using API.Entities;
|
||||
using API.Extensions;
|
||||
using API.Extensions.QueryExtensions;
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
@ -6,6 +6,7 @@ using API.Data.Misc;
|
||||
using API.DTOs.CollectionTags;
|
||||
using API.Entities;
|
||||
using API.Extensions;
|
||||
using API.Extensions.QueryExtensions;
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
@ -4,6 +4,7 @@ using System.Threading.Tasks;
|
||||
using API.DTOs.Metadata;
|
||||
using API.Entities;
|
||||
using API.Extensions;
|
||||
using API.Extensions.QueryExtensions;
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
@ -10,6 +10,7 @@ using API.DTOs.Metadata;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Extensions.QueryExtensions;
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
using Kavita.Common.Extensions;
|
||||
|
@ -4,6 +4,7 @@ using System.Threading.Tasks;
|
||||
using API.DTOs;
|
||||
using API.Entities;
|
||||
using API.Extensions;
|
||||
using API.Extensions.QueryExtensions;
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.DTOs;
|
||||
@ -6,6 +7,7 @@ using API.DTOs.ReadingLists;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Extensions.QueryExtensions;
|
||||
using API.Helpers;
|
||||
using API.Services;
|
||||
using AutoMapper;
|
||||
@ -15,10 +17,18 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace API.Data.Repositories;
|
||||
|
||||
[Flags]
|
||||
public enum ReadingListIncludes
|
||||
{
|
||||
None = 1,
|
||||
Items = 2,
|
||||
ItemChapter = 4,
|
||||
}
|
||||
|
||||
public interface IReadingListRepository
|
||||
{
|
||||
Task<PagedList<ReadingListDto>> GetReadingListDtosForUserAsync(int userId, bool includePromoted, UserParams userParams);
|
||||
Task<ReadingList?> GetReadingListByIdAsync(int readingListId);
|
||||
Task<ReadingList?> GetReadingListByIdAsync(int readingListId, ReadingListIncludes includes = ReadingListIncludes.None);
|
||||
Task<IEnumerable<ReadingListItemDto>> GetReadingListItemDtosByIdAsync(int readingListId, int userId);
|
||||
Task<ReadingListDto?> GetReadingListDtoByIdAsync(int readingListId, int userId);
|
||||
Task<IEnumerable<ReadingListItemDto>> AddReadingProgressModifiers(int userId, IList<ReadingListItemDto> items);
|
||||
@ -34,9 +44,9 @@ public interface IReadingListRepository
|
||||
Task<string?> GetCoverImageAsync(int readingListId);
|
||||
Task<IList<string>> GetAllCoverImagesAsync();
|
||||
Task<bool> ReadingListExists(string name);
|
||||
Task<List<ReadingList>> GetAllReadingListsAsync();
|
||||
IEnumerable<PersonDto> GetReadingListCharactersAsync(int readingListId);
|
||||
Task<IList<ReadingList>> GetAllWithNonWebPCovers();
|
||||
Task<IList<string>> GetFirstFourCoverImagesByReadingListId(int readingListId);
|
||||
}
|
||||
|
||||
public class ReadingListRepository : IReadingListRepository
|
||||
@ -88,15 +98,6 @@ public class ReadingListRepository : IReadingListRepository
|
||||
.AnyAsync(x => x.NormalizedTitle != null && x.NormalizedTitle.Equals(normalized));
|
||||
}
|
||||
|
||||
public async Task<List<ReadingList>> GetAllReadingListsAsync()
|
||||
{
|
||||
return await _context.ReadingList
|
||||
.Include(r => r.Items.OrderBy(i => i.Order))
|
||||
.AsSplitQuery()
|
||||
.OrderBy(l => l.Title)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public IEnumerable<PersonDto> GetReadingListCharactersAsync(int readingListId)
|
||||
{
|
||||
return _context.ReadingListItem
|
||||
@ -114,6 +115,23 @@ public class ReadingListRepository : IReadingListRepository
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// If less than 4 images exist, will return nothing back. Will not be full paths, but just cover image filenames
|
||||
/// </summary>
|
||||
/// <param name="readingListId"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="NotImplementedException"></exception>
|
||||
public async Task<IList<string>> GetFirstFourCoverImagesByReadingListId(int readingListId)
|
||||
{
|
||||
return await _context.ReadingListItem
|
||||
.Where(ri => ri.ReadingListId == readingListId)
|
||||
.Include(ri => ri.Chapter)
|
||||
.Where(ri => ri.Chapter.CoverImage != null)
|
||||
.Select(ri => ri.Chapter.CoverImage)
|
||||
.Take(4)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public void Remove(ReadingListItem item)
|
||||
{
|
||||
_context.ReadingListItem.Remove(item);
|
||||
@ -151,10 +169,11 @@ public class ReadingListRepository : IReadingListRepository
|
||||
return await query.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<ReadingList?> GetReadingListByIdAsync(int readingListId)
|
||||
public async Task<ReadingList?> GetReadingListByIdAsync(int readingListId, ReadingListIncludes includes = ReadingListIncludes.None)
|
||||
{
|
||||
return await _context.ReadingList
|
||||
.Where(r => r.Id == readingListId)
|
||||
.Includes(includes)
|
||||
.Include(r => r.Items.OrderBy(item => item.Order))
|
||||
.AsSplitQuery()
|
||||
.SingleOrDefaultAsync();
|
||||
|
@ -17,6 +17,7 @@ using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Metadata;
|
||||
using API.Extensions;
|
||||
using API.Extensions.QueryExtensions;
|
||||
using API.Helpers;
|
||||
using API.Services;
|
||||
using API.Services.Tasks;
|
||||
|
@ -4,6 +4,7 @@ using System.Threading.Tasks;
|
||||
using API.DTOs.Metadata;
|
||||
using API.Entities;
|
||||
using API.Extensions;
|
||||
using API.Extensions.QueryExtensions;
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
@ -9,6 +9,7 @@ using API.DTOs.Filtering;
|
||||
using API.DTOs.Reader;
|
||||
using API.Entities;
|
||||
using API.Extensions;
|
||||
using API.Extensions.QueryExtensions;
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
@ -7,14 +7,14 @@ namespace API.Entities.Enums;
|
||||
/// </summary>
|
||||
public enum WritingStyle
|
||||
{
|
||||
/// <summary>
|
||||
/// Vertical writing style for the book-reader
|
||||
/// </summary>
|
||||
[Description ("Vertical")]
|
||||
Vertical = 0,
|
||||
/// <summary>
|
||||
/// Horizontal writing style for the book-reader
|
||||
/// </summary>
|
||||
[Description ("Horizontal")]
|
||||
Horizontal = 1
|
||||
Horizontal = 0,
|
||||
/// <summary>
|
||||
/// Vertical writing style for the book-reader
|
||||
/// </summary>
|
||||
[Description ("Vertical")]
|
||||
Vertical = 1
|
||||
}
|
||||
|
@ -39,14 +39,22 @@ public class ReadingList : IEntityDate
|
||||
public DateTime LastModified { get; set; }
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
public DateTime LastModifiedUtc { get; set; }
|
||||
// /// <summary>
|
||||
// /// Minimum Year and Month the Reading List starts
|
||||
// /// </summary>
|
||||
// public DateOnly StartingYear { get; set; }
|
||||
// /// <summary>
|
||||
// /// Maximum Year and Month the Reading List starts
|
||||
// /// </summary>
|
||||
// public DateOnly EndingYear { get; set; }
|
||||
/// <summary>
|
||||
/// Minimum Year the Reading List starts
|
||||
/// </summary>
|
||||
public int StartingYear { get; set; }
|
||||
/// <summary>
|
||||
/// Minimum Month the Reading List starts
|
||||
/// </summary>
|
||||
public int StartingMonth { get; set; }
|
||||
/// <summary>
|
||||
/// Maximum Year the Reading List starts
|
||||
/// </summary>
|
||||
public int EndingYear { get; set; }
|
||||
/// <summary>
|
||||
/// Maximum Month the Reading List starts
|
||||
/// </summary>
|
||||
public int EndingMonth { get; set; }
|
||||
|
||||
// Relationships
|
||||
public int AppUserId { get; set; }
|
||||
|
@ -1,6 +1,7 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using API.Entities;
|
||||
using API.Helpers;
|
||||
using API.Parser;
|
||||
|
||||
namespace API.Extensions;
|
||||
@ -39,6 +40,6 @@ public static class ChapterListExtensions
|
||||
/// <returns></returns>
|
||||
public static int MinimumReleaseYear(this IList<Chapter> chapters)
|
||||
{
|
||||
return chapters.Select(v => v.ReleaseDate.Year).Where(y => y >= 1000).DefaultIfEmpty().Min();
|
||||
return chapters.Select(v => v.ReleaseDate.Year).Where(y => NumberHelper.IsValidYear(y)).DefaultIfEmpty().Min();
|
||||
}
|
||||
}
|
||||
|
148
API/Extensions/QueryExtensions/IncludesExtensions.cs
Normal file
148
API/Extensions/QueryExtensions/IncludesExtensions.cs
Normal file
@ -0,0 +1,148 @@
|
||||
using System.Linq;
|
||||
using API.Data.Repositories;
|
||||
using API.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace API.Extensions.QueryExtensions;
|
||||
|
||||
/// <summary>
|
||||
/// All extensions against IQueryable that enables the dynamic including based on bitwise flag pattern
|
||||
/// </summary>
|
||||
public static class IncludesExtensions
|
||||
{
|
||||
public static IQueryable<CollectionTag> Includes(this IQueryable<CollectionTag> queryable,
|
||||
CollectionTagIncludes includes)
|
||||
{
|
||||
if (includes.HasFlag(CollectionTagIncludes.SeriesMetadata))
|
||||
{
|
||||
queryable = queryable.Include(c => c.SeriesMetadatas);
|
||||
}
|
||||
|
||||
return queryable.AsSplitQuery();
|
||||
}
|
||||
|
||||
public static IQueryable<Chapter> Includes(this IQueryable<Chapter> queryable,
|
||||
ChapterIncludes includes)
|
||||
{
|
||||
if (includes.HasFlag(ChapterIncludes.Volumes))
|
||||
{
|
||||
queryable = queryable.Include(v => v.Volume);
|
||||
}
|
||||
|
||||
if (includes.HasFlag(ChapterIncludes.Files))
|
||||
{
|
||||
queryable = queryable
|
||||
.Include(c => c.Files);
|
||||
}
|
||||
|
||||
|
||||
return queryable.AsSplitQuery();
|
||||
}
|
||||
|
||||
public static IQueryable<Series> Includes(this IQueryable<Series> query,
|
||||
SeriesIncludes includeFlags)
|
||||
{
|
||||
if (includeFlags.HasFlag(SeriesIncludes.Library))
|
||||
{
|
||||
query = query.Include(u => u.Library);
|
||||
}
|
||||
|
||||
if (includeFlags.HasFlag(SeriesIncludes.Volumes))
|
||||
{
|
||||
query = query.Include(s => s.Volumes);
|
||||
}
|
||||
|
||||
if (includeFlags.HasFlag(SeriesIncludes.Chapters))
|
||||
{
|
||||
query = query
|
||||
.Include(s => s.Volumes)
|
||||
.ThenInclude(v => v.Chapters);
|
||||
}
|
||||
|
||||
if (includeFlags.HasFlag(SeriesIncludes.Related))
|
||||
{
|
||||
query = query.Include(s => s.Relations)
|
||||
.ThenInclude(r => r.TargetSeries)
|
||||
.Include(s => s.RelationOf);
|
||||
}
|
||||
|
||||
if (includeFlags.HasFlag(SeriesIncludes.Metadata))
|
||||
{
|
||||
query = query.Include(s => s.Metadata)
|
||||
.ThenInclude(m => m.CollectionTags.OrderBy(g => g.NormalizedTitle))
|
||||
.Include(s => s.Metadata)
|
||||
.ThenInclude(m => m.Genres.OrderBy(g => g.NormalizedTitle))
|
||||
.Include(s => s.Metadata)
|
||||
.ThenInclude(m => m.People)
|
||||
.Include(s => s.Metadata)
|
||||
.ThenInclude(m => m.Tags.OrderBy(g => g.NormalizedTitle));
|
||||
}
|
||||
|
||||
|
||||
return query.AsSplitQuery();
|
||||
}
|
||||
|
||||
public static IQueryable<AppUser> Includes(this IQueryable<AppUser> query, AppUserIncludes includeFlags)
|
||||
{
|
||||
if (includeFlags.HasFlag(AppUserIncludes.Bookmarks))
|
||||
{
|
||||
query = query.Include(u => u.Bookmarks);
|
||||
}
|
||||
|
||||
if (includeFlags.HasFlag(AppUserIncludes.Progress))
|
||||
{
|
||||
query = query.Include(u => u.Progresses);
|
||||
}
|
||||
|
||||
if (includeFlags.HasFlag(AppUserIncludes.ReadingLists))
|
||||
{
|
||||
query = query.Include(u => u.ReadingLists);
|
||||
}
|
||||
|
||||
if (includeFlags.HasFlag(AppUserIncludes.ReadingListsWithItems))
|
||||
{
|
||||
query = query.Include(u => u.ReadingLists)
|
||||
.ThenInclude(r => r.Items);
|
||||
}
|
||||
|
||||
if (includeFlags.HasFlag(AppUserIncludes.Ratings))
|
||||
{
|
||||
query = query.Include(u => u.Ratings);
|
||||
}
|
||||
|
||||
if (includeFlags.HasFlag(AppUserIncludes.UserPreferences))
|
||||
{
|
||||
query = query.Include(u => u.UserPreferences);
|
||||
}
|
||||
|
||||
if (includeFlags.HasFlag(AppUserIncludes.WantToRead))
|
||||
{
|
||||
query = query.Include(u => u.WantToRead);
|
||||
}
|
||||
|
||||
if (includeFlags.HasFlag(AppUserIncludes.Devices))
|
||||
{
|
||||
query = query.Include(u => u.Devices);
|
||||
}
|
||||
|
||||
return query.AsSplitQuery();
|
||||
}
|
||||
|
||||
public static IQueryable<ReadingList> Includes(this IQueryable<ReadingList> queryable,
|
||||
ReadingListIncludes includes)
|
||||
{
|
||||
if (includes.HasFlag(ReadingListIncludes.Items))
|
||||
{
|
||||
queryable = queryable.Include(r => r.Items.OrderBy(item => item.Order));
|
||||
}
|
||||
|
||||
if (includes.HasFlag(ReadingListIncludes.ItemChapter))
|
||||
{
|
||||
queryable = queryable
|
||||
.Include(r => r.Items.OrderBy(item => item.Order))
|
||||
.ThenInclude(ri => ri.Chapter);
|
||||
}
|
||||
|
||||
return queryable.AsSplitQuery();
|
||||
}
|
||||
}
|
92
API/Extensions/QueryExtensions/QueryableExtensions.cs
Normal file
92
API/Extensions/QueryExtensions/QueryableExtensions.cs
Normal file
@ -0,0 +1,92 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Linq.Expressions;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data.Misc;
|
||||
using API.Data.Repositories;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace API.Extensions.QueryExtensions;
|
||||
|
||||
public static class QueryableExtensions
|
||||
{
|
||||
public static Task<AgeRestriction> GetUserAgeRestriction(this DbSet<AppUser> queryable, int userId)
|
||||
{
|
||||
if (userId < 1)
|
||||
{
|
||||
return Task.FromResult(new AgeRestriction()
|
||||
{
|
||||
AgeRating = AgeRating.NotApplicable,
|
||||
IncludeUnknowns = true
|
||||
});
|
||||
}
|
||||
return queryable
|
||||
.AsNoTracking()
|
||||
.Where(u => u.Id == userId)
|
||||
.Select(u =>
|
||||
new AgeRestriction(){
|
||||
AgeRating = u.AgeRestriction,
|
||||
IncludeUnknowns = u.AgeRestrictionIncludeUnknowns
|
||||
})
|
||||
.SingleAsync();
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Applies restriction based on if the Library has restrictions (like include in search)
|
||||
/// </summary>
|
||||
/// <param name="query"></param>
|
||||
/// <param name="context"></param>
|
||||
/// <returns></returns>
|
||||
public static IQueryable<Library> IsRestricted(this IQueryable<Library> query, QueryContext context)
|
||||
{
|
||||
if (context.HasFlag(QueryContext.None)) return query;
|
||||
|
||||
if (context.HasFlag(QueryContext.Dashboard))
|
||||
{
|
||||
query = query.Where(l => l.IncludeInDashboard);
|
||||
}
|
||||
|
||||
if (context.HasFlag(QueryContext.Recommended))
|
||||
{
|
||||
query = query.Where(l => l.IncludeInRecommended);
|
||||
}
|
||||
|
||||
if (context.HasFlag(QueryContext.Search))
|
||||
{
|
||||
query = query.Where(l => l.IncludeInSearch);
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all libraries for a given user
|
||||
/// </summary>
|
||||
/// <param name="library"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="queryContext"></param>
|
||||
/// <returns></returns>
|
||||
public static IQueryable<int> GetUserLibraries(this IQueryable<Library> library, int userId, QueryContext queryContext = QueryContext.None)
|
||||
{
|
||||
return library
|
||||
.Include(l => l.AppUsers)
|
||||
.Where(lib => lib.AppUsers.Any(user => user.Id == userId))
|
||||
.IsRestricted(queryContext)
|
||||
.AsNoTracking()
|
||||
.AsSplitQuery()
|
||||
.Select(lib => lib.Id);
|
||||
}
|
||||
|
||||
public static IEnumerable<DateTime> Range(this DateTime startDate, int numberOfDays) =>
|
||||
Enumerable.Range(0, numberOfDays).Select(e => startDate.AddDays(e));
|
||||
|
||||
public static IQueryable<T> WhereIf<T>(this IQueryable<T> queryable, bool condition,
|
||||
Expression<Func<T, bool>> predicate)
|
||||
{
|
||||
return condition ? queryable.Where(predicate) : queryable;
|
||||
}
|
||||
}
|
96
API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs
Normal file
96
API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs
Normal file
@ -0,0 +1,96 @@
|
||||
using System.Linq;
|
||||
using API.Data.Misc;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.Extensions.QueryExtensions;
|
||||
|
||||
/// <summary>
|
||||
/// Responsible for restricting Entities based on an AgeRestriction
|
||||
/// </summary>
|
||||
public static class RestrictByAgeExtensions
|
||||
{
|
||||
public static IQueryable<Series> RestrictAgainstAgeRestriction(this IQueryable<Series> queryable, AgeRestriction restriction)
|
||||
{
|
||||
if (restriction.AgeRating == AgeRating.NotApplicable) return queryable;
|
||||
var q = queryable.Where(s => s.Metadata.AgeRating <= restriction.AgeRating);
|
||||
|
||||
if (!restriction.IncludeUnknowns)
|
||||
{
|
||||
return q.Where(s => s.Metadata.AgeRating != AgeRating.Unknown);
|
||||
}
|
||||
|
||||
//q.WhereIf(!restriction.IncludeUnknowns, s => s.Metadata.AgeRating != AgeRating.Unknown);
|
||||
|
||||
return q;
|
||||
}
|
||||
|
||||
public static IQueryable<CollectionTag> RestrictAgainstAgeRestriction(this IQueryable<CollectionTag> queryable, AgeRestriction restriction)
|
||||
{
|
||||
if (restriction.AgeRating == AgeRating.NotApplicable) return queryable;
|
||||
|
||||
if (restriction.IncludeUnknowns)
|
||||
{
|
||||
return queryable.Where(c => c.SeriesMetadatas.All(sm =>
|
||||
sm.AgeRating <= restriction.AgeRating));
|
||||
}
|
||||
|
||||
return queryable.Where(c => c.SeriesMetadatas.All(sm =>
|
||||
sm.AgeRating <= restriction.AgeRating && sm.AgeRating > AgeRating.Unknown));
|
||||
}
|
||||
|
||||
public static IQueryable<Genre> RestrictAgainstAgeRestriction(this IQueryable<Genre> queryable, AgeRestriction restriction)
|
||||
{
|
||||
if (restriction.AgeRating == AgeRating.NotApplicable) return queryable;
|
||||
|
||||
if (restriction.IncludeUnknowns)
|
||||
{
|
||||
return queryable.Where(c => c.SeriesMetadatas.All(sm =>
|
||||
sm.AgeRating <= restriction.AgeRating));
|
||||
}
|
||||
|
||||
return queryable.Where(c => c.SeriesMetadatas.All(sm =>
|
||||
sm.AgeRating <= restriction.AgeRating && sm.AgeRating > AgeRating.Unknown));
|
||||
}
|
||||
|
||||
public static IQueryable<Tag> RestrictAgainstAgeRestriction(this IQueryable<Tag> queryable, AgeRestriction restriction)
|
||||
{
|
||||
if (restriction.AgeRating == AgeRating.NotApplicable) return queryable;
|
||||
|
||||
if (restriction.IncludeUnknowns)
|
||||
{
|
||||
return queryable.Where(c => c.SeriesMetadatas.All(sm =>
|
||||
sm.AgeRating <= restriction.AgeRating));
|
||||
}
|
||||
|
||||
return queryable.Where(c => c.SeriesMetadatas.All(sm =>
|
||||
sm.AgeRating <= restriction.AgeRating && sm.AgeRating > AgeRating.Unknown));
|
||||
}
|
||||
|
||||
public static IQueryable<Person> RestrictAgainstAgeRestriction(this IQueryable<Person> queryable, AgeRestriction restriction)
|
||||
{
|
||||
if (restriction.AgeRating == AgeRating.NotApplicable) return queryable;
|
||||
|
||||
if (restriction.IncludeUnknowns)
|
||||
{
|
||||
return queryable.Where(c => c.SeriesMetadatas.All(sm =>
|
||||
sm.AgeRating <= restriction.AgeRating));
|
||||
}
|
||||
|
||||
return queryable.Where(c => c.SeriesMetadatas.All(sm =>
|
||||
sm.AgeRating <= restriction.AgeRating && sm.AgeRating > AgeRating.Unknown));
|
||||
}
|
||||
|
||||
public static IQueryable<ReadingList> RestrictAgainstAgeRestriction(this IQueryable<ReadingList> queryable, AgeRestriction restriction)
|
||||
{
|
||||
if (restriction.AgeRating == AgeRating.NotApplicable) return queryable;
|
||||
var q = queryable.Where(rl => rl.AgeRating <= restriction.AgeRating);
|
||||
|
||||
if (!restriction.IncludeUnknowns)
|
||||
{
|
||||
return q.Where(rl => rl.AgeRating != AgeRating.Unknown);
|
||||
}
|
||||
|
||||
return q;
|
||||
}
|
||||
}
|
@ -1,290 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Linq.Expressions;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data.Misc;
|
||||
using API.Data.Repositories;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace API.Extensions;
|
||||
|
||||
public static class QueryableExtensions
|
||||
{
|
||||
public static IQueryable<Series> RestrictAgainstAgeRestriction(this IQueryable<Series> queryable, AgeRestriction restriction)
|
||||
{
|
||||
if (restriction.AgeRating == AgeRating.NotApplicable) return queryable;
|
||||
var q = queryable.Where(s => s.Metadata.AgeRating <= restriction.AgeRating);
|
||||
if (!restriction.IncludeUnknowns)
|
||||
{
|
||||
return q.Where(s => s.Metadata.AgeRating != AgeRating.Unknown);
|
||||
}
|
||||
|
||||
return q;
|
||||
}
|
||||
|
||||
public static IQueryable<CollectionTag> RestrictAgainstAgeRestriction(this IQueryable<CollectionTag> queryable, AgeRestriction restriction)
|
||||
{
|
||||
if (restriction.AgeRating == AgeRating.NotApplicable) return queryable;
|
||||
|
||||
if (restriction.IncludeUnknowns)
|
||||
{
|
||||
return queryable.Where(c => c.SeriesMetadatas.All(sm =>
|
||||
sm.AgeRating <= restriction.AgeRating));
|
||||
}
|
||||
|
||||
return queryable.Where(c => c.SeriesMetadatas.All(sm =>
|
||||
sm.AgeRating <= restriction.AgeRating && sm.AgeRating > AgeRating.Unknown));
|
||||
}
|
||||
|
||||
public static IQueryable<Genre> RestrictAgainstAgeRestriction(this IQueryable<Genre> queryable, AgeRestriction restriction)
|
||||
{
|
||||
if (restriction.AgeRating == AgeRating.NotApplicable) return queryable;
|
||||
|
||||
if (restriction.IncludeUnknowns)
|
||||
{
|
||||
return queryable.Where(c => c.SeriesMetadatas.All(sm =>
|
||||
sm.AgeRating <= restriction.AgeRating));
|
||||
}
|
||||
|
||||
return queryable.Where(c => c.SeriesMetadatas.All(sm =>
|
||||
sm.AgeRating <= restriction.AgeRating && sm.AgeRating > AgeRating.Unknown));
|
||||
}
|
||||
|
||||
public static IQueryable<Tag> RestrictAgainstAgeRestriction(this IQueryable<Tag> queryable, AgeRestriction restriction)
|
||||
{
|
||||
if (restriction.AgeRating == AgeRating.NotApplicable) return queryable;
|
||||
|
||||
if (restriction.IncludeUnknowns)
|
||||
{
|
||||
return queryable.Where(c => c.SeriesMetadatas.All(sm =>
|
||||
sm.AgeRating <= restriction.AgeRating));
|
||||
}
|
||||
|
||||
return queryable.Where(c => c.SeriesMetadatas.All(sm =>
|
||||
sm.AgeRating <= restriction.AgeRating && sm.AgeRating > AgeRating.Unknown));
|
||||
}
|
||||
|
||||
public static IQueryable<Person> RestrictAgainstAgeRestriction(this IQueryable<Person> queryable, AgeRestriction restriction)
|
||||
{
|
||||
if (restriction.AgeRating == AgeRating.NotApplicable) return queryable;
|
||||
|
||||
if (restriction.IncludeUnknowns)
|
||||
{
|
||||
return queryable.Where(c => c.SeriesMetadatas.All(sm =>
|
||||
sm.AgeRating <= restriction.AgeRating));
|
||||
}
|
||||
|
||||
return queryable.Where(c => c.SeriesMetadatas.All(sm =>
|
||||
sm.AgeRating <= restriction.AgeRating && sm.AgeRating > AgeRating.Unknown));
|
||||
}
|
||||
|
||||
public static IQueryable<ReadingList> RestrictAgainstAgeRestriction(this IQueryable<ReadingList> queryable, AgeRestriction restriction)
|
||||
{
|
||||
if (restriction.AgeRating == AgeRating.NotApplicable) return queryable;
|
||||
var q = queryable.Where(rl => rl.AgeRating <= restriction.AgeRating);
|
||||
|
||||
if (!restriction.IncludeUnknowns)
|
||||
{
|
||||
return q.Where(rl => rl.AgeRating != AgeRating.Unknown);
|
||||
}
|
||||
|
||||
return q;
|
||||
}
|
||||
|
||||
public static Task<AgeRestriction> GetUserAgeRestriction(this DbSet<AppUser> queryable, int userId)
|
||||
{
|
||||
if (userId < 1)
|
||||
{
|
||||
return Task.FromResult(new AgeRestriction()
|
||||
{
|
||||
AgeRating = AgeRating.NotApplicable,
|
||||
IncludeUnknowns = true
|
||||
});
|
||||
}
|
||||
return queryable
|
||||
.AsNoTracking()
|
||||
.Where(u => u.Id == userId)
|
||||
.Select(u =>
|
||||
new AgeRestriction(){
|
||||
AgeRating = u.AgeRestriction,
|
||||
IncludeUnknowns = u.AgeRestrictionIncludeUnknowns
|
||||
})
|
||||
.SingleAsync();
|
||||
}
|
||||
|
||||
public static IQueryable<CollectionTag> Includes(this IQueryable<CollectionTag> queryable,
|
||||
CollectionTagIncludes includes)
|
||||
{
|
||||
if (includes.HasFlag(CollectionTagIncludes.SeriesMetadata))
|
||||
{
|
||||
queryable = queryable.Include(c => c.SeriesMetadatas);
|
||||
}
|
||||
|
||||
return queryable.AsSplitQuery();
|
||||
}
|
||||
|
||||
public static IQueryable<Chapter> Includes(this IQueryable<Chapter> queryable,
|
||||
ChapterIncludes includes)
|
||||
{
|
||||
if (includes.HasFlag(ChapterIncludes.Volumes))
|
||||
{
|
||||
queryable = queryable.Include(v => v.Volume);
|
||||
}
|
||||
|
||||
if (includes.HasFlag(ChapterIncludes.Files))
|
||||
{
|
||||
queryable = queryable
|
||||
.Include(c => c.Files);
|
||||
}
|
||||
|
||||
|
||||
return queryable.AsSplitQuery();
|
||||
}
|
||||
|
||||
public static IQueryable<Series> Includes(this IQueryable<Series> query,
|
||||
SeriesIncludes includeFlags)
|
||||
{
|
||||
if (includeFlags.HasFlag(SeriesIncludes.Library))
|
||||
{
|
||||
query = query.Include(u => u.Library);
|
||||
}
|
||||
|
||||
if (includeFlags.HasFlag(SeriesIncludes.Volumes))
|
||||
{
|
||||
query = query.Include(s => s.Volumes);
|
||||
}
|
||||
|
||||
if (includeFlags.HasFlag(SeriesIncludes.Chapters))
|
||||
{
|
||||
query = query
|
||||
.Include(s => s.Volumes)
|
||||
.ThenInclude(v => v.Chapters);
|
||||
}
|
||||
|
||||
if (includeFlags.HasFlag(SeriesIncludes.Related))
|
||||
{
|
||||
query = query.Include(s => s.Relations)
|
||||
.ThenInclude(r => r.TargetSeries)
|
||||
.Include(s => s.RelationOf);
|
||||
}
|
||||
|
||||
if (includeFlags.HasFlag(SeriesIncludes.Metadata))
|
||||
{
|
||||
query = query.Include(s => s.Metadata)
|
||||
.ThenInclude(m => m.CollectionTags.OrderBy(g => g.NormalizedTitle))
|
||||
.Include(s => s.Metadata)
|
||||
.ThenInclude(m => m.Genres.OrderBy(g => g.NormalizedTitle))
|
||||
.Include(s => s.Metadata)
|
||||
.ThenInclude(m => m.People)
|
||||
.Include(s => s.Metadata)
|
||||
.ThenInclude(m => m.Tags.OrderBy(g => g.NormalizedTitle));
|
||||
}
|
||||
|
||||
|
||||
return query.AsSplitQuery();
|
||||
}
|
||||
|
||||
public static IQueryable<AppUser> Includes(this IQueryable<AppUser> query, AppUserIncludes includeFlags)
|
||||
{
|
||||
if (includeFlags.HasFlag(AppUserIncludes.Bookmarks))
|
||||
{
|
||||
query = query.Include(u => u.Bookmarks);
|
||||
}
|
||||
|
||||
if (includeFlags.HasFlag(AppUserIncludes.Progress))
|
||||
{
|
||||
query = query.Include(u => u.Progresses);
|
||||
}
|
||||
|
||||
if (includeFlags.HasFlag(AppUserIncludes.ReadingLists))
|
||||
{
|
||||
query = query.Include(u => u.ReadingLists);
|
||||
}
|
||||
|
||||
if (includeFlags.HasFlag(AppUserIncludes.ReadingListsWithItems))
|
||||
{
|
||||
query = query.Include(u => u.ReadingLists)
|
||||
.ThenInclude(r => r.Items);
|
||||
}
|
||||
|
||||
if (includeFlags.HasFlag(AppUserIncludes.Ratings))
|
||||
{
|
||||
query = query.Include(u => u.Ratings);
|
||||
}
|
||||
|
||||
if (includeFlags.HasFlag(AppUserIncludes.UserPreferences))
|
||||
{
|
||||
query = query.Include(u => u.UserPreferences);
|
||||
}
|
||||
|
||||
if (includeFlags.HasFlag(AppUserIncludes.WantToRead))
|
||||
{
|
||||
query = query.Include(u => u.WantToRead);
|
||||
}
|
||||
|
||||
if (includeFlags.HasFlag(AppUserIncludes.Devices))
|
||||
{
|
||||
query = query.Include(u => u.Devices);
|
||||
}
|
||||
|
||||
return query.AsSplitQuery();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies restriction based on if the Library has restrictions (like include in search)
|
||||
/// </summary>
|
||||
/// <param name="query"></param>
|
||||
/// <param name="context"></param>
|
||||
/// <returns></returns>
|
||||
public static IQueryable<Library> IsRestricted(this IQueryable<Library> query, QueryContext context)
|
||||
{
|
||||
if (context.HasFlag(QueryContext.None)) return query;
|
||||
|
||||
if (context.HasFlag(QueryContext.Dashboard))
|
||||
{
|
||||
query = query.Where(l => l.IncludeInDashboard);
|
||||
}
|
||||
|
||||
if (context.HasFlag(QueryContext.Recommended))
|
||||
{
|
||||
query = query.Where(l => l.IncludeInRecommended);
|
||||
}
|
||||
|
||||
if (context.HasFlag(QueryContext.Search))
|
||||
{
|
||||
query = query.Where(l => l.IncludeInSearch);
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all libraries for a given user
|
||||
/// </summary>
|
||||
/// <param name="library"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="queryContext"></param>
|
||||
/// <returns></returns>
|
||||
public static IQueryable<int> GetUserLibraries(this IQueryable<Library> library, int userId, QueryContext queryContext = QueryContext.None)
|
||||
{
|
||||
return library
|
||||
.Include(l => l.AppUsers)
|
||||
.Where(lib => lib.AppUsers.Any(user => user.Id == userId))
|
||||
.IsRestricted(queryContext)
|
||||
.AsNoTracking()
|
||||
.AsSplitQuery()
|
||||
.Select(lib => lib.Id);
|
||||
}
|
||||
|
||||
public static IEnumerable<DateTime> Range(this DateTime startDate, int numberOfDays) =>
|
||||
Enumerable.Range(0, numberOfDays).Select(e => startDate.AddDays(e));
|
||||
|
||||
public static IQueryable<T> WhereIf<T>(this IQueryable<T> queryable, bool condition,
|
||||
Expression<Func<T, bool>> predicate)
|
||||
{
|
||||
return condition ? queryable.Where(predicate) : queryable;
|
||||
}
|
||||
}
|
7
API/Helpers/NumberHelper.cs
Normal file
7
API/Helpers/NumberHelper.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace API.Helpers;
|
||||
|
||||
public static class NumberHelper
|
||||
{
|
||||
public static bool IsValidMonth(int number) => number is > 0 and <= 12;
|
||||
public static bool IsValidYear(int number) => number is >= 1000;
|
||||
}
|
@ -571,7 +571,7 @@ public class BookService : IBookService
|
||||
}
|
||||
|
||||
using var epubBook = EpubReader.OpenBook(filePath, BookReaderOptions);
|
||||
return epubBook.Content.Html.Count;
|
||||
return epubBook.GetReadingOrder().Count;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@ -218,4 +219,23 @@ public class ImageService : IImageService
|
||||
}
|
||||
|
||||
|
||||
public static string CreateMergedImage(List<string> coverImages, string dest)
|
||||
{
|
||||
// TODO: Needs testing
|
||||
// Currently this doesn't work due to non-standard cover image sizes and dimensions
|
||||
var image = Image.Black(320*4, 160*4);
|
||||
|
||||
for (var i = 0; i < coverImages.Count; i++)
|
||||
{
|
||||
var tile = Image.NewFromFile(coverImages[i], access: Enums.Access.Sequential);
|
||||
|
||||
var x = (i % 2) * (image.Width / 2);
|
||||
var y = (i / 2) * (image.Height / 2);
|
||||
|
||||
image = image.Insert(tile, x, y);
|
||||
}
|
||||
|
||||
image.WriteToFile(dest);
|
||||
return dest;
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
@ -10,6 +11,7 @@ using API.DTOs.ReadingLists;
|
||||
using API.DTOs.ReadingLists.CBL;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Helpers;
|
||||
using API.SignalR;
|
||||
using Kavita.Common;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@ -31,6 +33,8 @@ public interface IReadingListService
|
||||
|
||||
Task<CblImportSummaryDto> ValidateCblFile(int userId, CblReadingList cblReading);
|
||||
Task<CblImportSummaryDto> CreateReadingListFromCbl(int userId, CblReadingList cblReading, bool dryRun = false);
|
||||
Task CalculateStartAndEndDates(ReadingList readingListWithItems);
|
||||
Task<string> GenerateMergedImage(int readingListId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -142,6 +146,25 @@ public class ReadingListService : IReadingListService
|
||||
readingList.Promoted = dto.Promoted;
|
||||
readingList.CoverImageLocked = dto.CoverImageLocked;
|
||||
|
||||
|
||||
if (NumberHelper.IsValidMonth(dto.StartingMonth))
|
||||
{
|
||||
readingList.StartingMonth = dto.StartingMonth;
|
||||
}
|
||||
if (NumberHelper.IsValidYear(dto.StartingYear))
|
||||
{
|
||||
readingList.StartingYear = dto.StartingYear;
|
||||
}
|
||||
if (NumberHelper.IsValidMonth(dto.EndingMonth))
|
||||
{
|
||||
readingList.EndingMonth = dto.EndingMonth;
|
||||
}
|
||||
if (NumberHelper.IsValidYear(dto.EndingYear))
|
||||
{
|
||||
readingList.EndingYear = dto.EndingYear;
|
||||
}
|
||||
|
||||
|
||||
if (!dto.CoverImageLocked)
|
||||
{
|
||||
readingList.CoverImageLocked = false;
|
||||
@ -182,6 +205,7 @@ public class ReadingListService : IReadingListService
|
||||
var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(readingListId);
|
||||
if (readingList == null) return true;
|
||||
await CalculateReadingListAgeRating(readingList);
|
||||
await CalculateStartAndEndDates(readingList);
|
||||
|
||||
if (!_unitOfWork.HasChanges()) return true;
|
||||
|
||||
@ -239,6 +263,7 @@ public class ReadingListService : IReadingListService
|
||||
}
|
||||
|
||||
await CalculateReadingListAgeRating(readingList);
|
||||
await CalculateStartAndEndDates(readingList);
|
||||
|
||||
if (!_unitOfWork.HasChanges()) return true;
|
||||
|
||||
@ -254,6 +279,52 @@ public class ReadingListService : IReadingListService
|
||||
await CalculateReadingListAgeRating(readingList, readingList.Items.Select(i => i.SeriesId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the Start month/year and Ending month/year
|
||||
/// </summary>
|
||||
/// <param name="readingListWithItems">Reading list should have all items</param>
|
||||
public async Task CalculateStartAndEndDates(ReadingList readingListWithItems)
|
||||
{
|
||||
var items = readingListWithItems.Items;
|
||||
if (readingListWithItems.Items.All(i => i.Chapter == null))
|
||||
{
|
||||
items =
|
||||
(await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(readingListWithItems.Id, ReadingListIncludes.ItemChapter))?.Items;
|
||||
}
|
||||
if (items == null || items.Count == 0) return;
|
||||
|
||||
if (items.First().Chapter == null)
|
||||
{
|
||||
_logger.LogError("Tried to calculate release dates for Reading List, but missing Chapter entities");
|
||||
return;
|
||||
}
|
||||
var maxReleaseDate = items.Max(item => item.Chapter.ReleaseDate);
|
||||
var minReleaseDate = items.Max(item => item.Chapter.ReleaseDate);
|
||||
if (maxReleaseDate != DateTime.MinValue)
|
||||
{
|
||||
readingListWithItems.EndingMonth = maxReleaseDate.Month;
|
||||
readingListWithItems.EndingYear = maxReleaseDate.Year;
|
||||
}
|
||||
if (minReleaseDate != DateTime.MinValue)
|
||||
{
|
||||
readingListWithItems.StartingMonth = minReleaseDate.Month;
|
||||
readingListWithItems.StartingYear = minReleaseDate.Year;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<string?> GenerateMergedImage(int readingListId)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
// var coverImages = (await _unitOfWork.ReadingListRepository.GetFirstFourCoverImagesByReadingListId(readingListId)).ToList();
|
||||
// if (coverImages.Count < 4) return null;
|
||||
// var fullImages = coverImages
|
||||
// .Select(c => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, c)).ToList();
|
||||
//
|
||||
// var combinedFile = ImageService.CreateMergedImage(fullImages, _directoryService.FileSystem.Path.Join(_directoryService.TempDirectory, $"{readingListId}.png"));
|
||||
// // webp needs to be handled
|
||||
// return combinedFile;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the highest Age Rating from each Reading List Item
|
||||
/// </summary>
|
||||
@ -522,6 +593,14 @@ public class ReadingListService : IReadingListService
|
||||
if (dryRun) return importSummary;
|
||||
|
||||
await CalculateReadingListAgeRating(readingList);
|
||||
await CalculateStartAndEndDates(readingList);
|
||||
|
||||
// For CBL Import only we override pre-calculated dates
|
||||
if (NumberHelper.IsValidMonth(cblReading.StartMonth)) readingList.StartingMonth = cblReading.StartMonth;
|
||||
if (NumberHelper.IsValidYear(cblReading.StartYear)) readingList.StartingYear = cblReading.StartYear;
|
||||
if (NumberHelper.IsValidMonth(cblReading.EndMonth)) readingList.EndingMonth = cblReading.EndMonth;
|
||||
if (NumberHelper.IsValidYear(cblReading.EndYear)) readingList.EndingYear = cblReading.EndYear;
|
||||
|
||||
if (!string.IsNullOrEmpty(readingList.Summary?.Trim()))
|
||||
{
|
||||
readingList.Summary = readingList.Summary?.Trim();
|
||||
|
@ -78,7 +78,7 @@ public class SeriesService : ISeriesService
|
||||
series.Metadata.AgeRatingLocked = true;
|
||||
}
|
||||
|
||||
if (updateSeriesMetadataDto.SeriesMetadata.ReleaseYear > 1000 && series.Metadata.ReleaseYear != updateSeriesMetadataDto.SeriesMetadata.ReleaseYear)
|
||||
if (NumberHelper.IsValidYear(updateSeriesMetadataDto.SeriesMetadata.ReleaseYear) && series.Metadata.ReleaseYear != updateSeriesMetadataDto.SeriesMetadata.ReleaseYear)
|
||||
{
|
||||
series.Metadata.ReleaseYear = updateSeriesMetadataDto.SeriesMetadata.ReleaseYear;
|
||||
series.Metadata.ReleaseYearLocked = true;
|
||||
|
@ -8,6 +8,7 @@ using API.DTOs.Statistics;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Extensions.QueryExtensions;
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@ -340,29 +341,26 @@ public class StatisticService : IStatisticService
|
||||
.Join(_context.Volume, x => x.chapter.VolumeId, volume => volume.Id,
|
||||
(x, volume) => new {x.appUserProgresses, x.chapter, volume})
|
||||
.Join(_context.Series, x => x.appUserProgresses.SeriesId, series => series.Id,
|
||||
(x, series) => new {x.appUserProgresses, x.chapter, x.volume, series});
|
||||
(x, series) => new {x.appUserProgresses, x.chapter, x.volume, series})
|
||||
.WhereIf(userId > 0, x => x.appUserProgresses.AppUserId == userId)
|
||||
.WhereIf(days > 0, x => x.appUserProgresses.LastModified >= DateTime.Now.AddDays(days * -1));
|
||||
|
||||
if (userId > 0)
|
||||
{
|
||||
query = query.Where(x => x.appUserProgresses.AppUserId == userId);
|
||||
}
|
||||
|
||||
if (days > 0)
|
||||
{
|
||||
var date = DateTime.Now.AddDays(days * -1);
|
||||
query = query.Where(x => x.appUserProgresses.LastModified >= date);
|
||||
}
|
||||
// .Where(p => p.chapter.AvgHoursToRead > 0)
|
||||
// .SumAsync(p =>
|
||||
// p.chapter.AvgHoursToRead * (p.progress.PagesRead / (1.0f * p.chapter.Pages))))
|
||||
|
||||
var results = await query.GroupBy(x => new
|
||||
{
|
||||
Day = x.appUserProgresses.LastModified.Date,
|
||||
x.series.Format
|
||||
x.series.Format,
|
||||
})
|
||||
.Select(g => new PagesReadOnADayCount<DateTime>
|
||||
{
|
||||
Value = g.Key.Day,
|
||||
Format = g.Key.Format,
|
||||
Count = g.Count()
|
||||
Count = (long) g.Sum(x =>
|
||||
x.chapter.AvgHoursToRead * (x.appUserProgresses.PagesRead / (1.0f * x.chapter.Pages)))
|
||||
})
|
||||
.OrderBy(d => d.Value)
|
||||
.ToListAsync();
|
||||
|
@ -13,7 +13,7 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Services.Tasks;
|
||||
|
||||
internal abstract class GithubReleaseMetadata
|
||||
internal class GithubReleaseMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the Tag
|
||||
|
@ -2,6 +2,6 @@
|
||||
* Mode the user is reading the book in. Not applicable with ReaderMode.Webtoon
|
||||
*/
|
||||
export enum WritingStyle{
|
||||
Vertical = 0,
|
||||
Horizontal = 1
|
||||
Horizontal = 0,
|
||||
Vertical = 1,
|
||||
}
|
||||
|
@ -30,4 +30,8 @@ export interface ReadingList {
|
||||
* If this is empty or null, the cover image isn't set. Do not use this externally.
|
||||
*/
|
||||
coverImage: string;
|
||||
startingYear: number;
|
||||
startingMonth: number;
|
||||
endingYear: number;
|
||||
endingMonth: number;
|
||||
}
|
@ -2,7 +2,7 @@
|
||||
<h2 title>
|
||||
{{title}}
|
||||
</h2>
|
||||
<h6 subtitle *ngIf="pagination">{{pagination.totalItems}} Series</h6>
|
||||
<h6 subtitle *ngIf="pagination">{{pagination.totalItems | number}} Series</h6>
|
||||
</app-side-nav-companion-bar>
|
||||
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
||||
<app-card-detail-layout
|
||||
|
@ -2,7 +2,7 @@
|
||||
<h2 title>
|
||||
Bookmarks
|
||||
</h2>
|
||||
<h6 subtitle>{{series.length}} Series</h6>
|
||||
<h6 subtitle>{{series.length | number}} Series</h6>
|
||||
</app-side-nav-companion-bar>
|
||||
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
||||
<app-card-detail-layout
|
||||
|
@ -4,7 +4,7 @@
|
||||
|
||||
<span class="highlight">
|
||||
<i class="fa fa-check me-1" aria-hidden="true"></i>
|
||||
{{selectionCount}} items selected
|
||||
{{selectionCount | number}} items selected
|
||||
</span>
|
||||
|
||||
<span>
|
||||
|
@ -102,7 +102,7 @@
|
||||
|
||||
virtual-scroller.empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
display: inline-block;
|
||||
|
@ -2,7 +2,7 @@
|
||||
<h2 title>
|
||||
Collections
|
||||
</h2>
|
||||
<h6 subtitle>{{collections.length}} Items</h6>
|
||||
<h6 subtitle>{{collections.length | number}} Items</h6>
|
||||
</app-side-nav-companion-bar>
|
||||
<app-card-detail-layout
|
||||
[isLoading]="isLoading"
|
||||
|
@ -40,6 +40,6 @@
|
||||
</ul>
|
||||
</div>
|
||||
</app-side-nav-companion-bar>
|
||||
<h6 subtitle class="subtitle-with-actionables" *ngIf="active.fragment === ''">{{pagination.totalItems}} Series</h6>
|
||||
<h6 subtitle class="subtitle-with-actionables" *ngIf="active.fragment === ''">{{pagination.totalItems | number}} Series</h6>
|
||||
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
||||
<div [ngbNavOutlet]="nav"></div>
|
||||
|
@ -213,10 +213,13 @@ NZ0ZV4zm4/L1dfnYNCrjTFq9G03rmj5D+Y4i0OHuL3GFPJytaM54AAAAAElFTkSuQmCC
|
||||
</div>
|
||||
<div class="ms-1">
|
||||
<app-series-format [format]="item.format"></app-series-format>
|
||||
<span *ngIf="item.name.toLowerCase().trim().indexOf(searchTerm) >= 0; else localizedName">{{item.name}}</span>
|
||||
<ng-template #localizedName>
|
||||
<span [innerHTML]="item.localizedName"></span>
|
||||
</ng-template>
|
||||
<ng-container *ngIf="searchTerm.toLowerCase().trim() as st">
|
||||
<span *ngIf="item.name.toLowerCase().trim().indexOf(st) >= 0; else localizedName">{{item.name}}</span>
|
||||
<ng-template #localizedName>
|
||||
<span [innerHTML]="item.localizedName"></span>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
|
||||
<div class="text-light fst-italic" style="font-size: 0.8rem;">in {{item.libraryName}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,25 +1,58 @@
|
||||
<div cdkDropList class="{{items.length > 0 ? 'example-list list-group-flush' : ''}}" (cdkDropListDropped)="drop($event)">
|
||||
<div class="example-box" *ngFor="let item of items; index as i" cdkDrag [cdkDragData]="item" cdkDragBoundary=".example-list">
|
||||
<div class="d-flex list-container">
|
||||
<div class="me-3 align-middle">
|
||||
<i class="fa fa-grip-vertical drag-handle" aria-hidden="true" cdkDragHandle></i>
|
||||
</div>
|
||||
<ng-container>
|
||||
|
||||
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
|
||||
<ng-container *ngIf="items.length > 100; else dragList">
|
||||
<div class="example-list list-group-flush">
|
||||
<virtual-scroller #scroll [items]="items" [bufferAmount]="BufferAmount" [parentScroll]="parentScroll">
|
||||
<div class="example-box" *ngFor="let item of scroll.viewPortItems; index as i; trackBy: trackByIdentity">
|
||||
<div class="d-flex list-container">
|
||||
<div class="me-3 align-middle">
|
||||
<div style="padding-top: 40px">
|
||||
<label for="reorder-{{i}}" class="form-label visually-hidden">Reorder</label>
|
||||
<input *ngIf="accessibilityMode" id="reorder-{{i}}" class="form-control" type="number" min="0" [max]="items.length - 1" [value]="i" style="width: 60px"
|
||||
(focusout)="updateIndex(i, item)" (keydown.enter)="updateIndex(i, item)" aria-describedby="instructions">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="align-middle" style="padding-top: 40px">
|
||||
<label for="reorder-{{i}}" class="form-label visually-hidden">Reorder</label>
|
||||
<input *ngIf="accessibilityMode" id="reorder-{{i}}" class="form-control" type="number" min="0" [max]="items.length - 1" [value]="i" style="width: 60px" (focusout)="updateIndex(i, item)" (keydown.enter)="updateIndex(i, item)" aria-describedby="instructions">
|
||||
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
|
||||
|
||||
<button class="btn btn-icon float-end" (click)="removeItem(item, i)" *ngIf="showRemoveButton">
|
||||
<i class="fa fa-times" aria-hidden="true"></i>
|
||||
<span class="visually-hidden" attr.aria-labelledby="item.id--{{i}}">Remove item</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</virtual-scroller>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #dragList>
|
||||
<div cdkDropList class="{{items.length > 0 ? 'example-list list-group-flush' : ''}}" (cdkDropListDropped)="drop($event)">
|
||||
<div class="example-box" *ngFor="let item of items; index as i" cdkDrag [cdkDragData]="item" cdkDragBoundary=".example-list">
|
||||
<div class="d-flex list-container">
|
||||
<div class="me-3 align-middle">
|
||||
<div class="align-middle" style="padding-top: 40px" *ngIf="accessibilityMode">
|
||||
<label for="reorder-{{i}}" class="form-label visually-hidden">Reorder</label>
|
||||
<input id="reorder-{{i}}" class="form-control" type="number" min="0" [max]="items.length - 1" [value]="i" style="width: 60px"
|
||||
(focusout)="updateIndex(i, item)" (keydown.enter)="updateIndex(i, item)" aria-describedby="instructions">
|
||||
</div>
|
||||
<i *ngIf="!accessibilityMode" class="fa fa-grip-vertical drag-handle" aria-hidden="true" cdkDragHandle></i>
|
||||
</div>
|
||||
|
||||
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
|
||||
|
||||
<button class="btn btn-icon float-end" (click)="removeItem(item, i)" *ngIf="showRemoveButton">
|
||||
<i class="fa fa-times" aria-hidden="true"></i>
|
||||
<span class="visually-hidden" attr.aria-labelledby="item.id--{{i}}">Remove item</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-icon float-end" (click)="removeItem(item, i)" *ngIf="showRemoveButton">
|
||||
<i class="fa fa-times" aria-hidden="true"></i>
|
||||
<span class="visually-hidden" attr.aria-labelledby="item.id--{{i}}">Remove item</span>
|
||||
</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="visually-hidden" id="instructions">
|
||||
When you put a number in the reorder input, the item will be inserted at that location and all other items will have their order updated.
|
||||
</p>
|
||||
|
||||
<p class="visually-hidden" id="instructions">
|
||||
When you put a number in the reorder input, the item will be inserted at that location and all other items will have their order updated.
|
||||
</p>
|
||||
|
||||
|
||||
</ng-container>
|
@ -9,11 +9,12 @@
|
||||
|
||||
.example-box {
|
||||
margin: 5px 0;
|
||||
//border-bottom: solid 1px #ccc;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
max-height: 140px;
|
||||
height: 140px;
|
||||
|
||||
.drag-handle {
|
||||
cursor: move;
|
||||
@ -59,3 +60,14 @@
|
||||
border-radius: 5px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.virtual-scroller, virtual-scroller {
|
||||
width: 100%;
|
||||
height: calc(var(--vh) * 100 - 173px);
|
||||
}
|
||||
|
||||
virtual-scroller.empty {
|
||||
display: none;
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
|
||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChild, EventEmitter, Input, Output, TemplateRef } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChild, EventEmitter, Input, Output, TemplateRef, TrackByFunction, ViewChild } from '@angular/core';
|
||||
import { VirtualScrollerComponent } from '@iharbeck/ngx-virtual-scroller';
|
||||
|
||||
export interface IndexUpdateEvent {
|
||||
fromPosition: number;
|
||||
@ -26,10 +27,19 @@ export class DraggableOrderedListComponent {
|
||||
*/
|
||||
@Input() showRemoveButton: boolean = true;
|
||||
@Input() items: Array<any> = [];
|
||||
/**
|
||||
* Parent scroll for virtualize pagination
|
||||
*/
|
||||
@Input() parentScroll!: Element | Window;
|
||||
@Input() trackByIdentity: TrackByFunction<any> = (index: number, item: any) => `${item.id}_${item.order}_${item.title}`;
|
||||
@Output() orderUpdated: EventEmitter<IndexUpdateEvent> = new EventEmitter<IndexUpdateEvent>();
|
||||
@Output() itemRemove: EventEmitter<ItemRemoveEvent> = new EventEmitter<ItemRemoveEvent>();
|
||||
@ContentChild('draggableItem') itemTemplate!: TemplateRef<any>;
|
||||
|
||||
get BufferAmount() {
|
||||
return Math.min(this.items.length / 20, 20);
|
||||
}
|
||||
|
||||
constructor(private readonly cdRef: ChangeDetectorRef) { }
|
||||
|
||||
drop(event: CdkDragDrop<string[]>) {
|
||||
|
@ -1,12 +1,10 @@
|
||||
<app-side-nav-companion-bar [hasExtras]="readingList !== undefined" [extraDrawer]="extrasDrawer">
|
||||
<h2 title>
|
||||
<span *ngIf="actions.length > 0">
|
||||
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [attr.labelBy]="readingList?.title"></app-card-actionables>
|
||||
</span>
|
||||
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [attr.labelBy]="readingList?.title" *ngIf="actions.length > 0"></app-card-actionables>
|
||||
{{readingList?.title}}
|
||||
<span *ngIf="readingList?.promoted" class="ms-1">(<i class="fa fa-angle-double-up" aria-hidden="true"></i>)</span>
|
||||
</h2>
|
||||
<h6 subtitle class="subtitle-with-actionables">{{items.length}} Items</h6>
|
||||
<h6 subtitle class="subtitle-with-actionables">{{items.length | number}} Items</h6>
|
||||
|
||||
<ng-template #extrasDrawer let-offcanvas>
|
||||
<div style="margin-top: 56px" *ngIf="readingList">
|
||||
@ -36,7 +34,7 @@
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-side-nav-companion-bar>
|
||||
<div class="container-fluid mt-2" *ngIf="readingList">
|
||||
<div class="container-fluid mt-2" *ngIf="readingList" >
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-2 col-xs-4 col-sm-6 d-none d-sm-block" *ngIf="readingList.coverImage !== '' && readingList.coverImage !== undefined && readingList.coverImage !== null">
|
||||
@ -83,6 +81,20 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-0 mt-2" *ngIf="readingList.startingYear !== 0">
|
||||
<h4 class="reading-list-years">
|
||||
<ng-container *ngIf="readingList.startingMonth > 0">{{readingList.startingMonth | date:'MMM'}}</ng-container>
|
||||
<ng-container *ngIf="readingList.startingMonth > 0 && readingList.startingYear > 0">, </ng-container>
|
||||
<ng-container *ngIf="readingList.startingYear > 0">{{readingList.startingYear}}</ng-container>
|
||||
—
|
||||
<ng-container *ngIf="readingList.endingYear > 0">
|
||||
<ng-container *ngIf="readingList.endingMonth > 0">{{readingList.endingMonth}}</ng-container>
|
||||
<ng-container *ngIf="readingList.endingMonth > 0 && readingList.endingYear > 0">, </ng-container>
|
||||
<ng-container *ngIf="readingList.endingYear > 0">{{readingList.endingYear}}</ng-container>
|
||||
</ng-container>
|
||||
|
||||
</h4>
|
||||
</div>
|
||||
<!-- Summary row-->
|
||||
<div class="row g-0 mt-2">
|
||||
<app-read-more [text]="readingListSummary" [maxLength]="250"></app-read-more>
|
||||
@ -92,22 +104,18 @@
|
||||
|
||||
<div class="row mb-3">
|
||||
<ng-container *ngIf="characters$ | async as characters">
|
||||
<div class="row g-0" *ngIf="characters && characters.length > 0">
|
||||
<div class="col-md-4">
|
||||
<h5>Characters</h5>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<app-badge-expander [items]="characters">
|
||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||
<app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge>
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</div>
|
||||
<div class="row" *ngIf="characters && characters.length > 0">
|
||||
<h5>Characters</h5>
|
||||
<app-badge-expander [items]="characters">
|
||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||
<app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge>
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3" cdkScrollable>
|
||||
<div class="row mb-3 scroll-container" #scrollingBlock>
|
||||
<div class="mx-auto" style="width: 200px;">
|
||||
<ng-container *ngIf="items.length === 0 && !isLoading">
|
||||
Nothing added
|
||||
@ -115,10 +123,10 @@
|
||||
<app-loading [loading]="isLoading"></app-loading>
|
||||
</div>
|
||||
|
||||
<!-- TODO: This needs virtualization -->
|
||||
<app-draggable-ordered-list [items]="items" (orderUpdated)="orderUpdated($event)" [accessibilityMode]="accessibilityMode" [showRemoveButton]="false">
|
||||
<app-draggable-ordered-list [items]="items" (orderUpdated)="orderUpdated($event)" [accessibilityMode]="accessibilityMode"
|
||||
[showRemoveButton]="false">
|
||||
<ng-template #draggableItem let-item let-position="idx">
|
||||
<app-reading-list-item class="content-container" [item]="item" [position]="position" [libraryTypes]="libraryTypes"
|
||||
<app-reading-list-item [ngClass]="{'content-container': items.length < 100, 'non-virtualized-container': items.length >= 100}" [item]="item" [position]="position" [libraryTypes]="libraryTypes"
|
||||
[promoted]="item.promoted" (read)="readChapter($event)" (remove)="itemRemoved($event, position)"></app-reading-list-item>
|
||||
</ng-template>
|
||||
</app-draggable-ordered-list>
|
||||
|
@ -2,9 +2,31 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.non-virtualized-container {
|
||||
width: 100%;
|
||||
max-height: 140px;
|
||||
height: 140px;
|
||||
}
|
||||
|
||||
.dropdown-toggle-split {
|
||||
border-top-right-radius: 6px !important;
|
||||
border-bottom-right-radius: 6px !important;
|
||||
border-top-left-radius: 0px !important;
|
||||
border-bottom-left-radius: 0px !important;
|
||||
}
|
||||
|
||||
.reading-list-years {
|
||||
color: var(--input-placeholder-color);
|
||||
}
|
||||
|
||||
.scroll-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
height: calc((var(--vh) *100) - 173px);
|
||||
margin-bottom: 10px;
|
||||
|
||||
&.empty {
|
||||
height: auto;
|
||||
}
|
||||
}
|
@ -36,8 +36,10 @@
|
||||
<a href="/library/{{item.libraryId}}/series/{{item.seriesId}}">{{item.seriesName}}</a>
|
||||
</div>
|
||||
|
||||
<!-- TODO: Let's add summary here-->
|
||||
|
||||
<div class="ps-1 mt-2" *ngIf="item.releaseDate !== '0001-01-01T00:00:00'">
|
||||
Released: {{item.releaseDate | date:'short'}}
|
||||
Released: {{item.releaseDate | date:'longDate'}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -3,7 +3,7 @@
|
||||
<app-card-actionables [actions]="globalActions" (actionHandler)="performGlobalAction($event)"></app-card-actionables>
|
||||
<span>Reading Lists</span>
|
||||
</h2>
|
||||
<h6 subtitle *ngIf="pagination">{{pagination.totalItems}} Items</h6>
|
||||
<h6 subtitle *ngIf="pagination">{{pagination.totalItems | number}} Items</h6>
|
||||
</app-side-nav-companion-bar>
|
||||
|
||||
<app-card-detail-layout
|
||||
|
@ -37,6 +37,60 @@
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mb-3">
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<h6 id="starting-year-header">Starting</h6>
|
||||
<div class="col-md-6 col-sm-12" *ngIf="reviewGroup.get('startingMonth') as formControl" style="width: 90%">
|
||||
<label for="start-month" class="form-label">Month</label>
|
||||
<input id="start-month" class="form-control" formControlName="startingMonth"
|
||||
type="number" [class.is-invalid]="formControl?.invalid && formControl?.touched"
|
||||
aria-describedby="starting-year-header">
|
||||
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="reviewGroup.dirty || reviewGroup.touched">
|
||||
<div *ngIf="formControl.errors?.min || formControl.errors?.max">
|
||||
Must be between 1 and 12 or blank
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-sm-12" *ngIf="reviewGroup.get('startingYear') as formControl" style="width: 90%">
|
||||
<label for="start-year" class="form-label">Year</label>
|
||||
<input id="start-year" class="form-control" formControlName="startingYear" type="number"
|
||||
[class.is-invalid]="formControl.invalid && formControl.touched"
|
||||
aria-describedby="starting-year-header">
|
||||
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="reviewGroup.dirty || reviewGroup.touched">
|
||||
<div *ngIf="formControl.errors?.min || formControl.errors?.max">
|
||||
Must be between 1 and 12 or blank
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<h6 id="ending-year-heading">Ending</h6>
|
||||
<div class="col-md-6 col-sm-12" *ngIf="reviewGroup.get('endingMonth') as formControl" style="width: 90%">
|
||||
<label for="library-name" class="form-label">Month</label>
|
||||
<input id="library-name" class="form-control" formControlName="endingMonth" type="number"
|
||||
[class.is-invalid]="formControl?.invalid && formControl?.touched"
|
||||
aria-describedby="ending-year-header">
|
||||
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="reviewGroup.dirty || reviewGroup.touched">
|
||||
<div *ngIf="formControl.errors?.min || formControl.errors?.max">
|
||||
Must be between 1 and 12 or blank
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-sm-12" *ngIf="reviewGroup.get('endingYear') as formControl" style="width: 90%">
|
||||
<label for="library-name" class="form-label">Year</label>
|
||||
<input id="library-name" class="form-control" formControlName="endingYear" type="number"
|
||||
[class.is-invalid]="formControl?.invalid && formControl?.touched"
|
||||
aria-describedby="ending-year-header">
|
||||
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="reviewGroup.dirty || reviewGroup.touched">
|
||||
<div *ngIf="formControl.errors?.min || formControl.errors?.max">
|
||||
Must be between 1 and 12 or blank
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mb-3">
|
||||
<label for="summary" class="form-label">Summary</label>
|
||||
<textarea id="summary" class="form-control" formControlName="summary" rows="3"></textarea>
|
||||
|
@ -49,6 +49,10 @@ export class EditReadingListModalComponent implements OnInit, OnDestroy {
|
||||
title: new FormControl(this.readingList.title, { nonNullable: true, validators: [Validators.required] }),
|
||||
summary: new FormControl(this.readingList.summary, { nonNullable: true, validators: [] }),
|
||||
promoted: new FormControl(this.readingList.promoted, { nonNullable: true, validators: [] }),
|
||||
startingMonth: new FormControl(this.readingList.startingMonth, { nonNullable: true, validators: [Validators.min(1), Validators.max(12)] }),
|
||||
startingYear: new FormControl(this.readingList.startingYear, { nonNullable: true, validators: [Validators.min(1000)] }),
|
||||
endingMonth: new FormControl(this.readingList.endingMonth, { nonNullable: true, validators: [Validators.min(1), Validators.max(12)] }),
|
||||
endingYear: new FormControl(this.readingList.endingYear, { nonNullable: true, validators: [Validators.min(1000)] }),
|
||||
});
|
||||
|
||||
this.reviewGroup.get('title')?.valueChanges.pipe(
|
||||
@ -90,6 +94,10 @@ export class EditReadingListModalComponent implements OnInit, OnDestroy {
|
||||
if (this.reviewGroup.value.title.trim() === '') return;
|
||||
|
||||
const model = {...this.reviewGroup.value, readingListId: this.readingList.id, coverImageLocked: this.coverImageLocked};
|
||||
model.startingMonth = model.startingMonth || 0;
|
||||
model.startingYear = model.startingYear || 0;
|
||||
model.endingMonth = model.endingMonth || 0;
|
||||
model.endingYear = model.endingYear || 0;
|
||||
const apis = [this.readingListService.update(model)];
|
||||
|
||||
if (this.selectedCover !== '') {
|
||||
|
@ -18,6 +18,7 @@ import { FileUploadModule } from '@iplab/ngx-file-upload';
|
||||
import { CblConflictReasonPipe } from './_pipes/cbl-conflict-reason.pipe';
|
||||
import { StepTrackerComponent } from './_components/step-tracker/step-tracker.component';
|
||||
import { CblImportResultPipe } from './_pipes/cbl-import-result.pipe';
|
||||
import { VirtualScrollerModule } from '@iharbeck/ngx-virtual-scroller';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
@ -48,6 +49,7 @@ import { CblImportResultPipe } from './_pipes/cbl-import-result.pipe';
|
||||
ReadingListRoutingModule,
|
||||
NgbAccordionModule, // Import CBL
|
||||
FileUploadModule, // Import CBL
|
||||
VirtualScrollerModule,
|
||||
],
|
||||
exports: [
|
||||
AddToListModalComponent,
|
||||
|
@ -73,13 +73,29 @@
|
||||
<div class="col-xlg-10 col-lg-8 col-md-8 col-xs-8 col-sm-6 mt-2">
|
||||
<div class="row g-0">
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-primary" (click)="read()">
|
||||
<span>
|
||||
<i class="fa {{showBook ? 'fa-book-open' : 'fa-book'}} me-1"></i>
|
||||
</span>
|
||||
<span class="d-none d-sm-inline-block">{{(hasReadingProgress) ? 'Continue' : 'Read'}}</span>
|
||||
</button>
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-primary" (click)="read()">
|
||||
<span>
|
||||
<i class="fa {{showBook ? 'fa-book-open' : 'fa-book'}}" aria-hidden="true"></i>
|
||||
<span class="read-btn--text"> {{(hasReadingProgress) ? 'Continue' : 'Read'}}</span>
|
||||
</span>
|
||||
</button>
|
||||
<div class="btn-group" ngbDropdown role="group" display="dynamic" aria-label="Read options">
|
||||
<button type="button" class="btn btn-primary dropdown-toggle-split" ngbDropdownToggle></button>
|
||||
<div class="dropdown-menu" ngbDropdownMenu>
|
||||
<button ngbDropdownItem (click)="read(true)">
|
||||
<span>
|
||||
<i class="fa fa-glasses" aria-hidden="true"></i>
|
||||
<span class="read-btn--text"> {{(hasReadingProgress) ? 'Continue' : 'Read'}} Incognito</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="col-auto ms-2">
|
||||
<button class="btn btn-secondary" (click)="toggleWantToRead()" title="{{isWantToRead ? 'Remove from' : 'Add to'}} Want to Read">
|
||||
<span>
|
||||
|
@ -57,3 +57,10 @@
|
||||
::ng-deep .progress {
|
||||
border-radius: 0px;
|
||||
}
|
||||
|
||||
.dropdown-toggle-split {
|
||||
border-top-right-radius: 6px !important;
|
||||
border-bottom-right-radius: 6px !important;
|
||||
border-top-left-radius: 0px !important;
|
||||
border-bottom-left-radius: 0px !important;
|
||||
}
|
@ -654,14 +654,14 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
|
||||
});
|
||||
}
|
||||
|
||||
read() {
|
||||
read(incognitoMode: boolean = false) {
|
||||
if (this.currentlyReadingChapter !== undefined) {
|
||||
this.openChapter(this.currentlyReadingChapter);
|
||||
this.openChapter(this.currentlyReadingChapter, incognitoMode);
|
||||
return;
|
||||
}
|
||||
|
||||
this.readerService.getCurrentChapter(this.seriesId).subscribe(chapter => {
|
||||
this.openChapter(chapter);
|
||||
this.openChapter(chapter, incognitoMode);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { SeriesDetailRoutingModule } from './series-detail-routing.module';
|
||||
import { NgbCollapseModule, NgbNavModule, NgbProgressbarModule, NgbRatingModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { NgbCollapseModule, NgbDropdownModule, NgbNavModule, NgbProgressbarModule, NgbRatingModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { SeriesMetadataDetailComponent } from './_components/series-metadata-detail/series-metadata-detail.component';
|
||||
import { ReviewSeriesModalComponent } from './_modals/review-series-modal/review-series-modal.component';
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
@ -27,6 +27,7 @@ import { SeriesDetailComponent } from './_components/series-detail/series-detail
|
||||
NgbRatingModule,
|
||||
NgbTooltipModule, // Series Detail, Extras Drawer
|
||||
NgbProgressbarModule,
|
||||
NgbDropdownModule,
|
||||
|
||||
TypeaheadModule,
|
||||
PipeModule,
|
||||
|
@ -40,7 +40,7 @@
|
||||
[roundDomains]="true"
|
||||
[autoScale]="true"
|
||||
xAxisLabel="Time"
|
||||
yAxisLabel="Reading Activity"
|
||||
yAxisLabel="Hours Read"
|
||||
[timeline]="false"
|
||||
[results]="data"
|
||||
>
|
||||
|
@ -34,7 +34,7 @@ export class UserStatsInfoCardsComponent {
|
||||
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.items = yearCounts.map(t => `${t.name}: ${numberPipe.transform(t.value)} words`);
|
||||
ref.componentInstance.title = 'Words Read By Year';
|
||||
});
|
||||
}
|
||||
|
@ -5,7 +5,7 @@
|
||||
Want To Read
|
||||
</h2>
|
||||
</ng-container>
|
||||
<h6 subtitle>{{seriesPagination.totalItems}} Series</h6>
|
||||
<h6 subtitle>{{seriesPagination.totalItems | number}} Series</h6>
|
||||
</app-side-nav-companion-bar>
|
||||
</div>
|
||||
|
||||
|
58
openapi.json
58
openapi.json
@ -7,7 +7,7 @@
|
||||
"name": "GPL-3.0",
|
||||
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
||||
},
|
||||
"version": "0.7.1.16"
|
||||
"version": "0.7.1.19"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
@ -12383,6 +12383,26 @@
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"startingYear": {
|
||||
"type": "integer",
|
||||
"description": "Minimum Year the Reading List starts",
|
||||
"format": "int32"
|
||||
},
|
||||
"startingMonth": {
|
||||
"type": "integer",
|
||||
"description": "Minimum Month the Reading List starts",
|
||||
"format": "int32"
|
||||
},
|
||||
"endingYear": {
|
||||
"type": "integer",
|
||||
"description": "Maximum Year the Reading List starts",
|
||||
"format": "int32"
|
||||
},
|
||||
"endingMonth": {
|
||||
"type": "integer",
|
||||
"description": "Maximum Month the Reading List starts",
|
||||
"format": "int32"
|
||||
},
|
||||
"appUserId": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
@ -12420,6 +12440,26 @@
|
||||
"type": "string",
|
||||
"description": "This is used to tell the UI if it should request a Cover Image or not. If null or empty, it has not been set.",
|
||||
"nullable": true
|
||||
},
|
||||
"startingYear": {
|
||||
"type": "integer",
|
||||
"description": "Minimum Year the Reading List starts",
|
||||
"format": "int32"
|
||||
},
|
||||
"startingMonth": {
|
||||
"type": "integer",
|
||||
"description": "Minimum Month the Reading List starts",
|
||||
"format": "int32"
|
||||
},
|
||||
"endingYear": {
|
||||
"type": "integer",
|
||||
"description": "Maximum Year the Reading List starts",
|
||||
"format": "int32"
|
||||
},
|
||||
"endingMonth": {
|
||||
"type": "integer",
|
||||
"description": "Maximum Month the Reading List starts",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@ -14580,6 +14620,22 @@
|
||||
},
|
||||
"coverImageLocked": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"startingMonth": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"startingYear": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"endingMonth": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"endingYear": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
Loading…
x
Reference in New Issue
Block a user