Text View, View & Filter All Annotations, and More OPDS Love (#4062)

Co-authored-by: Amelia <77553571+Fesaa@users.noreply.github.com>
This commit is contained in:
Joe Milazzo 2025-09-28 14:28:21 -05:00 committed by GitHub
parent becb3d8c3b
commit 5290fd8959
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
139 changed files with 12399 additions and 2516 deletions

View File

@ -81,7 +81,6 @@
<PackageReference Include="NetVips" Version="3.1.0" />
<PackageReference Include="NetVips.Native" Version="8.17.1" />
<PackageReference Include="Polly" Version="8.6.2" />
<PackageReference Include="Quill.Delta" Version="1.0.7" />
<PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="4.0.0" />

View File

@ -102,16 +102,13 @@ public class AccountController : BaseApiController
try
{
user.UpdateLastActive();
await _unitOfWork.UserRepository.UpdateUserAsActive(user.Id);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update last active for {UserName}", user.UserName);
}
_unitOfWork.UserRepository.Update(user);
await _unitOfWork.CommitAsync();
return Ok(await ConstructUserDto(user, roles, false));
}
@ -293,7 +290,7 @@ public class AccountController : BaseApiController
// Update LastActive on account
try
{
user.UpdateLastActive();
await _unitOfWork.UserRepository.UpdateUserAsActive(user.Id);
}
catch (Exception ex)
{

View File

@ -1,14 +1,20 @@
using System;
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using API.Data;
using API.DTOs.Metadata.Browse.Requests;
using API.DTOs.Reader;
using API.Entities;
using API.Extensions;
using API.Helpers;
using API.Services;
using API.SignalR;
using HtmlAgilityPack;
using Kavita.Common;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
@ -19,18 +25,35 @@ public class AnnotationController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<AnnotationController> _logger;
private readonly IBookService _bookService;
private readonly ILocalizationService _localizationService;
private readonly IEventHub _eventHub;
private readonly IAnnotationService _annotationService;
public AnnotationController(IUnitOfWork unitOfWork, ILogger<AnnotationController> logger,
IBookService bookService, ILocalizationService localizationService, IEventHub eventHub)
ILocalizationService localizationService, IEventHub eventHub, IAnnotationService annotationService)
{
_unitOfWork = unitOfWork;
_logger = logger;
_bookService = bookService;
_localizationService = localizationService;
_eventHub = eventHub;
_annotationService = annotationService;
}
/// <summary>
/// Returns a list of annotations for browsing
/// </summary>
/// <param name="filter"></param>
/// <param name="userParams"></param>
/// <returns></returns>
[HttpPost("all-filtered")]
public async Task<ActionResult<PagedList<AnnotationDto>>> GetAnnotationsForBrowse(BrowseAnnotationFilterDto filter, [FromQuery] UserParams? userParams)
{
userParams ??= UserParams.Default;
var list = await _unitOfWork.AnnotationRepository.GetAnnotationDtos(User.GetUserId(), filter, userParams);
Response.AddPaginationHeader(list.CurrentPage, list.PageSize, list.TotalCount, list.TotalPages);
return Ok(list);
}
/// <summary>
@ -41,7 +64,6 @@ public class AnnotationController : BaseApiController
[HttpGet("all")]
public async Task<ActionResult<IEnumerable<AnnotationDto>>> GetAnnotations(int chapterId)
{
return Ok(await _unitOfWork.UserRepository.GetAnnotations(User.GetUserId(), chapterId));
}
@ -77,62 +99,16 @@ public class AnnotationController : BaseApiController
{
try
{
if (dto.HighlightCount == 0 || string.IsNullOrWhiteSpace(dto.SelectedText))
{
return BadRequest(_localizationService.Translate(User.GetUserId(), "invalid-payload"));
}
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(dto.ChapterId);
if (chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
var chapterTitle = string.Empty;
try
{
var toc = await _bookService.GenerateTableOfContents(chapter);
var pageTocs = BookChapterItemHelper.GetTocForPage(toc, dto.PageNumber);
if (pageTocs.Count > 0)
{
chapterTitle = pageTocs[0].Title;
}
}
catch (KavitaException)
{
/* Swallow */
}
var annotation = new AppUserAnnotation()
{
XPath = dto.XPath,
EndingXPath = dto.EndingXPath,
ChapterId = dto.ChapterId,
SeriesId = dto.SeriesId,
VolumeId = dto.VolumeId,
LibraryId = dto.LibraryId,
HighlightCount = dto.HighlightCount,
SelectedText = dto.SelectedText,
Comment = dto.Comment,
ContainsSpoiler = dto.ContainsSpoiler,
PageNumber = dto.PageNumber,
SelectedSlotIndex = dto.SelectedSlotIndex,
AppUserId = User.GetUserId(),
Context = dto.Context,
ChapterTitle = chapterTitle
};
_unitOfWork.AnnotationRepository.Attach(annotation);
await _unitOfWork.CommitAsync();
return Ok(await _unitOfWork.AnnotationRepository.GetAnnotationDto(annotation.Id));
return Ok(await _annotationService.CreateAnnotation(User.GetUserId(), dto));
}
catch (Exception ex)
catch (KavitaException ex)
{
_logger.LogError(ex, "There was an exception when creating an annotation on {ChapterId} - Page {Page}", dto.ChapterId, dto.PageNumber);
return BadRequest(_localizationService.Translate(User.GetUserId(), "annotation-failed-create"));
return BadRequest(await _localizationService.Translate(User.GetUserId(), ex.Message));
}
}
/// <summary>
/// Update the modifable fields (Spoiler, highlight slot, and comment) for an annotation
/// Update the modifiable fields (Spoiler, highlight slot, and comment) for an annotation
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
@ -141,28 +117,12 @@ public class AnnotationController : BaseApiController
{
try
{
var annotation = await _unitOfWork.AnnotationRepository.GetAnnotation(dto.Id);
if (annotation == null || annotation.AppUserId != User.GetUserId()) return BadRequest();
annotation.ContainsSpoiler = dto.ContainsSpoiler;
annotation.SelectedSlotIndex = dto.SelectedSlotIndex;
annotation.Comment = dto.Comment;
_unitOfWork.AnnotationRepository.Update(annotation);
if (!_unitOfWork.HasChanges() || await _unitOfWork.CommitAsync())
{
await _eventHub.SendMessageToAsync(MessageFactory.AnnotationUpdate, MessageFactory.AnnotationUpdateEvent(dto),
User.GetUserId());
return Ok(dto);
}
return Ok(await _annotationService.UpdateAnnotation(User.GetUserId(), dto));
}
catch (Exception ex)
catch (KavitaException ex)
{
_logger.LogError(ex, "There was an exception updating Annotation for Chapter {ChapterId} - Page {PageNumber}", dto.ChapterId, dto.PageNumber);
return BadRequest();
return BadRequest(await _localizationService.Translate(User.GetUserId(), ex.Message));
}
return Ok();
}
/// <summary>
@ -174,10 +134,75 @@ public class AnnotationController : BaseApiController
public async Task<ActionResult> DeleteAnnotation(int annotationId)
{
var annotation = await _unitOfWork.AnnotationRepository.GetAnnotation(annotationId);
if (annotation == null || annotation.AppUserId != User.GetUserId()) return BadRequest(_localizationService.Translate(User.GetUserId(), "annotation-delete"));
if (annotation == null || annotation.AppUserId != User.GetUserId()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "annotation-delete"));
_unitOfWork.AnnotationRepository.Remove(annotation);
await _unitOfWork.CommitAsync();
return Ok();
}
/// <summary>
/// Removes annotations in bulk. Requires every annotation to be owned by the authenticated user
/// </summary>
/// <param name="annotationIds"></param>
/// <returns></returns>
[HttpPost("bulk-delete")]
public async Task<ActionResult> DeleteAnnotationsBulk(IList<int> annotationIds)
{
var userId = User.GetUserId();
var annotations = await _unitOfWork.AnnotationRepository.GetAnnotations(annotationIds);
if (annotations.Any(a => a.AppUserId != userId))
{
return BadRequest();
}
_unitOfWork.AnnotationRepository.Remove(annotations);
await _unitOfWork.CommitAsync();
return Ok();
}
/// <summary>
/// Exports annotations for the given users
/// </summary>
/// <returns></returns>
[HttpPost("export-filter")]
public async Task<IActionResult> ExportAnnotationsFilter(BrowseAnnotationFilterDto filter, [FromQuery] UserParams? userParams)
{
userParams ??= UserParams.Default;
var list = await _unitOfWork.AnnotationRepository.GetAnnotationDtos(User.GetUserId(), filter, userParams);
var annotations = list.Select(a => a.Id).ToList();
var json = await _annotationService.ExportAnnotations(User.GetUserId(), annotations);
if (string.IsNullOrEmpty(json)) return BadRequest();
var bytes = Encoding.UTF8.GetBytes(json);
var fileName = System.Web.HttpUtility.UrlEncode($"annotations_export_{User.GetUserId()}_{DateTime.UtcNow:yyyyMMdd_HHmmss}_filtered");
return File(bytes, "application/json", fileName + ".json");
}
/// <summary>
/// Exports Annotations for the User
/// </summary>
/// <param name="annotations">Export annotations with the given ids</param>
/// <returns></returns>
[HttpPost("export")]
public async Task<IActionResult> ExportAnnotations(IList<int>? annotations = null)
{
var json = await _annotationService.ExportAnnotations(User.GetUserId(), annotations);
if (string.IsNullOrEmpty(json)) return BadRequest();
var bytes = Encoding.UTF8.GetBytes(json);
var fileName = System.Web.HttpUtility.UrlEncode($"annotations_export_{User.GetUserId()}_{DateTime.UtcNow:yyyyMMdd_HHmmss}");
if (annotations != null)
{
fileName += "_user_selection";
}
return File(bytes, "application/json", fileName + ".json");
}
}

View File

@ -50,7 +50,7 @@ public class LicenseController(
}
/// <summary>
/// Has any license registered with the instance. Does not check Kavita+ API
/// Has any license registered with the instance. Does not validate against Kavita+ API
/// </summary>
/// <returns></returns>
[Authorize("RequireAdminRole")]
@ -117,6 +117,16 @@ public class LicenseController(
return BadRequest(localizationService.Translate(User.GetUserId(), "unable-to-reset-k+"));
}
/// <summary>
/// Resend the welcome email to the user
/// </summary>
/// <returns></returns>
[HttpPost("resend-license")]
public async Task<ActionResult<bool>> ResendWelcomeEmail()
{
return Ok(await licenseService.ResendWelcomeEmail());
}
/// <summary>
/// Updates server license
/// </summary>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,30 @@
using System;
using System.Text.Json.Serialization;
namespace API.DTOs.Annotations;
public sealed record FullAnnotationDto
{
public int Id { get; set; }
[JsonIgnore]
public int UserId { get; set; }
public string SelectedText { get; set; }
public string? Comment { get; set; }
public string? CommentHtml { get; set; }
public string? CommentPlainText { get; set; }
public string? Context { get; set; }
public string? ChapterTitle { get; set; }
public int PageNumber { get; set; }
public int SelectedSlotIndex { get; set; }
public bool ContainsSpoiler { get; set; }
public DateTime CreatedUtc { get; set; }
public DateTime LastModifiedUtc { get; set; }
public int LibraryId { get; set; }
public string LibraryName { get; set; }
public int SeriesId { get; set; }
public string SeriesName { get; set; }
public int VolumeId { get; set; }
public string VolumeName { get; set; }
public int ChapterId { get; set; }
}

View File

@ -37,5 +37,13 @@ public enum SortField
/// <summary>
/// Randomise the order
/// </summary>
Random = 9
Random = 9,
}
public enum AnnotationSortField
{
Owner = 1,
Created = 2,
LastModified = 3,
Color = 4,
}

View File

@ -17,3 +17,12 @@ public sealed record PersonSortOptions
public PersonSortField SortField { get; set; }
public bool IsAscending { get; set; } = true;
}
/// <summary>
/// All Sorting Options for a query related to Annotation Entity
/// </summary>
public sealed record AnnotationSortOptions
{
public AnnotationSortField SortField { get; set; }
public bool IsAscending { get; set; } = true;
}

View File

@ -56,6 +56,10 @@ public enum FilterField
/// Last time User Read
/// </summary>
ReadLast = 32,
/// <summary>
/// Total filesize accross all files for all chapters of the series
/// </summary>
FileSize = 33,
}
public enum PersonFilterField
@ -65,3 +69,22 @@ public enum PersonFilterField
SeriesCount = 3,
ChapterCount = 4,
}
public enum AnnotationFilterField
{
Owner = 1,
Library = 2,
Spoiler = 3,
/// <summary>
/// When used, only returns your own annotations
/// </summary>
HighlightSlot = 4,
/// <summary>
/// This is the text selected in the book
/// </summary>
Selection = 5,
/// <summary>
/// This is the text the user wrote
/// </summary>
Comment = 6,
}

View File

@ -1,5 +1,4 @@
using API.DTOs.Metadata.Browse.Requests;

namespace API.DTOs.Filtering.v2;
public sealed record FilterStatementDto
@ -15,3 +14,10 @@ public sealed record PersonFilterStatementDto
public PersonFilterField Field { get; set; }
public string Value { get; set; }
}
public sealed record AnnotationFilterStatementDto
{
public FilterComparison Comparison { get; set; }
public AnnotationFilterField Field { get; set; }
public string Value { get; set; }
}

View File

@ -0,0 +1,26 @@
#nullable enable
using System.Collections.Generic;
using API.DTOs.Filtering;
using API.DTOs.Filtering.v2;
namespace API.DTOs.Metadata.Browse.Requests;
public class BrowseAnnotationFilterDto
{
/// <summary>
/// Not used - For parity with Series Filter
/// </summary>
public int Id { get; set; }
/// <summary>
/// Not used - For parity with Series Filter
/// </summary>
public string? Name { get; set; }
public ICollection<AnnotationFilterStatementDto> Statements { get; set; } = [];
public FilterCombination Combination { get; set; } = FilterCombination.And;
public AnnotationSortOptions? SortOptions { get; set; }
/// <summary>
/// Limit the number of rows returned. Defaults to not applying a limit (aka 0)
/// </summary>
public int LimitTo { get; set; } = 0;
}

View File

@ -0,0 +1,6 @@
namespace API.DTOs.OPDS.Requests;
public interface IOpdsPagination
{
public int PageNumber { get; init; }
}

View File

