mirror of
https://github.com/Kareadita/Kavita.git
synced 2026-01-03 10:40:22 -05:00
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:
parent
becb3d8c3b
commit
5290fd8959
@ -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" />
|
||||
|
||||
@ -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)
|
||||
{
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
30
API/DTOs/Annotations/FullAnnotationDto.cs
Normal file
30
API/DTOs/Annotations/FullAnnotationDto.cs
Normal 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; }
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
6
API/DTOs/OPDS/Requests/IOpdsPagination.cs
Normal file
6
API/DTOs/OPDS/Requests/IOpdsPagination.cs
Normal file
@ -0,0 +1,6 @@
|
||||
namespace API.DTOs.OPDS.Requests;
|
||||
|
||||
public interface IOpdsPagination
|
||||
{
|
||||
public int PageNumber { get; init; }
|
||||
}
|
||||
9
API/DTOs/OPDS/Requests/IOpdsRequest.cs
Normal file
9
API/DTOs/OPDS/Requests/IOpdsRequest.cs
Normal 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; }
|
||||
}
|
||||
10
API/DTOs/OPDS/Requests/OpdsCatalogeRequest.cs
Normal file
10
API/DTOs/OPDS/Requests/OpdsCatalogeRequest.cs
Normal 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; }
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
10
API/DTOs/OPDS/Requests/OpdsSearchRequest.cs
Normal file
10
API/DTOs/OPDS/Requests/OpdsSearchRequest.cs
Normal 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; }
|
||||
}
|
||||
14
API/DTOs/OPDS/Requests/OpdsSmartFilterCatalogueRequest.cs
Normal file
14
API/DTOs/OPDS/Requests/OpdsSmartFilterCatalogueRequest.cs
Normal 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; }
|
||||
}
|
||||
12
API/DTOs/OPDS/Requests/OpdsSmartFilterRequest.cs
Normal file
12
API/DTOs/OPDS/Requests/OpdsSmartFilterRequest.cs
Normal 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;
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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; } = [];
|
||||
|
||||
@ -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
|
||||
|
||||
3908
API/Data/Migrations/20250924142016_AddAnnotationsHtmlContent.Designer.cs
generated
Normal file
3908
API/Data/Migrations/20250924142016_AddAnnotationsHtmlContent.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
3905
API/Data/Migrations/20250928181727_RemoveEpubPageCalc.Designer.cs
generated
Normal file
3905
API/Data/Migrations/20250928181727_RemoveEpubPageCalc.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
API/Data/Migrations/20250928181727_RemoveEpubPageCalc.cs
Normal file
29
API/Data/Migrations/20250928181727_RemoveEpubPageCalc.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 =>
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
{
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
20
API/Exceptions/OpdsException.cs
Normal file
20
API/Exceptions/OpdsException.cs
Normal 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) { }
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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)
|
||||
{
|
||||
|
||||
117
API/Extensions/QueryExtensions/Filtering/AnnotationFilter.cs
Normal file
117
API/Extensions/QueryExtensions/Filtering/AnnotationFilter.cs
Normal 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),
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@ -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"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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")
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@ -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")
|
||||
};
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
64
API/Middleware/OpdsActionFilterAttribute.cs
Normal file
64
API/Middleware/OpdsActionFilterAttribute.cs
Normal 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();
|
||||
}
|
||||
|
||||
}
|
||||
46
API/Middleware/OpdsActiveUserMiddlewareAttribute.cs
Normal file
46
API/Middleware/OpdsActiveUserMiddlewareAttribute.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
278
API/Services/AnnotationService.cs
Normal file
278
API/Services/AnnotationService.cs
Normal 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(" ", " ");
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogError(exception, "Invalid html, cannot parse plain text");
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
1289
API/Services/OpdsService.cs
Normal file
File diff suppressed because it is too large
Load Diff
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
{
|
||||
|
||||
@ -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
1223
UI/Web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
||||
28
UI/Web/src/app/_models/metadata/v2/annotations-filter.ts
Normal file
28
UI/Web/src/app/_models/metadata/v2/annotations-filter.ts
Normal 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>;
|
||||
@ -35,7 +35,8 @@ export enum FilterField
|
||||
Imprint = 29,
|
||||
Team = 30,
|
||||
Location = 31,
|
||||
ReadLast = 32
|
||||
ReadLast = 32,
|
||||
FileSize = 33,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -1,6 +0,0 @@
|
||||
export enum EpubPageCalculationMethod {
|
||||
Default = 0,
|
||||
Calculation1 = 1
|
||||
}
|
||||
|
||||
export const allCalcMethods = [EpubPageCalculationMethod.Default, EpubPageCalculationMethod.Calculation1];
|
||||
@ -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;
|
||||
|
||||
@ -70,6 +70,7 @@ export class BrowseTitlePipe implements PipeTransform {
|
||||
case FilterField.ReadLast:
|
||||
case FilterField.Summary:
|
||||
case FilterField.SeriesName:
|
||||
case FilterField.FileSize:
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
|
||||
@ -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');
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -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}`);
|
||||
}
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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'
|
||||
}
|
||||
];
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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));
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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/',
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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>
|
||||
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
197
UI/Web/src/app/all-annotations/all-annotations.component.ts
Normal file
197
UI/Web/src/app/all-annotations/all-annotations.component.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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)"
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user