@ -0,0 +1,9 @@
namespace API.DTOs.OPDS.Requests;
public interface IOpdsRequest
{
public string ApiKey { get; init; }
public string Prefix { get; init; }
public string BaseUrl { get; init; }
public int UserId { get; init; }
}

View File

@ -0,0 +1,10 @@
namespace API.DTOs.OPDS.Requests;
public sealed record OpdsCatalogueRequest : IOpdsRequest
{
public string ApiKey { get; init; }
public string Prefix { get; init; }
public string BaseUrl { get; init; }
public int UserId { get; init; }
}

View File

@ -0,0 +1,18 @@
namespace API.DTOs.OPDS.Requests;
/// <summary>
/// A special case for dealing with lower level entities (volume/chapter) which need higher level entity ids
/// </summary>
/// <remarks>Not all variables will always be used. Implementation will use</remarks>
public sealed record OpdsItemsFromCompoundEntityIdsRequest : IOpdsRequest, IOpdsPagination
{
public string ApiKey { get; init; }
public string Prefix { get; init; }
public string BaseUrl { get; init; }
public int UserId { get; init; }
public int PageNumber { get; init; }
public int SeriesId { get; init; }
public int VolumeId { get; init; }
public int ChapterId { get; init; }
}

View File

@ -0,0 +1,10 @@
namespace API.DTOs.OPDS.Requests;
public sealed record OpdsSearchRequest : IOpdsRequest
{
public string ApiKey { get; init; }
public string Prefix { get; init; }
public string BaseUrl { get; init; }
public int UserId { get; init; }
public string Query { get; init; }
}

View File

@ -0,0 +1,14 @@
namespace API.DTOs.OPDS.Requests;
/// <summary>
/// A generic Catalogue request for a specific Entity
/// </summary>
public sealed record OpdsPaginatedCatalogueRequest : IOpdsRequest, IOpdsPagination
{
public string ApiKey { get; init; }
public string Prefix { get; init; }
public string BaseUrl { get; init; }
public int UserId { get; init; }
public int PageNumber { get; init; }
}

View File

@ -0,0 +1,12 @@
namespace API.DTOs.OPDS.Requests;
public sealed record OpdsItemsFromEntityIdRequest : IOpdsRequest, IOpdsPagination
{
public string ApiKey { get; init; }
public string Prefix { get; init; }
public string BaseUrl { get; init; }
public int UserId { get; init; }
public int EntityId { get; init; }
public int PageNumber { get; init; } = 0;
}

View File

@ -28,6 +28,10 @@ public sealed record AnnotationDto
/// Rich text Comment
/// </summary>
public string? Comment { get; set; }
/// <inheritdoc cref="AppUserAnnotation.CommentHtml"/>
public string? CommentHtml { get; set; }
/// <inheritdoc cref="AppUserAnnotation.CommentPlainText"/>
public string? CommentPlainText { get; set; }
/// <summary>
/// Title of the TOC Chapter within Epub (not Chapter Entity)
/// </summary>

View File

@ -9,6 +9,7 @@ namespace API.DTOs;
public sealed record UserDto
{
public int Id { get; init; }
public string Username { get; init; } = null!;
public string Email { get; init; } = null!;
public IList<string> Roles { get; set; } = [];

View File

@ -111,10 +111,6 @@ public sealed record UserReadingProfileDto
[Required]
public bool BookReaderImmersiveMode { get; set; } = false;
/// <inheritdoc cref="AppUserReadingProfile.BookReaderEpubPageCalculationMethod"/>
[Required]
public EpubPageCalculationMethod BookReaderEpubPageCalculationMethod { get; set; } = EpubPageCalculationMethod.Default;
#endregion
#region PdfReader

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,59 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class AddAnnotationsHtmlContent : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "CommentHtml",
table: "AppUserAnnotation",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "CommentPlainText",
table: "AppUserAnnotation",
type: "TEXT",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_AppUserAnnotation_SeriesId",
table: "AppUserAnnotation",
column: "SeriesId");
migrationBuilder.AddForeignKey(
name: "FK_AppUserAnnotation_Series_SeriesId",
table: "AppUserAnnotation",
column: "SeriesId",
principalTable: "Series",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_AppUserAnnotation_Series_SeriesId",
table: "AppUserAnnotation");
migrationBuilder.DropIndex(
name: "IX_AppUserAnnotation_SeriesId",
table: "AppUserAnnotation");
migrationBuilder.DropColumn(
name: "CommentHtml",
table: "AppUserAnnotation");
migrationBuilder.DropColumn(
name: "CommentPlainText",
table: "AppUserAnnotation");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class RemoveEpubPageCalc : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "BookReaderEpubPageCalculationMethod",
table: "AppUserReadingProfiles");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "BookReaderEpubPageCalculationMethod",
table: "AppUserReadingProfiles",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
}
}

View File

@ -180,6 +180,12 @@ namespace API.Data.Migrations
b.Property<string>("Comment")
.HasColumnType("TEXT");
b.Property<string>("CommentHtml")
.HasColumnType("TEXT");
b.Property<string>("CommentPlainText")
.HasColumnType("TEXT");
b.Property<bool>("ContainsSpoiler")
.HasColumnType("INTEGER");
@ -231,6 +237,8 @@ namespace API.Data.Migrations
b.HasIndex("ChapterId");
b.HasIndex("SeriesId");
b.ToTable("AppUserAnnotation");
});
@ -732,9 +740,6 @@ namespace API.Data.Migrations
.HasColumnType("TEXT")
.HasDefaultValue("#000000");
b.Property<int>("BookReaderEpubPageCalculationMethod")
.HasColumnType("INTEGER");
b.Property<string>("BookReaderFontFamily")
.HasColumnType("TEXT");
@ -2983,9 +2988,17 @@ namespace API.Data.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.Series", "Series")
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AppUser");
b.Navigation("Chapter");
b.Navigation("Series");
});
modelBuilder.Entity("API.Entities.AppUserBookmark", b =>

View File

@ -1,6 +1,16 @@
using System.Threading.Tasks;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.DTOs.Filtering.v2;
using API.DTOs.Metadata.Browse.Requests;
using API.DTOs.Annotations;
using API.DTOs.Reader;
using API.Entities;
using API.Extensions.QueryExtensions;
using API.Extensions.QueryExtensions.Filtering;
using API.Helpers;
using API.Helpers.Converters;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.EntityFrameworkCore;
@ -13,8 +23,13 @@ public interface IAnnotationRepository
void Attach(AppUserAnnotation annotation);
void Update(AppUserAnnotation annotation);
void Remove(AppUserAnnotation annotation);
void Remove(IEnumerable<AppUserAnnotation> annotations);
Task<AnnotationDto?> GetAnnotationDto(int id);
Task<AppUserAnnotation?> GetAnnotation(int id);
Task<IList<AppUserAnnotation>> GetAnnotations(IList<int> ids);
Task<IList<FullAnnotationDto>> GetFullAnnotationsByUserIdAsync(int userId);
Task<IList<FullAnnotationDto>> GetFullAnnotations(int userId, IList<int> annotationIds);
Task<PagedList<AnnotationDto>> GetAnnotationDtos(int userId, BrowseAnnotationFilterDto filter, UserParams userParams);
}
public class AnnotationRepository(DataContext context, IMapper mapper) : IAnnotationRepository
@ -34,6 +49,11 @@ public class AnnotationRepository(DataContext context, IMapper mapper) : IAnnota
context.AppUserAnnotation.Remove(annotation);
}
public void Remove(IEnumerable<AppUserAnnotation> annotations)
{
context.AppUserAnnotation.RemoveRange(annotations);
}
public async Task<AnnotationDto?> GetAnnotationDto(int id)
{
return await context.AppUserAnnotation
@ -46,4 +66,109 @@ public class AnnotationRepository(DataContext context, IMapper mapper) : IAnnota
return await context.AppUserAnnotation
.FirstOrDefaultAsync(a => a.Id == id);
}
public async Task<IList<AppUserAnnotation>> GetAnnotations(IList<int> ids)
{
return await context.AppUserAnnotation
.Where(a => ids.Contains(a.Id))
.ToListAsync();
}
public async Task<PagedList<AnnotationDto>> GetAnnotationDtos(int userId, BrowseAnnotationFilterDto filter, UserParams userParams)
{
var query = await CreatedFilteredAnnotationQueryable(userId, filter);
return await PagedList<AnnotationDto>.CreateAsync(query, userParams);
}
private async Task<IQueryable<AnnotationDto>> CreatedFilteredAnnotationQueryable(int userId, BrowseAnnotationFilterDto filter)
{
var allLibrariesCount = await context.Library.CountAsync();
var userLibs = await context.Library.GetUserLibraries(userId).ToListAsync();
var seriesIds = await context.Series.Where(s => userLibs.Contains(s.LibraryId)).Select(s => s.Id).ToListAsync();
var query = context.AppUserAnnotation.AsNoTracking();
query = BuildAnnotationFilterQuery(userId, filter, query);
var validUsers = await context.AppUserPreferences
.Where(a => a.AppUserId == userId) // TODO: Remove when the below is done
.Where(p => true) // TODO: Filter on sharing annotations preference
.Select(p => p.AppUserId)
.ToListAsync();
query = query.Where(a => validUsers.Contains(a.AppUserId))
.WhereIf(allLibrariesCount != userLibs.Count,
a => seriesIds.Contains(a.SeriesId));
var sortedQuery = query.SortBy(filter.SortOptions);
var limitedQuery = filter.LimitTo <= 0 ? sortedQuery : sortedQuery.Take(filter.LimitTo);
return limitedQuery.ProjectTo<AnnotationDto>(mapper.ConfigurationProvider);
}
private static IQueryable<AppUserAnnotation> BuildAnnotationFilterQuery(int userId, BrowseAnnotationFilterDto filter, IQueryable<AppUserAnnotation> query)
{
if (filter.Statements == null || filter.Statements.Count == 0) return query;
// Manual intervention for Highlight slots, as they are not user recognisable. But would make sense
// to miss match between users
if (filter.Statements.Any(s => s.Field == AnnotationFilterField.HighlightSlot))
{
filter.Statements.Add(new AnnotationFilterStatementDto
{
Field = AnnotationFilterField.Owner,
Comparison = FilterComparison.Equal,
Value = $"{userId}",
});
}
var queries = filter.Statements
.Select(statement => BuildAnnotationFilterGroup(statement, query))
.ToList();
return filter.Combination == FilterCombination.And
? queries.Aggregate((q1, q2) => q1.Intersect(q2))
: queries.Aggregate((q1, q2) => q1.Union(q2));
}
private static IQueryable<AppUserAnnotation> BuildAnnotationFilterGroup(AnnotationFilterStatementDto statement, IQueryable<AppUserAnnotation> query)
{
var value = AnnotationFilterFieldValueConverter.ConvertValue(statement.Field, statement.Value);
return statement.Field switch
{
AnnotationFilterField.Owner => query.IsOwnedBy(true, statement.Comparison, (IList<int>) value),
AnnotationFilterField.Library => query.IsInLibrary(true, statement.Comparison, (IList<int>) value),
AnnotationFilterField.HighlightSlot => query.IsUsingHighlights(true, statement.Comparison, (IList<int>) value),
AnnotationFilterField.Spoiler => query.Where(a => !(bool) value || !a.ContainsSpoiler),
AnnotationFilterField.Comment => query.HasCommented(true, statement.Comparison, (string) value),
AnnotationFilterField.Selection => query.HasSelected(true, statement.Comparison, (string) value),
_ => throw new ArgumentOutOfRangeException(nameof(statement.Field), $"Unexpected value for field: {statement.Field}")
};
}
public async Task<IList<FullAnnotationDto>> GetFullAnnotations(int userId, IList<int> annotationIds)
{
return await context.AppUserAnnotation
.AsNoTracking()
.Where(a => annotationIds.Contains(a.Id))
.Where(a => a.AppUserId == userId)
//.Where(a => a.AppUserId == userId || a.AppUser.UserPreferences.ShareAnnotations) TODO: Filter out annotations for users who don't share them
.SelectFullAnnotation()
.ToListAsync();
}
/// <summary>
/// This does not track!
/// </summary>
/// <param name="userId"></param>
/// <returns></returns>
public async Task<IList<FullAnnotationDto>> GetFullAnnotationsByUserIdAsync(int userId)
{
return await context.AppUserAnnotation
.Where(a => a.AppUserId == userId)
.SelectFullAnnotation()
.ToListAsync();
}
}

View File

@ -1317,7 +1317,8 @@ public class SeriesRepository : ISeriesRepository
FilterField.ReadingDate => query.HasReadingDate(true, statement.Comparison, (DateTime) value, userId),
FilterField.ReadLast => query.HasReadLast(true, statement.Comparison, (int) value, userId),
FilterField.AverageRating => query.HasAverageRating(true, statement.Comparison, (float) value),
_ => throw new ArgumentOutOfRangeException(nameof(statement.Field), $"Unexpected value for field: {statement.Field}")
FilterField.FileSize => query.HasFileSize(true, statement.Comparison, (long) value),
_ => throw new ArgumentOutOfRangeException(nameof(statement.Field), $"Unexpected value for field: {statement.Field}"),
};
}

View File

@ -120,6 +120,7 @@ public interface IUserRepository
Task<AnnotationDto?> GetAnnotationDtoById(int userId, int annotationId);
Task<List<AnnotationDto>> GetAnnotationDtosBySeries(int userId, int seriesId);
Task UpdateUserAsActive(int userId);
}
public class UserRepository : IUserRepository
@ -630,6 +631,15 @@ public class UserRepository : IUserRepository
.ToListAsync();
}
public async Task UpdateUserAsActive(int userId)
{
await _context.Set<AppUser>()
.Where(u => u.Id == userId)
.ExecuteUpdateAsync(setters => setters
.SetProperty(u => u.LastActiveUtc, DateTime.UtcNow)
.SetProperty(u => u.LastActive, DateTime.Now));
}
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
{

View File

@ -38,35 +38,30 @@ public static class Seed
new()
{
Id = 1,
Title = "Cyan",
SlotNumber = 0,
Color = new RgbaColor { R = 0, G = 255, B = 255, A = 0.4f }
},
new()
{
Id = 2,
Title = "Green",
SlotNumber = 1,
Color = new RgbaColor { R = 0, G = 255, B = 0, A = 0.4f }
},
new()
{
Id = 3,
Title = "Yellow",
SlotNumber = 2,
Color = new RgbaColor { R = 255, G = 255, B = 0, A = 0.4f }
},
new()
{
Id = 4,
Title = "Orange",
SlotNumber = 3,
Color = new RgbaColor { R = 255, G = 165, B = 0, A = 0.4f }
},
new()
{
Id = 5,
Title = "Purple",
SlotNumber = 4,
Color = new RgbaColor { R = 255, G = 0, B = 255, A = 0.4f }
}

View File

@ -29,6 +29,14 @@ public class AppUserAnnotation : IEntityDate
/// </summary>
public string? Comment { get; set; }
/// <summary>
/// The annotation in html format, this is generated by the UI (quill)
/// </summary>
public string? CommentHtml { get; set; }
/// <summary>
/// All html stripped from <see cref="CommentHtml"/>, this is done by the backend
/// </summary>
public string? CommentPlainText { get; set; }
/// <summary>
/// The number of characters selected
/// </summary>
public int HighlightCount { get; set; }
@ -53,6 +61,7 @@ public class AppUserAnnotation : IEntityDate
public required int LibraryId { get; set; }
public required int SeriesId { get; set; }
public Series Series { get; set; }
public required int VolumeId { get; set; }
public required int ChapterId { get; set; }
public Chapter Chapter { get; set; }

View File

@ -139,10 +139,6 @@ public class AppUserReadingProfile
/// </summary>
/// <remarks>Defaults to false</remarks>
public bool BookReaderImmersiveMode { get; set; } = false;
/// <summary>
/// Book Reader Option: Different calculation modes for the page due to a bleed bug that devs cannot reproduce reliably or fix
/// </summary>
public EpubPageCalculationMethod BookReaderEpubPageCalculationMethod { get; set; } = EpubPageCalculationMethod.Default;
#endregion
#region PdfReader

View File

@ -3,10 +3,6 @@
public sealed record HighlightSlot
{
public int Id { get; set; }
/// <summary>
/// Hex representation
/// </summary>
public string Title { get; set; }
public int SlotNumber { get; set; }
public RgbaColor Color { get; set; }
}

View File

@ -0,0 +1,20 @@
using System;
using API.Controllers;
using API.Services;
namespace API.Exceptions;
/// <summary>
/// Should be caught in <see cref="OpdsController"/> and ONLY used in <see cref="OpdsService"/>
/// </summary>
public class OpdsException : Exception
{
public OpdsException()
{ }
public OpdsException(string message) : base(message)
{ }
public OpdsException(string message, Exception inner)
: base(message, inner) { }
}

View File

@ -3,6 +3,7 @@ using API.Constants;
using API.Controllers;
using API.Data;
using API.Helpers;
using API.Middleware;
using API.Services;
using API.Services.Plus;
using API.Services.Store;
@ -60,6 +61,8 @@ public static class ApplicationServiceExtensions
services.AddScoped<IReadingProfileService, ReadingProfileService>();
services.AddScoped<IKoreaderService, KoreaderService>();
services.AddScoped<IFontService, FontService>();
services.AddScoped<IAnnotationService, AnnotationService>();
services.AddScoped<IOpdsService, OpdsService>();
services.AddScoped<IScannerService, ScannerService>();
services.AddScoped<IProcessSeries, ProcessSeries>();
@ -89,6 +92,7 @@ public static class ApplicationServiceExtensions
services.AddScoped<IOidcService, OidcService>();
services.AddScoped<OpdsActionFilterAttribute>();
services.AddScoped<OpdsActiveUserMiddlewareAttribute>();
services.AddSqLite();
services.AddSignalR(opt => opt.EnableDetailedErrors = true);

View File

@ -12,6 +12,14 @@ namespace API.Extensions;
public static class HttpExtensions
{
/// <summary>
/// Adds pagination headers - Use with <see cref="PagedList{T}"/>
/// </summary>
/// <param name="response"></param>
/// <param name="currentPage"></param>
/// <param name="itemsPerPage"></param>
/// <param name="totalItems"></param>
/// <param name="totalPages"></param>
public static void AddPaginationHeader(this HttpResponse response, int currentPage,
int itemsPerPage, int totalItems, int totalPages)
{

View File

@ -0,0 +1,117 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.DTOs.Filtering.v2;
using API.DTOs.Reader;
using API.Entities;
using Kavita.Common;
using Microsoft.EntityFrameworkCore;
namespace API.Extensions.QueryExtensions.Filtering;
public static class AnnotationFilter
{
public static IQueryable<AppUserAnnotation> IsOwnedBy(this IQueryable<AppUserAnnotation> queryable, bool condition,
FilterComparison comparison, IList<int> ownerIds)
{
if (ownerIds.Count == 0 || !condition) return queryable;
return comparison switch
{
FilterComparison.Equal => queryable.Where(a => a.AppUserId == ownerIds[0]),
FilterComparison.Contains => queryable.Where(a => ownerIds.Contains(a.AppUserId)),
FilterComparison.NotContains => queryable.Where(a => !ownerIds.Contains(a.AppUserId)),
FilterComparison.NotEqual => queryable.Where(a => a.AppUserId != ownerIds[0]),
_ => throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null),
};
}
public static IQueryable<AppUserAnnotation> IsInLibrary(this IQueryable<AppUserAnnotation> queryable, bool condition,
FilterComparison comparison, IList<int> libraryIds)
{
if (libraryIds.Count == 0 || !condition) return queryable;
return comparison switch
{
FilterComparison.Equal => queryable.Where(a => a.Series.LibraryId == libraryIds[0]),
FilterComparison.Contains => queryable.Where(a => libraryIds.Contains(a.Series.LibraryId)),
FilterComparison.NotContains => queryable.Where(a => !libraryIds.Contains(a.Series.LibraryId)),
FilterComparison.NotEqual => queryable.Where(a => a.Series.LibraryId != libraryIds[0]),
_ => throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null),
};
}
public static IQueryable<AppUserAnnotation> IsUsingHighlights(this IQueryable<AppUserAnnotation> queryable, bool condition,
FilterComparison comparison, IList<int> highlightSlotIdxs)
{
if (highlightSlotIdxs.Count == 0 || !condition) return queryable;
return comparison switch
{
FilterComparison.Equal => queryable.Where(a => a.SelectedSlotIndex== highlightSlotIdxs[0]),
FilterComparison.Contains => queryable.Where(a => highlightSlotIdxs.Contains(a.SelectedSlotIndex)),
FilterComparison.NotContains => queryable.Where(a => !highlightSlotIdxs.Contains(a.SelectedSlotIndex)),
FilterComparison.NotEqual => queryable.Where(a => a.SelectedSlotIndex != highlightSlotIdxs[0]),
_ => throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null),
};
}
public static IQueryable<AppUserAnnotation> HasSelected(this IQueryable<AppUserAnnotation> queryable, bool condition,
FilterComparison comparison, string value)
{
if (string.IsNullOrEmpty(value) || !condition) return queryable;
return comparison switch
{
FilterComparison.Equal => queryable.Where(a => a.SelectedText == value),
FilterComparison.NotEqual => queryable.Where(a => a.SelectedText != value),
FilterComparison.BeginsWith => queryable.Where(a => EF.Functions.Like(a.SelectedText, $"{value}%")),
FilterComparison.EndsWith => queryable.Where(a => EF.Functions.Like(a.SelectedText, $"%{value}")),
FilterComparison.Matches => queryable.Where(a => EF.Functions.Like(a.SelectedText, $"%{value}%")),
FilterComparison.GreaterThan or
FilterComparison.GreaterThanEqual or
FilterComparison.LessThan or
FilterComparison.LessThanEqual or
FilterComparison.Contains or
FilterComparison.MustContains or
FilterComparison.NotContains or
FilterComparison.IsBefore or
FilterComparison.IsAfter or
FilterComparison.IsInLast or
FilterComparison.IsNotInLast or
FilterComparison.IsEmpty => throw new KavitaException($"{comparison} is not applicable for Annotation.SelectedText"),
_ => throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null),
};
}
public static IQueryable<AppUserAnnotation> HasCommented(this IQueryable<AppUserAnnotation> queryable, bool condition,
FilterComparison comparison, string value)
{
if (string.IsNullOrEmpty(value) || !condition) return queryable;
return comparison switch
{
FilterComparison.Equal => queryable.Where(a => a.CommentPlainText == value),
FilterComparison.NotEqual => queryable.Where(a => a.CommentPlainText != value),
FilterComparison.BeginsWith => queryable.Where(a => EF.Functions.Like(a.CommentPlainText, $"{value}%")),
FilterComparison.EndsWith => queryable.Where(a => EF.Functions.Like(a.CommentPlainText, $"%{value}")),
FilterComparison.Matches => queryable.Where(a => EF.Functions.Like(a.CommentPlainText, $"%{value}%")),
FilterComparison.GreaterThan or
FilterComparison.GreaterThanEqual or
FilterComparison.LessThan or
FilterComparison.LessThanEqual or
FilterComparison.Contains or
FilterComparison.MustContains or
FilterComparison.NotContains or
FilterComparison.IsBefore or
FilterComparison.IsAfter or
FilterComparison.IsInLast or
FilterComparison.IsNotInLast or
FilterComparison.IsEmpty => throw new KavitaException($"{comparison} is not applicable for Annotation.CommentPlainText"),
_ => throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null),
};
}
}

View File

@ -925,5 +925,21 @@ public static class SeriesFilter
}
}
public static IQueryable<Series> HasFileSize(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, float fileSize)
{
if (fileSize == 0f || !condition) return queryable;
return comparison switch
{
FilterComparison.Equal => queryable.Where(s => s.Volumes.Sum(v => v.Chapters.Sum(c => c.Files.Sum(f => f.Bytes))) == fileSize),
FilterComparison.LessThan => queryable.Where(s => s.Volumes.Sum(v => v.Chapters.Sum(c => c.Files.Sum(f => f.Bytes))) < fileSize),
FilterComparison.LessThanEqual => queryable.Where(s => s.Volumes.Sum(v => v.Chapters.Sum(c => c.Files.Sum(f => f.Bytes))) <= fileSize),
FilterComparison.GreaterThan => queryable.Where(s => s.Volumes.Sum(v => v.Chapters.Sum(c => c.Files.Sum(f => f.Bytes))) > fileSize),
FilterComparison.GreaterThanEqual => queryable.Where(s => s.Volumes.Sum(v => v.Chapters.Sum(c => c.Files.Sum(f => f.Bytes))) >= fileSize),
_ => throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported"),
};
}
}

View File

@ -6,6 +6,7 @@ using System.Threading.Tasks;
using API.Data.Misc;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Annotations;
using API.DTOs.Filtering;
using API.DTOs.KavitaPlus.Manage;
using API.DTOs.Metadata.Browse;
@ -291,10 +292,29 @@ public static class QueryableExtensions
PersonSortField.SeriesCount => query.OrderByDescending(p => p.SeriesMetadataPeople.Count),
PersonSortField.ChapterCount when sort.IsAscending => query.OrderBy(p => p.ChapterPeople.Count),
PersonSortField.ChapterCount => query.OrderByDescending(p => p.ChapterPeople.Count),
_ => query.OrderBy(p => p.Name)
_ => query.OrderBy(p => p.Name),
};
}
public static IQueryable<AppUserAnnotation> SortBy(this IQueryable<AppUserAnnotation> query, AnnotationSortOptions? sort)
{
if (sort == null)
{
return query.OrderBy(a => a.CreatedUtc);
}
return sort.SortField switch
{
AnnotationSortField.Owner when sort.IsAscending => query.OrderBy(a => a.AppUser.UserName),
AnnotationSortField.Owner => query.OrderByDescending(a => a.AppUser.UserName),
AnnotationSortField.Created when sort.IsAscending => query.OrderBy(a => a.CreatedUtc),
AnnotationSortField.Created => query.OrderByDescending(a => a.CreatedUtc),
AnnotationSortField.LastModified when sort.IsAscending => query.OrderBy(a => a.LastModifiedUtc),
AnnotationSortField.LastModified => query.OrderByDescending(a => a.LastModifiedUtc),
AnnotationSortField.Color when sort.IsAscending => query.OrderBy(a => a.SelectedSlotIndex),
AnnotationSortField.Color => query.OrderByDescending(a => a.SelectedSlotIndex),
_ => query.OrderBy(a => a.CreatedUtc),
};
}
/// <summary>
@ -325,4 +345,35 @@ public static class QueryableExtensions
_ => query
};
}
public static IQueryable<FullAnnotationDto> SelectFullAnnotation(this IQueryable<AppUserAnnotation> query)
{
return query.Select(a => new FullAnnotationDto
{
Id = a.Id,
UserId = a.AppUserId,
SelectedText = a.SelectedText,
Comment = a.Comment,
CommentHtml = a.CommentHtml,
CommentPlainText = a.CommentPlainText,
Context = a.Context,
ChapterTitle = a.ChapterTitle,
PageNumber = a.PageNumber,
SelectedSlotIndex = a.SelectedSlotIndex,
ContainsSpoiler = a.ContainsSpoiler,
CreatedUtc = a.CreatedUtc,
LastModifiedUtc = a.LastModifiedUtc,
LibraryId = a.LibraryId,
LibraryName = a.Chapter.Volume.Series.Library.Name,
SeriesId = a.SeriesId,
SeriesName = a.Chapter.Volume.Series.Name,
VolumeId = a.VolumeId,
VolumeName = a.Chapter.Volume.Name,
ChapterId = a.ChapterId,
})
.OrderBy(a => a.SeriesId)
.ThenBy(a => a.VolumeId)
.ThenBy(a => a.ChapterId)
.ThenBy(a => a.PageNumber);
}
}

View File

@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
@ -6,7 +7,7 @@ using System.Text.RegularExpressions;
namespace API.Extensions;
#nullable enable
public static class StringExtensions
public static partial class StringExtensions
{
private static readonly Regex SentenceCaseRegex = new(@"(^[a-z])|\.\s+(.)",
RegexOptions.ExplicitCapture | RegexOptions.Compiled,
@ -93,4 +94,51 @@ public static class StringExtensions
return string.IsNullOrEmpty(input) ? string.Empty : string.Concat(Enumerable.Repeat(input, n));
}
public static IList<int> ParseIntArray(this string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return [];
}
return value.Split(',')
.Where(s => !string.IsNullOrEmpty(s))
.Select(int.Parse)
.ToList();
}
/// <summary>
/// Parses a human-readable file size string (e.g. "1.43 GB") into bytes.
/// </summary>
/// <param name="input">The input string like "1.43 GB", "4.2 KB", "512 B"</param>
/// <returns>Byte count as long</returns>
public static long ParseHumanReadableBytes(this string input)
{
if (string.IsNullOrWhiteSpace(input))
throw new ArgumentException("Input cannot be null or empty.", nameof(input));
var match = HumanReadableBytesRegex().Match(input);
if (!match.Success)
throw new FormatException($"Invalid format: '{input}'");
var value = double.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
var unit = match.Groups[2].Value.ToUpperInvariant();
var multiplier = unit switch
{
"B" => 1L,
"KB" => 1L << 10,
"MB" => 1L << 20,
"GB" => 1L << 30,
"TB" => 1L << 40,
"PB" => 1L << 50,
"EB" => 1L << 60,
_ => throw new FormatException($"Unknown unit: '{unit}'")
};
return (long)(value * multiplier);
}
[GeneratedRegex(@"^\s*(\d+(?:\.\d+)?)\s*([KMGTPE]?B)\s*$", RegexOptions.IgnoreCase)]
private static partial Regex HumanReadableBytesRegex();
}

View File

@ -0,0 +1,25 @@
using System;
using System.Linq;
using API.DTOs.Filtering.v2;
using API.Extensions;
namespace API.Helpers.Converters;
public static class AnnotationFilterFieldValueConverter
{
public static object ConvertValue(AnnotationFilterField field, string value)
{
return field switch
{
AnnotationFilterField.Owner or
AnnotationFilterField.HighlightSlot or
AnnotationFilterField.Library => value.ParseIntArray(),
AnnotationFilterField.Spoiler => bool.Parse(value),
AnnotationFilterField.Selection => value,
AnnotationFilterField.Comment => value,
_ => throw new ArgumentOutOfRangeException(nameof(field), field, "Field is not supported")
};
}
}

View File

@ -107,6 +107,7 @@ public static class FilterFieldValueConverter
.ToList(),
FilterField.ReadTime => string.IsNullOrEmpty(value) ? 0 : int.Parse(value),
FilterField.AverageRating => string.IsNullOrEmpty(value) ? 0f : value.AsFloat(),
FilterField.FileSize => value.ParseHumanReadableBytes(),
_ => throw new ArgumentException("Invalid field type")
};
}

View File

@ -242,5 +242,7 @@
"generated-reading-profile-name": "Generated from {0}",
"genre-doesnt-exist": "Genre doesn't exist",
"font-url-not-allowed": "Uploading a Font by url is only allowed from Google Fonts"
"font-url-not-allowed": "Uploading a Font by url is only allowed from Google Fonts",
"annotation-export-failed": "Unable to export Annotations, check logs",
"download-not-allowed": "User does not have download permissions"
}

View File

@ -0,0 +1,64 @@
using System;
using System.Net;
using System.Threading.Tasks;
using API.Controllers;
using API.Data;
using API.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Logging;
namespace API.Middleware;
/// <summary>
/// Middleware that checks if Opds has been enabled for this server, and sets OpdsController.UserId in HttpContext
/// </summary>
[AttributeUsage(AttributeTargets.Class)]
public class OpdsActionFilterAttribute(IUnitOfWork unitOfWork, ILocalizationService localizationService, ILogger<OpdsController> logger): ActionFilterAttribute
{
public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
int userId;
try
{
if (!context.ActionArguments.TryGetValue("apiKey", out var apiKeyObj) || apiKeyObj is not string apiKey)
{
context.Result = new BadRequestResult();
return;
}
userId = await unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
if (userId == null || userId == 0)
{
context.Result = new UnauthorizedResult();
return;
}
var settings = await unitOfWork.SettingsRepository.GetSettingsDtoAsync();
if (!settings.EnableOpds)
{
context.Result = new ContentResult
{
Content = await localizationService.Translate(userId, "opds-disabled"),
ContentType = "text/plain",
StatusCode = (int)HttpStatusCode.BadRequest,
};
return;
}
}
catch (Exception ex)
{
logger.LogError(ex, "failed to handle OPDS request");
context.Result = new BadRequestResult();
return;
}
// Add the UserId from ApiKey onto the OPDSController
context.HttpContext.Items.Add(OpdsController.UserId, userId);
await next();
}
}

View File

@ -0,0 +1,46 @@
using System;
using System.Threading.Tasks;
using API.Controllers;
using API.Data;
using API.SignalR.Presence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Logging;
namespace API.Middleware;
/// <summary>
/// Middleware that will track any API calls as updating the authenticated (ApiKey) user's LastActive and inform <see cref="PresenceTracker"/>
/// </summary>
[AttributeUsage(AttributeTargets.Class)]
public class OpdsActiveUserMiddlewareAttribute(IUnitOfWork unitOfWork, IPresenceTracker presenceTracker, ILogger<OpdsController> logger) : ActionFilterAttribute
{
public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
try
{
if (!context.ActionArguments.TryGetValue("apiKey", out var apiKeyObj) || apiKeyObj is not string apiKey)
{
await next();
return;
}
var userId = await unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
if (userId == 0)
{
context.Result = new UnauthorizedResult();
return;
}
await unitOfWork.UserRepository.UpdateUserAsActive(userId);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to count User as active during OPDS request");
await next();
return;
}
await next();
}
}

View File

@ -0,0 +1,278 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.DTOs.Annotations;
using API.DTOs.Reader;
using API.Entities;
using API.Helpers;
using API.SignalR;
using HtmlAgilityPack;
using Kavita.Common;
using Microsoft.Extensions.Logging;
namespace API.Services;
public interface IAnnotationService
{
Task<AnnotationDto> CreateAnnotation(int userId, AnnotationDto dto);
Task<AnnotationDto> UpdateAnnotation(int userId, AnnotationDto dto);
/// <summary>
/// Export all annotations for a user, or optionally specify which annotation exactly
/// </summary>
/// <param name="userId"></param>
/// <param name="annotationIds"></param>
/// <returns></returns>
Task<string> ExportAnnotations(int userId, IList<int>? annotationIds = null);
}
public class AnnotationService : IAnnotationService
{
private readonly IUnitOfWork _unitOfWork;
private readonly IBookService _bookService;
private readonly IDirectoryService _directoryService;
private readonly IEventHub _eventHub;
private readonly ILogger<AnnotationService> _logger;
public AnnotationService(IUnitOfWork unitOfWork, IBookService bookService,
IDirectoryService directoryService, IEventHub eventHub, ILogger<AnnotationService> logger)
{
_unitOfWork = unitOfWork;
_bookService = bookService;
_directoryService = directoryService;
_eventHub = eventHub;
_logger = logger;
}
/// <summary>
/// Create a new Annotation for the user against a Chapter
/// </summary>
/// <param name="userId"></param>
/// <param name="dto"></param>
/// <returns></returns>
/// <exception cref="KavitaException">Message is not localized</exception>
public async Task<AnnotationDto> CreateAnnotation(int userId, AnnotationDto dto)
{
try
{
if (dto.HighlightCount == 0 || string.IsNullOrWhiteSpace(dto.SelectedText))
{
throw new KavitaException("invalid-payload");
}
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(dto.ChapterId) ?? throw new KavitaException("chapter-doesnt-exist");
var chapterTitle = string.Empty;
try
{
var toc = await _bookService.GenerateTableOfContents(chapter);
var pageTocs = BookChapterItemHelper.GetTocForPage(toc, dto.PageNumber);
if (pageTocs.Count > 0)
{
chapterTitle = pageTocs[0].Title;
}
}
catch (KavitaException)
{
/* Swallow */
}
var annotation = new AppUserAnnotation()
{
XPath = dto.XPath,
EndingXPath = dto.EndingXPath,
ChapterId = dto.ChapterId,
SeriesId = dto.SeriesId,
VolumeId = dto.VolumeId,
LibraryId = dto.LibraryId,
HighlightCount = dto.HighlightCount,
SelectedText = dto.SelectedText,
Comment = dto.Comment,
CommentHtml = dto.CommentHtml,
CommentPlainText = StripHtml(dto.CommentHtml),
ContainsSpoiler = dto.ContainsSpoiler,
PageNumber = dto.PageNumber,
SelectedSlotIndex = dto.SelectedSlotIndex,
AppUserId = userId,
Context = dto.Context,
ChapterTitle = chapterTitle
};
_unitOfWork.AnnotationRepository.Attach(annotation);
await _unitOfWork.CommitAsync();
return await _unitOfWork.AnnotationRepository.GetAnnotationDto(annotation.Id);
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an exception when creating an annotation on {ChapterId} - Page {Page}", dto.ChapterId, dto.PageNumber);
throw new KavitaException("annotation-failed-create");
}
}
/// <summary>
/// Update the modifiable fields (Spoiler, highlight slot, and comment) for an annotation
/// </summary>
/// <param name="userId"></param>
/// <param name="dto"></param>
/// <returns></returns>
/// <exception cref="KavitaException">Message is not localized</exception>
public async Task<AnnotationDto> UpdateAnnotation(int userId, AnnotationDto dto)
{
try
{
var annotation = await _unitOfWork.AnnotationRepository.GetAnnotation(dto.Id);
if (annotation == null || annotation.AppUserId != userId) throw new KavitaException("denied");
annotation.ContainsSpoiler = dto.ContainsSpoiler;
annotation.SelectedSlotIndex = dto.SelectedSlotIndex;
annotation.Comment = dto.Comment;
annotation.CommentHtml = dto.CommentHtml;
annotation.CommentPlainText = StripHtml(dto.CommentHtml);
_unitOfWork.AnnotationRepository.Update(annotation);
if (!_unitOfWork.HasChanges() || await _unitOfWork.CommitAsync())
{
await _eventHub.SendMessageToAsync(MessageFactory.AnnotationUpdate,
MessageFactory.AnnotationUpdateEvent(dto), userId);
return dto;
}
} catch (Exception ex)
{
_logger.LogError(ex, "There was an exception updating Annotation for Chapter {ChapterId} - Page {PageNumber}", dto.ChapterId, dto.PageNumber);
}
throw new KavitaException("generic-error");
}
public async Task<string> ExportAnnotations(int userId, IList<int>? annotationIds = null)
{
try
{
// Get users with preferences for highlight colors
var users = (await _unitOfWork.UserRepository
.GetAllUsersAsync(AppUserIncludes.UserPreferences))
.ToDictionary(u => u.Id, u => u);
// Get all annotations for the user with related data
IList<FullAnnotationDto> annotations;
if (annotationIds == null)
{
annotations = await _unitOfWork.AnnotationRepository.GetFullAnnotationsByUserIdAsync(userId);
}
else
{
annotations = await _unitOfWork.AnnotationRepository.GetFullAnnotations(userId, annotationIds);
}
// Get settings for hostname
var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
var hostname = !string.IsNullOrWhiteSpace(settings.HostName) ? settings.HostName : "http://localhost:5000";
// Group annotations by series, then by volume
var exportData = annotations
.GroupBy(a => new { a.SeriesId, a.SeriesName, a.LibraryId, a.LibraryName })
.Select(seriesGroup => new
{
series = new
{
id = seriesGroup.Key.SeriesId,
title = seriesGroup.Key.SeriesName,
libraryName = seriesGroup.Key.LibraryName,
libraryId = seriesGroup.Key.LibraryId
},
volumes = seriesGroup
.GroupBy(a => new { a.VolumeId, a.VolumeName })
.Select(volumeGroup => new
{
id = volumeGroup.Key.VolumeId,
title = volumeGroup.Key.VolumeName,
annotations = volumeGroup.Select(annotation =>
{
var user = users[annotation.UserId];
var highlightSlot = user.UserPreferences.BookReaderHighlightSlots
.FirstOrDefault(slot => slot.SlotNumber == annotation.SelectedSlotIndex);
var slotColor = highlightSlot != null
? $"#{highlightSlot.Color.R:X2}{highlightSlot.Color.G:X2}{highlightSlot.Color.B:X2}"
: "#000000";
var deepLink = $"{hostname}/library/{annotation.LibraryId}/series/{annotation.SeriesId}/book/{annotation.ChapterId}?incognitoMode=true&annotationId={annotation.Id}";
var obsidianTitle = $"{seriesGroup.Key.SeriesName} - {volumeGroup.Key.VolumeName}";
if (!string.IsNullOrWhiteSpace(annotation.ChapterTitle))
{
obsidianTitle += $" - {annotation.ChapterTitle}";
}
return new
{
id = annotation.Id,
selectedText = annotation.SelectedText,
comment = annotation.CommentHtml,
context = annotation.Context,
chapterTitle = annotation.ChapterTitle,
pageNumber = annotation.PageNumber,
slotColor,
containsSpoiler = annotation.ContainsSpoiler,
deepLink,
createdUtc = annotation.CreatedUtc.ToString("yyyy-MM-ddTHH:mm:ssZ"),
lastModifiedUtc = annotation.LastModifiedUtc.ToString("yyyy-MM-ddTHH:mm:ssZ"),
obsidianTags = new[] { "#kavita", $"#{seriesGroup.Key.SeriesName.ToLowerInvariant().Replace(" ", "-")}", "#highlights" },
obsidianTitle,
obsidianBacklinks = new[] { $"[[{seriesGroup.Key.SeriesName} Series]]", $"[[{volumeGroup.Key.VolumeName}]]" }
};
}).ToArray(),
}).ToArray(),
}).ToArray();
// Serialize to JSON
var options = new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
var json = JsonSerializer.Serialize(exportData, options);
_logger.LogInformation("Successfully exported {AnnotationCount} annotations for user {UserId}", annotations.Count, userId);
return json;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to export annotations for user {UserId}", userId);
throw new KavitaException("annotation-export-failed");
}
}
private string StripHtml(string? html)
{
if (string.IsNullOrEmpty(html))
{
return string.Empty;
}
try
{
var document = new HtmlDocument();
document.LoadHtml(html);
return document.DocumentNode.InnerText.Replace("&nbsp;", " ");
}
catch (Exception exception)
{
_logger.LogError(exception, "Invalid html, cannot parse plain text");
return string.Empty;
}
}
}

View File

@ -233,7 +233,7 @@ public class LocalizationService : ILocalizationService
RenderName = GetDisplayName(fileName),
TranslationCompletion = 0, // Will be calculated later
IsRtL = IsRightToLeft(fileName),
Hash = hash
Hash = hash,
};
}
else
@ -258,7 +258,15 @@ public class LocalizationService : ILocalizationService
}
}
var kavitaLocales = locales.Values.ToList();
var validFileNames = uiLanguages
.Select(file => _directoryService.FileSystem.Path.GetFileNameWithoutExtension(file))
.Intersect(backendLanguages
.Select(file => _directoryService.FileSystem.Path.GetFileNameWithoutExtension(file)))
.ToList();
var kavitaLocales = locales.Values
.Where(l => validFileNames.Contains(l.FileName))
.ToList();
_cache.Set(LocaleCacheKey, kavitaLocales, _localsCacheOptions);
return kavitaLocales;

1289
API/Services/OpdsService.cs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -33,6 +33,7 @@ public interface ILicenseService
Task<bool> HasActiveSubscription(string? license);
Task<bool> ResetLicense(string license, string email);
Task<LicenseInfoDto?> GetLicenseInfo(bool forceCheck = false);
Task<bool> ResendWelcomeEmail();
}
public class LicenseService(
@ -305,4 +306,34 @@ public class LicenseService(
return null;
}
/// <summary>
/// Attempts to resend a welcome email to the registered user. The sub does not need to be active.
/// </summary>
/// <returns></returns>
public async Task<bool> ResendWelcomeEmail()
{
try
{
var encryptedLicense = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey);
if (string.IsNullOrEmpty(encryptedLicense.Value)) return false;
var httpResponse = await (Configuration.KavitaPlusApiUrl + "/api/license/resend-welcome-email")
.WithKavitaPlusHeaders(encryptedLicense.Value)
.PostAsync();
var response = await httpResponse.GetStringAsync();
if (response == null) return false;
return response == "true";
}
catch (FlurlHttpException e)
{
logger.LogError(e, "An error happened during the request to Kavita+ API");
}
return false;
}
}

View File

@ -445,7 +445,6 @@ public class ReadingProfileService(IUnitOfWork unitOfWork, ILocalizationService
existingProfile.BookThemeName = dto.BookReaderThemeName;
existingProfile.BookReaderLayoutMode = dto.BookReaderLayoutMode;
existingProfile.BookReaderImmersiveMode = dto.BookReaderImmersiveMode;
existingProfile.BookReaderEpubPageCalculationMethod = dto.BookReaderEpubPageCalculationMethod;
// PDF Reading
existingProfile.PdfTheme = dto.PdfTheme;

View File

@ -118,13 +118,11 @@ public class TokenService : ITokenService
try
{
user.UpdateLastActive();
_unitOfWork.UserRepository.Update(user);
await _unitOfWork.CommitAsync();
await _unitOfWork.UserRepository.UpdateUserAsActive(user.Id);
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an error updating last active for the user");
_logger.LogError(ex, "Failed to update last active for {UserName}", user.UserName);
}
return new TokenRequestDto()

View File

@ -19,7 +19,7 @@ public interface IPresenceTracker
internal class ConnectionDetail
{
public string UserName { get; set; }
public List<string> ConnectionIds { get; set; } = new List<string>();
public List<string> ConnectionIds { get; set; } = [];
public bool IsAdmin { get; set; }
}
@ -29,7 +29,7 @@ internal class ConnectionDetail
public class PresenceTracker : IPresenceTracker
{
private readonly IUnitOfWork _unitOfWork;
private static readonly Dictionary<int, ConnectionDetail> OnlineUsers = new Dictionary<int, ConnectionDetail>();
private static readonly Dictionary<int, ConnectionDetail> OnlineUsers = new();
public PresenceTracker(IUnitOfWork unitOfWork)
{
@ -63,9 +63,9 @@ public class PresenceTracker : IPresenceTracker
{
lock (OnlineUsers)
{
if (!OnlineUsers.ContainsKey(userId)) return Task.CompletedTask;
if (!OnlineUsers.TryGetValue(userId, out var user)) return Task.CompletedTask;
OnlineUsers[userId].ConnectionIds.Remove(connectionId);
user.ConnectionIds.Remove(connectionId);
if (OnlineUsers[userId].ConnectionIds.Count == 0)
{

View File

@ -37,3 +37,8 @@ Run `npm run start`
# Update latest angular
`ng update @angular/core @angular/cli @typescript-eslint/parser @angular/localize @angular/compiler-cli @angular/cdk @angular/animations @angular/common @angular/forms @angular/platform-browser @angular/platform-browser-dynamic @angular/router`
`npm install @angular-eslint/builder@latest @angular-eslint/eslint-plugin@latest @angular-eslint/eslint-plugin-template@latest @angular-eslint/schematics@latest @angular-eslint/template-parser@latest`
# Update Localization library
`npm install @jsverse/transloco@latest @jsverse/transloco-locale@latest @jsverse/transloco-persist-lang@latest @jsverse/transloco-persist-translations@latest @jsverse/transloco-preload-langs@latest`

1223
UI/Web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -20,25 +20,25 @@
"private": true,
"dependencies": {
"@angular-slider/ngx-slider": "^20.0.0",
"@angular/animations": "^20.2.3",
"@angular/cdk": "^20.2.1",
"@angular/common": "^20.2.3",
"@angular/compiler": "^20.2.3",
"@angular/core": "^20.2.3",
"@angular/forms": "^20.2.3",
"@angular/localize": "^20.2.3",
"@angular/platform-browser": "^20.2.3",
"@angular/platform-browser-dynamic": "^20.2.3",
"@angular/router": "^20.2.3",
"@angular/animations": "^20.3.2",
"@angular/cdk": "^20.2.5",
"@angular/common": "^20.3.2",
"@angular/compiler": "^20.3.2",
"@angular/core": "^20.3.2",
"@angular/forms": "^20.3.2",
"@angular/localize": "^20.3.2",
"@angular/platform-browser": "^20.3.2",
"@angular/platform-browser-dynamic": "^20.3.2",
"@angular/router": "^20.3.2",
"@fortawesome/fontawesome-free": "^7.0.1",
"@iharbeck/ngx-virtual-scroller": "^19.0.1",
"@iplab/ngx-color-picker": "^20.0.0",
"@iplab/ngx-file-upload": "^20.0.0",
"@jsverse/transloco": "^7.6.1",
"@jsverse/transloco-locale": "^7.0.1",
"@jsverse/transloco-persist-lang": "^7.0.2",
"@jsverse/transloco-persist-translations": "^7.0.1",
"@jsverse/transloco-preload-langs": "^7.0.1",
"@jsverse/transloco": "^8.0.2",
"@jsverse/transloco-locale": "^8.0.2",
"@jsverse/transloco-persist-lang": "^8.0.2",
"@jsverse/transloco-persist-translations": "^8.0.2",
"@jsverse/transloco-preload-langs": "^8.0.2",
"@microsoft/signalr": "^9.0.6",
"@ng-bootstrap/ng-bootstrap": "^19.0.1",
"@popperjs/core": "^2.11.7",
@ -57,7 +57,7 @@
"ngx-file-drop": "^16.0.0",
"ngx-quill": "^28.0.1",
"ngx-stars": "^1.6.5",
"ngx-toastr": "^19.0.0",
"ngx-toastr": "^19.1.0",
"nosleep.js": "^0.12.0",
"rxjs": "^7.8.2",
"screenfull": "^6.0.2",
@ -66,21 +66,21 @@
"zone.js": "^0.15.1"
},
"devDependencies": {
"@angular-eslint/builder": "^20.1.1",
"@angular-eslint/eslint-plugin": "^20.1.1",
"@angular-eslint/eslint-plugin-template": "^20.1.1",
"@angular-eslint/schematics": "^20.1.1",
"@angular-eslint/template-parser": "^20.1.1",
"@angular/build": "^20.2.1",
"@angular/cli": "^20.2.1",
"@angular/compiler-cli": "^20.2.3",
"@angular-eslint/builder": "^20.3.0",
"@angular-eslint/eslint-plugin": "^20.3.0",
"@angular-eslint/eslint-plugin-template": "^20.3.0",
"@angular-eslint/schematics": "^20.3.0",
"@angular-eslint/template-parser": "^20.3.0",
"@angular/build": "^20.3.3",
"@angular/cli": "^20.3.3",
"@angular/compiler-cli": "^20.3.2",
"@types/d3": "^7.4.3",
"@types/file-saver": "^2.0.7",
"@types/luxon": "^3.6.2",
"@types/marked": "^5.0.2",
"@types/node": "^24.0.14",
"@typescript-eslint/eslint-plugin": "^8.38.0",
"@typescript-eslint/parser": "^8.42.0",
"@typescript-eslint/parser": "^8.44.1",
"eslint": "^9.31.0",
"jsonminify": "^0.4.2",
"karma-coverage": "~2.2.0",

View File

@ -0,0 +1,28 @@
import {FilterV2} from "./filter-v2";
export enum AnnotationsFilterField {
Owner = 1,
Library = 2,
Spoiler = 3,
HighlightSlots = 4,
Selection = 5,
Comment = 6,
}
export const allAnnotationsFilterFields = Object.keys(AnnotationsFilterField)
.filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0)
.map(key => parseInt(key, 10)) as AnnotationsFilterField[];
export enum AnnotationsSortField {
Owner = 1,
Created = 2,
LastModified = 3,
Color = 4,
}
export const allAnnotationsSortFields = Object.keys(AnnotationsSortField)
.filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0)
.map(key => parseInt(key, 10)) as AnnotationsSortField[];
export type AnnotationsFilter = FilterV2<AnnotationsFilterField, AnnotationsSortField>;

View File

@ -35,7 +35,8 @@ export enum FilterField
Imprint = 29,
Team = 30,
Location = 31,
ReadLast = 32
ReadLast = 32,
FileSize = 33,
}

View File

@ -11,7 +11,6 @@ import {PdfScrollMode} from "./pdf-scroll-mode";
import {PdfLayoutMode} from "./pdf-layout-mode";
import {PdfSpreadMode} from "./pdf-spread-mode";
import {UserBreakpoint} from "../../shared/_services/utility.service";
import {EpubPageCalculationMethod} from "../readers/epub-page-calculation-method";
export enum ReadingProfileKind {
Default = 0,
@ -52,7 +51,6 @@ export interface ReadingProfile {
bookReaderThemeName: string;
bookReaderLayoutMode: BookPageLayoutMode;
bookReaderImmersiveMode: boolean;
bookReaderEpubPageCalculationMethod: EpubPageCalculationMethod;
// PDF Reader
pdfTheme: PdfTheme;

View File

@ -1,6 +0,0 @@
export enum EpubPageCalculationMethod {
Default = 0,
Calculation1 = 1
}
export const allCalcMethods = [EpubPageCalculationMethod.Default, EpubPageCalculationMethod.Calculation1];

View File

@ -3,6 +3,7 @@ import {Preferences} from './preferences/preferences';
// This interface is only used for login and storing/retrieving JWT from local storage
export interface User {
id: number;
username: string;
token: string;
refreshToken: string;

View File

@ -70,6 +70,7 @@ export class BrowseTitlePipe implements PipeTransform {
case FilterField.ReadLast:
case FilterField.Summary:
case FilterField.SeriesName:
case FilterField.FileSize:
default:
return '';
}

View File

@ -1,20 +0,0 @@
import {Pipe, PipeTransform} from '@angular/core';
import {EpubPageCalculationMethod} from "../_models/readers/epub-page-calculation-method";
import {translate} from "@jsverse/transloco";
@Pipe({
name: 'epubPageCalcMethod'
})
export class EpubPageCalcMethodPipe implements PipeTransform {
transform(value: EpubPageCalculationMethod) {
switch (value) {
case EpubPageCalculationMethod.Default:
return translate('epub-page-calc-method-pipe.default');
case EpubPageCalculationMethod.Calculation1:
return translate('epub-page-calc-method-pipe.calc1');
}
}
}

View File

@ -76,6 +76,8 @@ export class FilterFieldPipe implements PipeTransform {
return translate('filter-field-pipe.read-last');
case FilterField.AverageRating:
return translate('filter-field-pipe.average-rating');
case FilterField.FileSize:
return translate('filter-field-pipe.file-size');
default:
throw new Error(`Invalid FilterField value: ${value}`);
}

View File

@ -3,6 +3,7 @@ import {FilterField} from "../_models/metadata/v2/filter-field";
import {translate} from "@jsverse/transloco";
import {ValidFilterEntity} from "../metadata-filter/filter-settings";
import {PersonFilterField} from "../_models/metadata/v2/person-filter-field";
import {AnnotationsFilterField} from "../_models/metadata/v2/annotations-filter";
@Pipe({
name: 'genericFilterField'
@ -12,6 +13,8 @@ export class GenericFilterFieldPipe implements PipeTransform {
transform<T extends number>(value: T, entityType: ValidFilterEntity): string {
switch (entityType) {
case "annotation":
return this.annotationsFilterField(value as AnnotationsFilterField);
case "series":
return this.translateFilterField(value as FilterField);
case "person":
@ -19,6 +22,24 @@ export class GenericFilterFieldPipe implements PipeTransform {
}
}
private annotationsFilterField(value: AnnotationsFilterField) {
switch (value) {
case AnnotationsFilterField.Selection:
return translate('generic-filter-field-pipe.annotation-selection')
case AnnotationsFilterField.Comment:
return translate('generic-filter-field-pipe.annotation-comment')
case AnnotationsFilterField.HighlightSlots:
return translate('generic-filter-field-pipe.annotation-highlights')
case AnnotationsFilterField.Owner:
return translate('generic-filter-field-pipe.annotation-owner');
case AnnotationsFilterField.Library:
return translate('filter-field-pipe.libraries');
case AnnotationsFilterField.Spoiler:
return translate('generic-filter-field-pipe.annotation-spoiler');
}
}
private translatePersonFilterField(value: PersonFilterField) {
switch (value) {
case PersonFilterField.Role:
@ -100,6 +121,8 @@ export class GenericFilterFieldPipe implements PipeTransform {
return translate('filter-field-pipe.read-last');
case FilterField.AverageRating:
return translate('filter-field-pipe.average-rating');
case FilterField.FileSize:
return translate('filter-field-pipe.file-size');
default:
throw new Error(`Invalid FilterField value: ${value}`);
}

View File

@ -3,6 +3,7 @@ import {SortField} from "../_models/metadata/series-filter";
import {TranslocoService} from "@jsverse/transloco";
import {ValidFilterEntity} from "../metadata-filter/filter-settings";
import {PersonSortField} from "../_models/metadata/v2/person-sort-field";
import {AnnotationsSortField} from "../_models/metadata/v2/annotations-filter";
@Pipe({
name: 'sortField',
@ -15,6 +16,8 @@ export class SortFieldPipe implements PipeTransform {
transform<T extends number>(value: T, entityType: ValidFilterEntity): string {
switch (entityType) {
case "annotation":
return this.getAnnotationSortFields(value as AnnotationsSortField);
case 'series':
return this.seriesSortFields(value as SortField);
case 'person':
@ -23,6 +26,19 @@ export class SortFieldPipe implements PipeTransform {
}
}
private getAnnotationSortFields(value: AnnotationsSortField) {
switch (value) {
case AnnotationsSortField.Color:
return this.translocoService.translate('sort-field-pipe.annotation-color');
case AnnotationsSortField.LastModified:
return this.translocoService.translate('sort-field-pipe.last-modified');
case AnnotationsSortField.Owner:
return this.translocoService.translate('sort-field-pipe.annotation-owner');
case AnnotationsSortField.Created:
return this.translocoService.translate('sort-field-pipe.created');
}
}
private personSortFields(value: PersonSortField) {
switch (value) {
case PersonSortField.Name:

View File

@ -3,6 +3,7 @@ import {BrowsePeopleComponent} from "../browse/browse-people/browse-people.compo
import {BrowseGenresComponent} from "../browse/browse-genres/browse-genres.component";
import {BrowseTagsComponent} from "../browse/browse-tags/browse-tags.component";
import {UrlFilterResolver} from "../_resolvers/url-filter.resolver";
import {AllAnnotationsComponent} from "../all-annotations/all-annotations.component";
export const routes: Routes = [
@ -21,4 +22,10 @@ export const routes: Routes = [
},
{path: 'genres', component: BrowseGenresComponent, pathMatch: 'full'},
{path: 'tags', component: BrowseTagsComponent, pathMatch: 'full'},
{path: 'annotations', component: AllAnnotationsComponent, pathMatch: 'full',
resolve: {
filter: UrlFilterResolver,
},
runGuardsAndResolvers: 'always'
}
];

View File

@ -14,6 +14,7 @@ import {SmartFilter} from "../_models/metadata/v2/smart-filter";
import {translate} from "@jsverse/transloco";
import {Person} from "../_models/metadata/person";
import {User} from '../_models/user';
import {Annotation} from "../book-reader/_models/annotations/annotation";
export enum Action {
Submenu = -1,
@ -130,6 +131,7 @@ export enum Action {
* Remove the reading profile from the entity
*/
ClearReadingProfile = 31,
Export = 32,
}
/**
@ -150,7 +152,7 @@ export interface ActionItem<T> {
/**
* @deprecated Use required Roles instead
*/
requiresAdmin: boolean;
requiresAdmin?: boolean;
children: Array<ActionItem<T>>;
/**
* An optional class which applies to an item. ie) danger on a delete action
@ -194,6 +196,7 @@ export class ActionFactoryService {
private sideNavStreamActions: Array<ActionItem<SideNavStream>> = [];
private smartFilterActions: Array<ActionItem<SmartFilter>> = [];
private sideNavHomeActions: Array<ActionItem<void>> = [];
private annotationActions: Array<ActionItem<Annotation>> = [];
constructor() {
this.accountService.currentUser$.subscribe((_) => {
@ -245,6 +248,10 @@ export class ActionFactoryService {
return this.applyCallbackToList(this.sideNavHomeActions, callback, shouldRenderFunc);
}
getAnnotationActions(callback: ActionCallback<Annotation>, shouldRenderFunc: ActionShouldRenderFunc<Annotation> = this.dummyShouldRender) {
return this.applyCallbackToList(this.annotationActions, callback, shouldRenderFunc);
}
dummyCallback(action: ActionItem<any>, entity: any) {}
dummyShouldRender(action: ActionItem<any>, entity: any, user: User) {return true;}
basicReadRender(action: ActionItem<any>, entity: any, user: User) {
@ -1099,7 +1106,28 @@ export class ActionFactoryService {
requiredRoles: [],
children: [],
}
]
];
this.annotationActions = [
{
action: Action.Delete,
title: 'delete',
description: '',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiredRoles: [],
children: [],
},
{
action: Action.Export,
title: 'export',
description: '',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiredRoles: [],
children: [],
}
];
}
@ -1118,9 +1146,9 @@ export class ActionFactoryService {
});
}
public applyCallbackToList(list: Array<ActionItem<any>>,
callback: ActionCallback<any>,
shouldRenderFunc: ActionShouldRenderFunc<any> = this.dummyShouldRender): Array<ActionItem<any>> {
public applyCallbackToList<T>(list: Array<ActionItem<T>>,
callback: ActionCallback<T>,
shouldRenderFunc: ActionShouldRenderFunc<T> = this.dummyShouldRender): Array<ActionItem<T>> {
// Create a clone of the list to ensure we aren't affecting the default state
const actions = list.map((a) => {
return { ...a };

View File

@ -1,15 +1,22 @@
import {computed, inject, Injectable, signal} from '@angular/core';
import {environment} from "../../environments/environment";
import {HttpClient} from "@angular/common/http";
import {HttpClient, HttpParams} from "@angular/common/http";
import {Annotation} from '../book-reader/_models/annotations/annotation';
import {TextResonse} from "../_types/text-response";
import {map, of, tap} from "rxjs";
import {switchMap} from "rxjs/operators";
import {asyncScheduler, map, of, tap} from "rxjs";
import {switchMap, throttleTime} from "rxjs/operators";
import {AccountService} from "./account.service";
import {User} from "../_models/user";
import {MessageHubService} from "./message-hub.service";
import {RgbaColor} from "../book-reader/_models/annotations/highlight-slot";
import {Router} from "@angular/router";
import {SAVER, Saver} from "../_providers/saver.provider";
import {download} from "../shared/_models/download";
import {DEBOUNCE_TIME} from "../shared/_services/download.service";
import {FilterV2} from "../_models/metadata/v2/filter-v2";
import {AnnotationsFilterField, AnnotationsSortField} from "../_models/metadata/v2/annotations-filter";
import {UtilityService} from "../shared/_services/utility.service";
import {PaginatedResult} from "../_models/pagination";
/**
* Represents any modification (create/delete/edit) that occurs to annotations
@ -28,9 +35,11 @@ export class AnnotationService {
private readonly httpClient = inject(HttpClient);
private readonly accountService = inject(AccountService);
private readonly utilityService = inject(UtilityService);
private readonly messageHub = inject(MessageHubService);
private readonly router = inject(Router);
private readonly baseUrl = environment.apiUrl;
private readonly save = inject<Saver>(SAVER);
private _annotations = signal<Annotation[]>([]);
/**
@ -73,6 +82,16 @@ export class AnnotationService {
}));
}
getAllAnnotationsFiltered(filter: FilterV2<AnnotationsFilterField, AnnotationsSortField>, pageNum?: number, itemsPerPage?: number) {
const params = this.utilityService.addPaginationIfExists(new HttpParams(), pageNum, itemsPerPage);
return this.httpClient.post<PaginatedResult<Annotation>[]>(this.baseUrl + 'annotation/all-filtered', filter, {observe: 'response', params}).pipe(
map((res: any) => {
return this.utilityService.createPaginatedResult(res as PaginatedResult<Annotation>[]);
}),
);
}
getAnnotationsForSeries(seriesId: number) {
return this.httpClient.get<Array<Annotation>>(this.baseUrl + 'annotation/all-for-series?seriesId=' + seriesId);
}
@ -109,23 +128,47 @@ export class AnnotationService {
return this.httpClient.get<Annotation>(this.baseUrl + `annotation/${annotationId}`);
}
delete(id: number) {
const filtered = this.annotations().filter(a => a.id === id);
if (filtered.length === 0) return of();
const annotationToDelete = filtered[0];
/**
* Deletes an annotation without it needing to be loading in the signal.
* Used in the ViewEditAnnotationDrawer. Event is still fired.
* @param annotation
*/
deleteAnnotation(annotation: Annotation) {
const id = annotation.id;
return this.httpClient.delete(this.baseUrl + `annotation?annotationId=${id}`, TextResonse).pipe(tap(_ => {
const annotations = this._annotations();
this._annotations.set(annotations.filter(a => a.id !== id));
this._events.set({
pageNumber: annotationToDelete.pageNumber,
pageNumber: annotation.pageNumber,
type: 'delete',
annotation: annotationToDelete
annotation: annotation
});
}));
}
delete(id: number) {
const filtered = this.annotations().filter(a => a.id === id);
if (filtered.length === 0) return of();
const annotationToDelete = filtered[0];
return this.deleteAnnotation(annotationToDelete);
}
/**
* While this method will update the services annotations list. No events will be sent out.
* Deletion on the callers' side should be handled in the rxjs chain.
* @param ids
*/
bulkDelete(ids: number[]) {
return this.httpClient.post(this.baseUrl + "annotation/bulk-delete", ids).pipe(
tap(() => {
this._annotations.update(x => x.filter(a => !ids.includes(a.id)));
}),
);
}
/**
* Routes to the book reader with the annotation in view
* @param item
@ -133,4 +176,29 @@ export class AnnotationService {
navigateToAnnotation(item: Annotation) {
this.router.navigate(['/library', item.libraryId, 'series', item.seriesId, 'book', item.chapterId], { queryParams: { annotation: item.id } });
}
exportFilter(filter: FilterV2<AnnotationsFilterField, AnnotationsSortField>, pageNum?: number, itemsPerPage?: number) {
const params = this.utilityService.addPaginationIfExists(new HttpParams(), pageNum, itemsPerPage);
return this.httpClient.post(this.baseUrl + 'annotation/export-filter', filter, {
observe: 'events',
responseType: 'blob',
reportProgress: true,
params}).
pipe(
throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
download((blob, filename) => {
this.save(blob, decodeURIComponent(filename));
})
);
}
exportAnnotations(ids?: number[]) {
return this.httpClient.post(this.baseUrl + 'annotation/export', ids, {observe: 'events', responseType: 'blob', reportProgress: true}).pipe(
throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
download((blob, filename) => {
this.save(blob, decodeURIComponent(filename));
})
);
}
}

View File

@ -162,10 +162,6 @@ export class EpubReaderMenuService {
this.offcanvasService.dismiss();
}
if (!editMode && this.utilityService.activeUserBreakpoint() <= UserBreakpoint.Tablet) {
// Open a modal to view the annotation?
}
const ref = this.offcanvasService.open(ViewEditAnnotationDrawerComponent, {position: 'bottom'});
ref.componentInstance.annotation.set(annotation);
(ref.componentInstance as ViewEditAnnotationDrawerComponent).mode.set(editMode ? AnnotationMode.Edit : AnnotationMode.View);

View File

@ -18,7 +18,6 @@ import {UserBreakpoint, UtilityService} from "../shared/_services/utility.servic
import {environment} from "../../environments/environment";
import {EpubFont} from "../_models/preferences/epub-font";
import {FontService} from "./font.service";
import {EpubPageCalculationMethod} from "../_models/readers/epub-page-calculation-method";
export interface ReaderSettingUpdate {
setting: 'pageStyle' | 'clickToPaginate' | 'fullscreen' | 'writingStyle' | 'layoutMode' | 'readingDirection' | 'immersiveMode' | 'theme' | 'pageCalcMethod';
@ -36,7 +35,6 @@ export type BookReadingProfileFormGroup = FormGroup<{
bookReaderThemeName: FormControl<string>;
bookReaderLayoutMode: FormControl<BookPageLayoutMode>;
bookReaderImmersiveMode: FormControl<boolean>;
bookReaderEpubPageCalculationMethod: FormControl<EpubPageCalculationMethod>;
}>
@Injectable()
@ -64,7 +62,6 @@ export class EpubReaderSettingsService {
private readonly _activeTheme = signal<BookTheme | undefined>(undefined);
private readonly _clickToPaginate = signal<boolean>(false);
private readonly _layoutMode = signal<BookPageLayoutMode>(BookPageLayoutMode.Default);
private readonly _pageCalcMode = signal<EpubPageCalculationMethod>(EpubPageCalculationMethod.Default);
private readonly _immersiveMode = signal<boolean>(false);
private readonly _isFullscreen = signal<boolean>(false);
@ -89,7 +86,6 @@ export class EpubReaderSettingsService {
public readonly immersiveMode = this._immersiveMode.asReadonly();
public readonly isFullscreen = this._isFullscreen.asReadonly();
public readonly epubFonts = this._epubFonts.asReadonly();
public readonly pageCalcMode = this._pageCalcMode.asReadonly();
// Computed signals for derived state
public readonly layoutMode = computed(() => {
@ -209,18 +205,6 @@ export class EpubReaderSettingsService {
});
}
});
effect(() => {
const pageCalcMethod = this._pageCalcMode();
if (!this.isInitialized) return;
if (pageCalcMethod) {
this.settingUpdateSubject.next({
setting: 'pageCalcMethod',
object: pageCalcMethod
});
}
});
}
@ -284,9 +268,6 @@ export class EpubReaderSettingsService {
if (profile.bookReaderLayoutMode === undefined) {
profile.bookReaderLayoutMode = BookPageLayoutMode.Default;
}
if (profile.bookReaderEpubPageCalculationMethod === undefined) {
profile.bookReaderEpubPageCalculationMethod = EpubPageCalculationMethod.Default;
}
// Update signals from profile
this._readingDirection.set(profile.bookReaderReadingDirection);
@ -294,7 +275,6 @@ export class EpubReaderSettingsService {
this._clickToPaginate.set(profile.bookReaderTapToPaginate);
this._layoutMode.set(profile.bookReaderLayoutMode);
this._immersiveMode.set(profile.bookReaderImmersiveMode);
this._pageCalcMode.set(profile.bookReaderEpubPageCalculationMethod);
// Set up page styles
this.setPageStyles(
@ -393,11 +373,6 @@ export class EpubReaderSettingsService {
this.settingsForm.get('bookReaderWritingStyle')?.setValue(value);
}
updatePageCalcMethod(value: EpubPageCalculationMethod) {
this._pageCalcMode.set(value);
this.settingsForm.get('bookReaderEpubPageCalculationMethod')?.setValue(value);
}
updateFullscreen(value: boolean) {
this._isFullscreen.set(value);
if (!this._isInitialized()) return;
@ -492,7 +467,6 @@ export class EpubReaderSettingsService {
bookReaderThemeName: this.fb.control(profile.bookReaderThemeName),
bookReaderLayoutMode: this.fb.control(this._layoutMode()),
bookReaderImmersiveMode: this.fb.control(this._immersiveMode()),
bookReaderEpubPageCalculationMethod: this.fb.control(this._pageCalcMode())
});
// Set up value change subscriptions
@ -607,14 +581,6 @@ export class EpubReaderSettingsService {
this.isUpdatingFromForm = false;
});
// Page Calc Method
this.settingsForm.get('bookReaderEpubPageCalculationMethod')?.valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(value => {
this.isUpdatingFromForm = true;
this._pageCalcMode.set(value as EpubPageCalculationMethod);
this.isUpdatingFromForm = false;
});
// Update implicit profile on form changes (debounced) - ONLY source of profile updates
this.settingsForm.valueChanges.pipe(
@ -678,7 +644,6 @@ export class EpubReaderSettingsService {
data.bookReaderImmersiveMode = this._immersiveMode();
data.bookReaderReadingDirection = this._readingDirection();
data.bookReaderWritingStyle = this._writingStyle();
data.bookReaderEpubPageCalculationMethod = this._pageCalcMode();
const activeTheme = this._activeTheme();
if (activeTheme) {

View File

@ -42,6 +42,10 @@ export class LicenseService {
return this.httpClient.post<string>(this.baseUrl + 'license/reset', {license, email}, TextResonse);
}
resendLicense() {
return this.httpClient.post<boolean>(this.baseUrl + 'license/resend-license', {}, TextResonse).pipe(map(res => (res + '') === "true"));
}
/**
* Returns information about License and will internally cache if license is valid or not
*/

View File

@ -1,7 +1,7 @@
import {HttpClient, HttpParams} from '@angular/common/http';
import {inject, Injectable} from '@angular/core';
import {computed, inject, Injectable} from '@angular/core';
import {tap} from 'rxjs/operators';
import {map, of} from 'rxjs';
import {map, Observable, of} from 'rxjs';
import {environment} from 'src/environments/environment';
import {Genre} from '../_models/metadata/genre';
import {AgeRatingDto} from '../_models/metadata/age-rating-dto';
@ -22,7 +22,7 @@ import {TextResonse} from "../_types/text-response";
import {QueryContext} from "../_models/metadata/v2/query-context";
import {AgeRatingPipe} from "../_pipes/age-rating.pipe";
import {MangaFormatPipe} from "../_pipes/manga-format.pipe";
import {TranslocoService} from "@jsverse/transloco";
import {translate, TranslocoService} from "@jsverse/transloco";
import {LibraryService} from './library.service';
import {CollectionTagService} from "./collection-tag.service";
import {PaginatedResult} from "../_models/pagination";
@ -33,6 +33,10 @@ import {ValidFilterEntity} from "../metadata-filter/filter-settings";
import {PersonFilterField} from "../_models/metadata/v2/person-filter-field";
import {PersonRolePipe} from "../_pipes/person-role.pipe";
import {PersonSortField} from "../_models/metadata/v2/person-sort-field";
import {AnnotationsFilterField} from "../_models/metadata/v2/annotations-filter";
import {AccountService} from "./account.service";
import {MemberService} from "./member.service";
import {RgbaColor} from "../book-reader/_models/annotations/highlight-slot";
@Injectable({
providedIn: 'root'
@ -45,6 +49,12 @@ export class MetadataService {
private readonly libraryService = inject(LibraryService);
private readonly collectionTagService = inject(CollectionTagService);
private readonly utilityService = inject(UtilityService);
private readonly accountService = inject(AccountService);
private readonly memberService = inject(MemberService)
private readonly highlightSlots = computed(() => {
return this.accountService.currentUserSignal()?.preferences?.bookReaderHighlightSlots ?? [];
});
baseUrl = environment.apiUrl;
private validLanguages: Array<Language> = [];
@ -167,6 +177,12 @@ export class MetadataService {
createDefaultFilterStatement(entityType: ValidFilterEntity) {
switch (entityType) {
case "annotation":
const userId = this.accountService.currentUserSignal()?.id;
if (userId) {
return this.createFilterStatement(AnnotationsFilterField.Owner, FilterComparison.Equal, `${this.accountService.currentUserSignal()!.id}`);
}
return this.createFilterStatement(AnnotationsFilterField.Owner);
case 'series':
return this.createFilterStatement(FilterField.SeriesName);
case 'person':
@ -240,8 +256,9 @@ export class MetadataService {
* @param entityType
*/
getOptionsForFilterField<T extends number>(filterField: T, entityType: ValidFilterEntity) {
switch (entityType) {
case "annotation":
return this.getAnnotationOptionsForFilterField(filterField as AnnotationsFilterField);
case 'series':
return this.getSeriesOptionsForFilterField(filterField as FilterField);
case 'person':
@ -249,6 +266,25 @@ export class MetadataService {
}
}
private getAnnotationOptionsForFilterField(field: AnnotationsFilterField): Observable<{value: number, label: string, color?: RgbaColor}[]> {
switch (field) {
case AnnotationsFilterField.Owner:
return this.memberService.getMembers(false).pipe(map(members => members.map(member => {
return {value: member.id, label: member.username};
})));
case AnnotationsFilterField.Library:
return this.libraryService.getLibraries().pipe(map(libs => libs.map(lib => {
return {value: lib.id, label: lib.name};
})));
case AnnotationsFilterField.HighlightSlots:
return of(this.highlightSlots().map((slot, idx) => {
return {value: slot.slotNumber, label: translate('highlight-bar.slot-label', {slot: slot.slotNumber + 1}), color: slot.color}; // Slots start at 0
}));
}
return of([]);
}
private getPersonOptionsForFilterField(field: PersonFilterField) {
switch (field) {
case PersonFilterField.Role:

View File

@ -1,5 +1,5 @@
import {DOCUMENT} from '@angular/common';
import { DestroyRef, inject, Injectable, Renderer2, RendererFactory2, RendererStyleFlags2 } from '@angular/core';
import {DestroyRef, inject, Injectable, Renderer2, RendererFactory2, RendererStyleFlags2} from '@angular/core';
import {filter, ReplaySubject, take} from 'rxjs';
import {HttpClient} from "@angular/common/http";
import {environment} from "../../environments/environment";
@ -9,10 +9,8 @@ import {AccountService} from "./account.service";
import {map} from "rxjs/operators";
import {NavigationEnd, Router} from "@angular/router";
import {takeUntilDestroyed, toSignal} from "@angular/core/rxjs-interop";
import {SettingsTabId} from "../sidenav/preference-nav/preference-nav.component";
import {WikiLink} from "../_models/wiki";
import {AuthGuard} from "../_guards/auth.guard";
import {SettingsService} from "../admin/settings.service";
/**
* NavItem used to construct the dropdown or NavLinkModal on mobile
@ -57,6 +55,10 @@ export class NavService {
transLocoKey: 'browse-tags',
routerLink: '/browse/tags',
},
{
transLocoKey: 'all-annotations',
routerLink: '/browse/annotations'
},
{
transLocoKey: 'announcements',
routerLink: '/announcements/',

View File

@ -3,7 +3,14 @@
<div class="card-container row g-0" #container>
@for(item of scroll.viewPortItems; let idx = $index; track item.id) {
<div style="min-width: 200px" class="col-auto m-2">
<app-annotation-card [annotation]="item" [allowEdit]="false" [showPageLink]="false" [isInReader]="false" [showInReaderLink]="true" />
<app-annotation-card
[annotation]="item"
[allowEdit]="false"
[showPageLink]="false"
[isInReader]="false"
[showInReaderLink]="true"
[openInIncognitoMode]="true"
/>
</div>
}
</div>

View File

@ -2,13 +2,12 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
DestroyRef, effect,
EventEmitter,
inject,
Input,
OnChanges,
OnDestroy,
OnInit,
Output
} from '@angular/core';
import {NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle, NgbModal} from '@ng-bootstrap/ng-bootstrap';
@ -17,7 +16,6 @@ import {ActionableEntity, ActionItem} from 'src/app/_services/action-factory.ser
import {AsyncPipe, NgTemplateOutlet} from "@angular/common";
import {TranslocoDirective} from "@jsverse/transloco";
import {DynamicListPipe} from "./_pipes/dynamic-list.pipe";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {Breakpoint, UtilityService} from "../../shared/_services/utility.service";
import {ActionableModalComponent} from "../actionable-modal/actionable-modal.component";
import {User} from "../../_models/user";
@ -33,7 +31,7 @@ import {User} from "../../_models/user";
styleUrls: ['./card-actionables.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CardActionablesComponent implements OnInit, OnChanges, OnDestroy {
export class CardActionablesComponent implements OnChanges, OnDestroy {
private readonly cdRef = inject(ChangeDetectorRef);
private readonly accountService = inject(AccountService);
@ -65,17 +63,20 @@ export class CardActionablesComponent implements OnInit, OnChanges, OnDestroy {
submenu: {[key: string]: NgbDropdown} = {};
private closeTimeout: any = null;
ngOnInit(): void {
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((user) => {
constructor() {
effect(() => {
const user = this.accountService.currentUserSignal();
if (!user) return;
this.currentUser = user;
this.actions = this.inputActions.filter(a => this.willRenderAction(a, user!));
this.actions = this.inputActions.filter(a => this.willRenderAction(a, user));
this.cdRef.markForCheck();
});
}
ngOnChanges() {
if (!this.currentUser) return; // We can safely return as actionables will never be visible if there is no user
this.actions = this.inputActions.filter(a => this.willRenderAction(a, this.currentUser!));
this.cdRef.markForCheck();
}

View File

@ -42,7 +42,7 @@
</i>
}
}
@if (!isChecking() && hasLicense() && !licenseInfo) {
@if (!isChecking() && hasLicense() && !licenseInfo()) {
<div><span class="error">{{t('license-mismatch')}}</span></div>
}
@ -203,6 +203,14 @@
<a class="btn btn-primary btn-sm mt-1" [href]="manageLink()" target="_blank" rel="noreferrer nofollow">{{t('manage')}}</a>
</app-setting-button>
</div>
<div class="mt-2 mb-2">
<app-setting-button [subtitle]="t('resend-license-email-tooltip')">
<button type="button" class="flex-fill btn btn-primary btn-sm mt-1" aria-describedby="license-key-header" (click)="resendWelcomeEmail()">
{{t('resend-license-email')}}
</button>
</app-setting-button>
</div>
</div>
}
</div>

View File

@ -56,7 +56,7 @@ export class LicenseComponent implements OnInit {
if (!email) return environment.manageLink;
return environment.manageLink + '?prefilled_email=' + encodeURIComponent(email);
})
});
@ -198,6 +198,17 @@ export class LicenseComponent implements OnInit {
});
}
resendWelcomeEmail() {
this.licenseService.resendLicense().subscribe(res => {
if (res) {
this.toastr.success(translate('toasts.k+-resend-welcome-email-success'));
} else {
this.toastr.error(translate('toasts.k+-resend-welcome-message-error'));
}
})
}
updateEditMode(mode: boolean) {
this.isViewMode.set(!mode);
}

View File

@ -128,7 +128,7 @@ export class ManageMetadataMappingsComponent implements OnInit {
export() {
const data = this.packData();
this.downloadService.downloadObjectAsJson(data, translate('manage-metadata-settings.export-file-name'))
this.downloadService.downloadObjectAsJson(data, translate('manage-metadata-settings.export-file-name'));
}
addAgeRatingMapping(str: string = '', rating: AgeRating = AgeRating.Unknown) {

View File

@ -3,6 +3,7 @@
<form [formGroup]="settingsForm">
<h4>{{t('title')}}</h4>
<ng-container>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('taskScan'); as formControl) {

View File

@ -18,7 +18,9 @@ import {
} from 'rxjs';
import {ServerService} from 'src/app/_services/server.service';
import {Job} from 'src/app/_models/job/job';
import {UpdateNotificationModalComponent} from 'src/app/announcements/_components/update-notification/update-notification-modal.component';
import {
UpdateNotificationModalComponent
} from 'src/app/announcements/_components/update-notification/update-notification-modal.component';
import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
import {DownloadService} from 'src/app/shared/_services/download.service';
import {DefaultValuePipe} from '../../_pipes/default-value.pipe';
@ -32,6 +34,7 @@ import {SettingItemComponent} from "../../settings/_components/setting-item/sett
import {SettingButtonComponent} from "../../settings/_components/setting-button/setting-button.component";
import {DefaultModalOptions} from "../../_models/default-modal-options";
import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable";
import {AnnotationService} from "../../_services/annotation.service";
interface AdhocTask {
name: string;
@ -59,6 +62,7 @@ export class ManageTasksSettingsComponent implements OnInit {
private readonly serverService = inject(ServerService);
private readonly modalService = inject(NgbModal);
private readonly downloadService = inject(DownloadService);
private readonly annotationService = inject(AnnotationService);
serverSettings!: ServerSettings;
settingsForm: FormGroup = new FormGroup({});
@ -331,6 +335,5 @@ export class ManageTasksSettingsComponent implements OnInit {
});
}
protected readonly ColumnMode = ColumnMode;
protected readonly ColumnMode = ColumnMode;
}

View File

@ -12,8 +12,8 @@ import {InviteUserComponent} from '../invite-user/invite-user.component';
import {EditUserComponent} from '../edit-user/edit-user.component';
import {Router} from '@angular/router';
import {TagBadgeComponent} from '../../shared/tag-badge/tag-badge.component';
import {AsyncPipe, NgClass, NgOptimizedImage, TitleCasePipe} from '@angular/common';
import {size, TranslocoModule, TranslocoService} from "@jsverse/transloco";
import {AsyncPipe, NgClass, TitleCasePipe} from '@angular/common';
import {TranslocoModule, TranslocoService} from "@jsverse/transloco";
import {DefaultDatePipe} from "../../_pipes/default-date.pipe";
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
@ -124,7 +124,7 @@ export class ManageUsersComponent implements OnInit {
setTimeout(() => {
this.loadMembers();
this.toastr.success(this.translocoService.translate('toasts.user-deleted', {user: member.username}));
}, 30); // SetTimeout because I've noticed this can run super fast and not give enough time for data to flush
}, 30); // SetTimeout because I've noticed this can run superfast and not give enough time for data to flush
});
}
}

View File

@ -71,7 +71,7 @@ export class SettingsService {
}
isEmailSetup() {
return this.http.get<string>(this.baseUrl + 'server/is-email-setup', TextResonse).pipe(map(d => d == "true"));
return this.http.get<string>(this.baseUrl + 'settings/is-email-setup', TextResonse).pipe(map(d => d == "true"));
}
getTaskFrequencies() {

View File

@ -0,0 +1,52 @@
<div class="main-container container-fluid">
<ng-container *transloco="let t; prefix:'all-annotations'" >
<app-side-nav-companion-bar [hasFilter]="true" (filterOpen)="filterOpen.emit($event)">
<h2 title>
<span>{{t('title')}}</span>
</h2>
<h6 subtitle>{{t('annotations-count', {num: pagination().totalItems | number})}} </h6>
</app-side-nav-companion-bar>
<app-card-detail-layout
[isLoading]="isLoading()"
[items]="annotations()"
[pagination]="pagination()"
[trackByIdentity]="trackByIdentity"
[jumpBarKeys]="[]"
[filteringDisabled]="true"
[refresh]="refresh"
[filterSettings]="filterSettings"
[filterOpen]="filterOpen"
(applyFilter)="updateFilter($event)"
>
<ng-template #topBar>
<app-bulk-operations [actionCallback]="handleAction" [shouldRenderFunc]="shouldRender" />
</ng-template>
<ng-template #extraButtons>
<button class="btn btn-secondary ms-2" (click)="exportFilter()">
{{t('export')}}
</button>
</ng-template>
<ng-template #cardItem let-item let-position="idx">
<app-annotation-card
[annotation]="item"
[allowEdit]="false"
[isInReader]="false"
[showInReaderLink]="true"
[showPageLink]="false"
[openInIncognitoMode]="true"
[showSelectionBox]="true"
[selected]="bulkSelectionService.isCardSelected('annotations', position)"
(selection)="bulkSelectionService.handleCardSelection('annotations', position, annotations().length, $event)"
/>
</ng-template>
</app-card-detail-layout>
</ng-container>
</div>

View File

@ -0,0 +1,13 @@
::ng-deep #card-detail-layout-items-container {
grid-template-columns: repeat(auto-fill, 25rem) !important;
justify-content: space-around;
.card-detail-layout-item {
background-color: transparent !important;
max-width: 25rem;
width: 25rem;
}
}

View File

@ -0,0 +1,197 @@
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
effect,
EventEmitter,
inject,
OnInit,
signal
} from '@angular/core';
import {
SideNavCompanionBarComponent
} from "../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component";
import {TranslocoDirective} from "@jsverse/transloco";
import {ActivatedRoute, Router} from "@angular/router";
import {AnnotationService} from "../_services/annotation.service";
import {FilterUtilitiesService} from "../shared/_services/filter-utilities.service";
import {Annotation} from "../book-reader/_models/annotations/annotation";
import {Pagination} from "../_models/pagination";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {map, tap} from "rxjs/operators";
import {AnnotationsFilterSettings} from "../metadata-filter/filter-settings";
import {
AnnotationsFilter,
AnnotationsFilterField,
AnnotationsSortField
} from "../_models/metadata/v2/annotations-filter";
import {MetadataService} from "../_services/metadata.service";
import {FilterStatement} from "../_models/metadata/v2/filter-statement";
import {FilterEvent} from "../_models/metadata/series-filter";
import {DecimalPipe} from "@angular/common";
import {CardDetailLayoutComponent} from "../cards/card-detail-layout/card-detail-layout.component";
import {
AnnotationCardComponent
} from "../book-reader/_components/_annotations/annotation-card/annotation-card.component";
import {Action, ActionFactoryService, ActionItem} from "../_services/action-factory.service";
import {BulkOperationsComponent} from "../cards/bulk-operations/bulk-operations.component";
import {BulkSelectionService} from "../cards/bulk-selection.service";
import {User} from "../_models/user";
@Component({
selector: 'app-all-annotations',
imports: [
SideNavCompanionBarComponent,
TranslocoDirective,
DecimalPipe,
CardDetailLayoutComponent,
AnnotationCardComponent,
BulkOperationsComponent
],
templateUrl: './all-annotations.component.html',
styleUrl: './all-annotations.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AllAnnotationsComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef);
private readonly router = inject(Router);
private readonly annotationsService = inject(AnnotationService);
private readonly route = inject(ActivatedRoute);
private readonly filterUtilityService = inject(FilterUtilitiesService);
private readonly metadataService = inject(MetadataService);
private readonly actionFactoryService = inject(ActionFactoryService);
public readonly bulkSelectionService = inject(BulkSelectionService);
isLoading = signal(true);
annotations = signal<Annotation[]>([]);
pagination = signal<Pagination>({
currentPage: 0,
itemsPerPage: 0,
totalItems: 0,
totalPages: 0
});
filterActive = signal(false);
filter = signal<AnnotationsFilter | undefined>(undefined);
filterSettings: AnnotationsFilterSettings = new AnnotationsFilterSettings();
trackByIdentity = (idx: number, item: Annotation) => `${item.id}`;
refresh: EventEmitter<void> = new EventEmitter();
filterOpen: EventEmitter<boolean> = new EventEmitter();
actions: ActionItem<Annotation>[] = [];
constructor() {
effect(() => {
const event = this.annotationsService.events();
if (!event) return;
switch (event.type) {
case "delete":
this.annotations.update(x => x.filter(a => a.id !== event.annotation.id));
}
});
effect(() => {
this.annotations();
this.bulkSelectionService.deselectAll();
});
}
ngOnInit() {
this.actions = this.actionFactoryService.getAnnotationActions(this.actionFactoryService.dummyCallback);
this.route.data.pipe(
takeUntilDestroyed(this.destroyRef),
map(data => data['filter'] as AnnotationsFilter | null | undefined),
tap(filter => {
if (!filter) {
filter = this.metadataService.createDefaultFilterDto('annotation');
filter.statements.push(this.metadataService.createDefaultFilterStatement('annotation') as FilterStatement<AnnotationsFilterField>);
}
this.filter.set(filter);
this.filterSettings.presetsV2 = this.filter();
this.loadData(this.filter())
}),
).subscribe();
}
handleAction = async (action: ActionItem<Annotation>, entity: Annotation) => {
const selectedIndices = this.bulkSelectionService.getSelectedCardsForSource('annotations');
const selectedAnnotations = this.annotations().filter((_, idx) => selectedIndices.includes(idx+''));
const ids = selectedAnnotations.map(a => a.id);
switch (action.action) {
case Action.Delete:
this.annotationsService.bulkDelete(ids).pipe(
tap(() => {
this.annotations.update(x => x.filter(a => !ids.includes(a.id)));
this.pagination.update(x => {
const count = this.annotations().length;
return {
...x,
totalItems: count,
totalPages: Math.ceil(count / x.itemsPerPage),
}
})
}),
).subscribe();
break
case Action.Export:
this.annotationsService.exportAnnotations(ids).subscribe();
break
}
}
exportFilter() {
const filter = this.filter();
if (!filter) return;
this.annotationsService.exportFilter(filter).subscribe();
}
shouldRender = (action: ActionItem<Annotation>, entity: Annotation, user: User) => {
switch (action.action) {
case Action.Delete:
const selectedIndices = this.bulkSelectionService.getSelectedCardsForSource('annotations');
const selectedAnnotations = this.annotations().filter((_, idx) => selectedIndices.includes(idx+''));
return selectedAnnotations.find(a => a.ownerUsername !== user.username) === undefined;
}
return true;
}
private loadData(filter?: AnnotationsFilter) {
if (!filter) {
filter = this.metadataService.createDefaultFilterDto('annotation');
filter.statements.push(this.metadataService.createDefaultFilterStatement('annotation') as FilterStatement<AnnotationsFilterField>);
}
this.annotationsService.getAllAnnotationsFiltered(filter).pipe(
tap(a => {
this.annotations.set(a.result);
this.pagination.set(a.pagination);
}),
tap(() => this.isLoading.set(false)),
).subscribe();
}
updateFilter(data: FilterEvent<AnnotationsFilterField, AnnotationsSortField>) {
if (!data.filterV2) {
return;
}
if (!data.isFirst) {
this.filterUtilityService.updateUrlFromFilter(data.filterV2).pipe(
takeUntilDestroyed(this.destroyRef),
tap(() => this.filter.set(data.filterV2)),
tap(() => this.loadData(this.filter()))
).subscribe();
return;
}
this.filter.set(data.filterV2);
}
}

View File

@ -8,7 +8,6 @@
<h5 subtitle>{{t('series-count', {num: pagination.totalItems | number})}}</h5>
}
</app-side-nav-companion-bar>
<app-bulk-operations [actionCallback]="bulkActionCallback" />
@if (filter) {
<app-card-detail-layout
[isLoading]="loadingSeries"
@ -19,6 +18,11 @@
[filterSettings]="filterSettings"
(applyFilter)="updateFilter($event)"
>
<ng-template #topBar>
<app-bulk-operations [actionCallback]="bulkActionCallback" />
</ng-template>
<ng-template #cardItem let-item let-position="idx">
<app-series-card [series]="item" [libraryId]="item.libraryId" (reload)="loadPage()"
(selection)="bulkSelectionService.handleCardSelection('series', position, series.length, $event)"

View File

@ -28,7 +28,7 @@
@if(showInReaderLink()) {
<a target="_blank" [routerLink]="['/library', annotation().libraryId, 'series', annotation().seriesId, 'book', annotation().chapterId]"
[queryParams]="{annotation: annotation().id, incognitoMode: false}">{{t('view-in-reader-label')}}</a>
[queryParams]="{annotation: annotation().id, incognitoMode: openInIncognitoMode()}">{{t('view-in-reader-label')}}</a>
}
@ -52,7 +52,8 @@
</button>
}
</div>
@if(annotation().containsSpoiler) {
@if(annotation().containsSpoiler) {
<div class="d-flex align-items-center">
<span class="small text-muted">
<i class="fa-solid fa-circle-exclamation me-1" aria-hidden="true"></i>
@ -60,6 +61,10 @@
</span>
</div>
}
@if (showSelectionBox()) {
<input class="form-check-input" type="checkbox" [checked]="selected()" (change)="selection.emit(this.selected())">
}
</div>
</div>

View File

@ -52,16 +52,21 @@ export class AnnotationCardComponent {
allowEdit = input<boolean>(true);
showPageLink = input<boolean>(true);
/**
* If sizes should be forced. Turned of in drawer to account for manual resize
* If sizes should be forced. Turned off in drawer to account for manual resize
*/
forceSize = input<boolean>(true);
/**
* Redirects to the reader with annotation in view
*/
showInReaderLink = input<boolean>(false);
showSelectionBox = input<boolean>(false);
openInIncognitoMode = input<boolean>(false);
isInReader = input<boolean>(true);
selected = input<boolean>(false);
@Output() delete = new EventEmitter();
@Output() navigate = new EventEmitter<Annotation>();
@Output() selection = new EventEmitter<boolean>();
titleColor: Signal<string>;
hasClicked = model<boolean>(false);

View File

@ -64,6 +64,7 @@
@if (an.ownerUsername === accountService.currentUserSignal()?.username) {
<button class="btn btn-primary" (click)="switchToEditMode()">{{t('edit')}}</button>
<button class="btn btn-danger" (click)="delete()">{{t('delete')}}</button>
}
}

View File

@ -11,11 +11,11 @@ import {
ViewChild,
ViewContainerRef
} from '@angular/core';
import {NgbActiveOffcanvas} from "@ng-bootstrap/ng-bootstrap";
import {NgbActiveModal, NgbActiveOffcanvas, NgbOffcanvas} from "@ng-bootstrap/ng-bootstrap";
import {AnnotationService} from "../../../../_services/annotation.service";
import {FormControl, FormGroup, NonNullableFormBuilder, ReactiveFormsModule} from "@angular/forms";
import {Annotation} from "../../../_models/annotations/annotation";
import {TranslocoDirective} from "@jsverse/transloco";
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {debounceTime, switchMap} from "rxjs/operators";
import {of} from "rxjs";
@ -32,7 +32,11 @@ import {QuillTheme, QuillWrapperComponent} from "../../quill-wrapper/quill-wrapp
import {ContentChange, QuillViewComponent} from "ngx-quill";
import {UtcToLocaleDatePipe} from "../../../../_pipes/utc-to-locale-date.pipe";
import {AccountService} from "../../../../_services/account.service";
import {OffCanvasResizeComponent, ResizeMode} from "../../../../shared/_components/off-canvas-resize/off-canvas-resize.component";
import {
OffCanvasResizeComponent,
ResizeMode
} from "../../../../shared/_components/off-canvas-resize/off-canvas-resize.component";
import {ConfirmService} from "../../../../shared/confirm.service";
export enum AnnotationMode {
View = 0,
@ -74,6 +78,8 @@ export class ViewEditAnnotationDrawerComponent implements OnInit {
private readonly fb = inject(NonNullableFormBuilder);
protected readonly utilityService = inject(UtilityService);
protected readonly accountService = inject(AccountService);
private readonly confirmService = inject(ConfirmService);
private readonly offcanvasService = inject(NgbOffcanvas);
@ViewChild('renderTarget', {read: ViewContainerRef}) renderTarget!: ViewContainerRef;
@ -92,6 +98,7 @@ export class ViewEditAnnotationDrawerComponent implements OnInit {
selectedSlotIndex: FormControl<number>,
}>;
annotationNote: object = {};
annotationHtml: string = '';
constructor() {
this.titleColor = computed(() => {
@ -214,6 +221,7 @@ export class ViewEditAnnotationDrawerComponent implements OnInit {
updatedAnnotation.containsSpoiler = this.formGroup.get('hasSpoiler')!.value;
updatedAnnotation.comment = JSON.stringify(this.annotationNote);
updatedAnnotation.commentHtml = this.annotationHtml;
return this.annotationService.updateAnnotation(updatedAnnotation);
}),
@ -238,6 +246,7 @@ export class ViewEditAnnotationDrawerComponent implements OnInit {
highlightAnnotation.containsSpoiler = this.formGroup.get('hasSpoiler')!.value;
highlightAnnotation.comment = JSON.stringify(this.annotationNote);
highlightAnnotation.commentHtml = this.annotationHtml;
// For create annotation, we have to have this hack
highlightAnnotation.createdUtc = '0001-01-01T00:00:00Z';
highlightAnnotation.lastModifiedUtc = '0001-01-01T00:00:00Z'
@ -273,8 +282,9 @@ export class ViewEditAnnotationDrawerComponent implements OnInit {
this.activeOffcanvas.close();
}
updateContent(event: ContentChange) {
this.annotationNote = event.content;
updateContent(event: {raw: ContentChange, html?: string}) {
this.annotationNote = event.raw.content;
this.annotationHtml = event.html ?? '';
}
private initHighlights() {
@ -346,4 +356,15 @@ export class ViewEditAnnotationDrawerComponent implements OnInit {
protected readonly QuillTheme = QuillTheme;
protected readonly ResizeMode = ResizeMode;
protected readonly window = window;
async delete() {
const annotation = this.annotation();
if (!annotation) return;
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-annotation'))) return;
this.annotationService.deleteAnnotation(annotation).subscribe(_ => {
this.offcanvasService.dismiss();
});
}
}

Some files were not shown because too many files have changed in this diff Show More