mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-12-27 15:20:19 -05:00
OPDS Enhancements, Epub fixes, and a lot more (#4035)
Co-authored-by: Amelia <77553571+Fesaa@users.noreply.github.com> Co-authored-by: Robbie Davis <robbie@therobbiedavis.com> Co-authored-by: Fabian Pammer <fpammer@mantro.net> Co-authored-by: Vinícius Licz <vinilicz@gmail.com>
This commit is contained in:
parent
9891df898f
commit
26ff71f42b
@ -1,10 +1,12 @@
|
||||
using System.IO;
|
||||
using System.IO.Abstractions;
|
||||
using System.Threading.Tasks;
|
||||
using API.Entities.Enums;
|
||||
using API.Services;
|
||||
using API.Services.Tasks.Scanner.Parser;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using VersOne.Epub;
|
||||
using Xunit;
|
||||
|
||||
namespace API.Tests.Services;
|
||||
@ -142,4 +144,19 @@ public class BookServiceTests
|
||||
Assert.Equal(parserInfo.Title, comicInfo.Title);
|
||||
Assert.Equal(parserInfo.Series, comicInfo.Title);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that the ./ rewrite hack works as expected
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ShouldBeAbleToLookUpImage()
|
||||
{
|
||||
var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/BookService");
|
||||
var filePath = Path.Join(testDirectory, "Relative Key Test File.epub");
|
||||
|
||||
var result = await _bookService.GetResourceAsync(filePath, "./images/titlepage800.png");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("image/png", result.ContentType);
|
||||
}
|
||||
}
|
||||
|
||||
@ -633,7 +633,7 @@ public class CleanupServiceTests(ITestOutputHelper outputHelper): AbstractDbTest
|
||||
await readerService.MarkChaptersAsRead(user, s.Id, new List<Chapter>() {c});
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
var chapter = await unitOfWork.ChapterRepository.GetChapterDtoAsync(c.Id);
|
||||
var chapter = await unitOfWork.ChapterRepository.GetChapterDtoAsync(c.Id, 1);
|
||||
await unitOfWork.ChapterRepository.AddChapterModifiers(user.Id, chapter);
|
||||
|
||||
Assert.NotNull(chapter);
|
||||
@ -644,7 +644,7 @@ public class CleanupServiceTests(ITestOutputHelper outputHelper): AbstractDbTest
|
||||
unitOfWork.ChapterRepository.Update(c);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
chapter = await unitOfWork.ChapterRepository.GetChapterDtoAsync(c.Id);
|
||||
chapter = await unitOfWork.ChapterRepository.GetChapterDtoAsync(c.Id, 1);
|
||||
await unitOfWork.ChapterRepository.AddChapterModifiers(user.Id, chapter);
|
||||
Assert.NotNull(chapter);
|
||||
Assert.Equal(2, chapter.PagesRead);
|
||||
@ -655,7 +655,7 @@ public class CleanupServiceTests(ITestOutputHelper outputHelper): AbstractDbTest
|
||||
new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), new MockFileSystem()));
|
||||
|
||||
await cleanupService.EnsureChapterProgressIsCapped();
|
||||
chapter = await unitOfWork.ChapterRepository.GetChapterDtoAsync(c.Id);
|
||||
chapter = await unitOfWork.ChapterRepository.GetChapterDtoAsync(c.Id, 1);
|
||||
await unitOfWork.ChapterRepository.AddChapterModifiers(user.Id, chapter);
|
||||
|
||||
Assert.NotNull(chapter);
|
||||
|
||||
@ -33,7 +33,7 @@ public class SiteThemeServiceTest(ITestOutputHelper outputHelper): AbstractDbTes
|
||||
var filesystem = CreateFileSystem();
|
||||
filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123"));
|
||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
|
||||
var siteThemeService = new ThemeService(ds, unitOfWork, _messageHub, Substitute.For<IFileService>(),
|
||||
var siteThemeService = new ThemeService(ds, unitOfWork, _messageHub,
|
||||
Substitute.For<ILogger<ThemeService>>(), Substitute.For<IMemoryCache>());
|
||||
|
||||
context.SiteTheme.Add(new SiteTheme()
|
||||
@ -62,7 +62,7 @@ public class SiteThemeServiceTest(ITestOutputHelper outputHelper): AbstractDbTes
|
||||
var filesystem = CreateFileSystem();
|
||||
filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123"));
|
||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
|
||||
var siteThemeService = new ThemeService(ds, unitOfWork, _messageHub, Substitute.For<IFileService>(),
|
||||
var siteThemeService = new ThemeService(ds, unitOfWork, _messageHub,
|
||||
Substitute.For<ILogger<ThemeService>>(), Substitute.For<IMemoryCache>());
|
||||
|
||||
context.SiteTheme.Add(new SiteTheme()
|
||||
@ -91,7 +91,7 @@ public class SiteThemeServiceTest(ITestOutputHelper outputHelper): AbstractDbTes
|
||||
var filesystem = CreateFileSystem();
|
||||
filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123"));
|
||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
|
||||
var siteThemeService = new ThemeService(ds, unitOfWork, _messageHub, Substitute.For<IFileService>(),
|
||||
var siteThemeService = new ThemeService(ds, unitOfWork, _messageHub,
|
||||
Substitute.For<ILogger<ThemeService>>(), Substitute.For<IMemoryCache>());
|
||||
|
||||
context.SiteTheme.Add(new SiteTheme()
|
||||
|
||||
Binary file not shown.
@ -1,6 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
@ -17,7 +16,6 @@ using API.Errors;
|
||||
using API.Extensions;
|
||||
using API.Helpers.Builders;
|
||||
using API.Services;
|
||||
using API.Services.Plus;
|
||||
using API.SignalR;
|
||||
using AutoMapper;
|
||||
using Hangfire;
|
||||
@ -29,7 +27,6 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SharpCompress;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
@ -53,7 +50,6 @@ public class AccountController : BaseApiController
|
||||
private readonly IEmailService _emailService;
|
||||
private readonly IEventHub _eventHub;
|
||||
private readonly ILocalizationService _localizationService;
|
||||
private readonly IOidcService _oidcService;
|
||||
|
||||
/// <inheritdoc />
|
||||
public AccountController(UserManager<AppUser> userManager,
|
||||
@ -62,8 +58,7 @@ public class AccountController : BaseApiController
|
||||
ILogger<AccountController> logger,
|
||||
IMapper mapper, IAccountService accountService,
|
||||
IEmailService emailService, IEventHub eventHub,
|
||||
ILocalizationService localizationService,
|
||||
IOidcService oidcService)
|
||||
ILocalizationService localizationService)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_signInManager = signInManager;
|
||||
@ -75,7 +70,6 @@ public class AccountController : BaseApiController
|
||||
_emailService = emailService;
|
||||
_eventHub = eventHub;
|
||||
_localizationService = localizationService;
|
||||
_oidcService = oidcService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -197,11 +191,7 @@ public class AccountController : BaseApiController
|
||||
var result = await _userManager.CreateAsync(user, registerDto.Password);
|
||||
if (!result.Succeeded) return BadRequest(result.Errors);
|
||||
|
||||
// Assign default streams
|
||||
_accountService.AddDefaultStreamsToUser(user);
|
||||
|
||||
// Assign default reading profile
|
||||
await _accountService.AddDefaultReadingProfileToUser(user);
|
||||
await _accountService.SeedUser(user);
|
||||
|
||||
var token = await _userManager.GenerateEmailConfirmationTokenAsync(user);
|
||||
if (string.IsNullOrEmpty(token)) return BadRequest(await _localizationService.Get("en", "confirm-token-gen"));
|
||||
@ -534,6 +524,11 @@ public class AccountController : BaseApiController
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Change the Age Rating restriction for the user
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("update/age-restriction")]
|
||||
public async Task<ActionResult> UpdateAgeRestriction(UpdateAgeRestrictionDto dto)
|
||||
{
|
||||
@ -745,11 +740,7 @@ public class AccountController : BaseApiController
|
||||
var result = await _userManager.CreateAsync(user, AccountService.DefaultPassword);
|
||||
if (!result.Succeeded) return BadRequest(result.Errors);
|
||||
|
||||
// Assign default streams
|
||||
_accountService.AddDefaultStreamsToUser(user);
|
||||
|
||||
// Assign default reading profile
|
||||
await _accountService.AddDefaultReadingProfileToUser(user);
|
||||
await _accountService.SeedUser(user);
|
||||
|
||||
// Assign Roles
|
||||
var roles = dto.Roles;
|
||||
|
||||
@ -45,6 +45,17 @@ public class AnnotationController : BaseApiController
|
||||
return Ok(await _unitOfWork.UserRepository.GetAnnotations(User.GetUserId(), chapterId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all annotations by Series
|
||||
/// </summary>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("all-for-series")]
|
||||
public async Task<ActionResult<AnnotationDto>> GetAnnotationsBySeries(int seriesId)
|
||||
{
|
||||
return Ok(await _unitOfWork.UserRepository.GetAnnotationDtosBySeries(User.GetUserId(), seriesId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the Annotation by Id. User must have access to annotation.
|
||||
/// </summary>
|
||||
|
||||
@ -111,19 +111,16 @@ public class BookController : BaseApiController
|
||||
public async Task<ActionResult> GetBookPageResources(int chapterId, [FromQuery] string file)
|
||||
{
|
||||
if (chapterId <= 0) return BadRequest(await _localizationService.Get("en", "chapter-doesnt-exist"));
|
||||
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId);
|
||||
|
||||
var chapter = await _cacheService.Ensure(chapterId);
|
||||
if (chapter == null) return BadRequest(await _localizationService.Get("en", "chapter-doesnt-exist"));
|
||||
|
||||
using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, BookService.LenientBookReaderOptions);
|
||||
var key = BookService.CoalesceKeyForAnyFile(book, file);
|
||||
var cachedFilePath = Path.Join(_cacheService.GetCachePath(chapterId), Path.GetFileName(chapter.Files.ElementAt(0).FilePath));
|
||||
var result = await _bookService.GetResourceAsync(cachedFilePath, file);
|
||||
|
||||
if (!book.Content.AllFiles.ContainsLocalFileRefWithKey(key)) return BadRequest(await _localizationService.Get("en", "file-missing"));
|
||||
if (!result.IsSuccess) return BadRequest(await _localizationService.Translate(User.GetUserId(), result.ErrorMessage));
|
||||
|
||||
var bookFile = book.Content.AllFiles.GetLocalFileRefByKey(key);
|
||||
var content = await bookFile.ReadContentAsBytesAsync();
|
||||
|
||||
var contentType = BookService.GetContentType(bookFile.ContentType);
|
||||
return File(content, contentType, $"{chapterId}-{file}");
|
||||
return File(result.Content, result.ContentType, $"{chapterId}-{file}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@ -51,9 +51,7 @@ public class ChapterController : BaseApiController
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<ChapterDto>> GetChapter(int chapterId)
|
||||
{
|
||||
var chapter =
|
||||
await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId,
|
||||
ChapterIncludes.People | ChapterIncludes.Files);
|
||||
var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId, User.GetUserId());
|
||||
|
||||
return Ok(chapter);
|
||||
}
|
||||
|
||||
@ -50,7 +50,7 @@ public class ColorScapeController : BaseApiController
|
||||
[HttpGet("chapter")]
|
||||
public async Task<ActionResult<ColorScapeDto>> GetColorScapeForChapter(int id)
|
||||
{
|
||||
var entity = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(id);
|
||||
var entity = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(id, User.GetUserId());
|
||||
return GetColorSpaceDto(entity);
|
||||
}
|
||||
|
||||
|
||||
@ -20,8 +20,13 @@ public class FallbackController : Controller
|
||||
_taskScheduler = taskScheduler;
|
||||
}
|
||||
|
||||
public PhysicalFileResult Index()
|
||||
public IActionResult Index()
|
||||
{
|
||||
if (HttpContext.Request.Path.StartsWithSegments("/api"))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return PhysicalFile(Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "index.html"), "text/HTML");
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,6 +18,7 @@ using API.DTOs.Filtering.v2;
|
||||
using API.DTOs.OPDS;
|
||||
using API.DTOs.Person;
|
||||
using API.DTOs.Progress;
|
||||
using API.DTOs.ReadingLists;
|
||||
using API.DTOs.Search;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
@ -105,33 +106,32 @@ public class OpdsController : BaseApiController
|
||||
|
||||
private readonly XmlSerializer _xmlSerializer;
|
||||
private readonly XmlSerializer _xmlOpenSearchSerializer;
|
||||
private readonly FilterDto _filterDto = new FilterDto()
|
||||
private readonly FilterDto _filterDto = new()
|
||||
{
|
||||
Formats = new List<MangaFormat>(),
|
||||
Character = new List<int>(),
|
||||
Colorist = new List<int>(),
|
||||
Editor = new List<int>(),
|
||||
Genres = new List<int>(),
|
||||
Inker = new List<int>(),
|
||||
Languages = new List<string>(),
|
||||
Letterer = new List<int>(),
|
||||
Penciller = new List<int>(),
|
||||
Libraries = new List<int>(),
|
||||
Publisher = new List<int>(),
|
||||
Formats = [],
|
||||
Character = [],
|
||||
Colorist = [],
|
||||
Editor = [],
|
||||
Genres = [],
|
||||
Inker = [],
|
||||
Languages = [],
|
||||
Letterer = [],
|
||||
Penciller = [],
|
||||
Libraries = [],
|
||||
Publisher = [],
|
||||
Rating = 0,
|
||||
Tags = new List<int>(),
|
||||
Translators = new List<int>(),
|
||||
Writers = new List<int>(),
|
||||
AgeRating = new List<AgeRating>(),
|
||||
CollectionTags = new List<int>(),
|
||||
CoverArtist = new List<int>(),
|
||||
Tags = [],
|
||||
Translators = [],
|
||||
Writers = [],
|
||||
AgeRating = [],
|
||||
CollectionTags = [],
|
||||
CoverArtist = [],
|
||||
ReadStatus = new ReadStatus(),
|
||||
SortOptions = null,
|
||||
PublicationStatus = new List<PublicationStatus>()
|
||||
PublicationStatus = []
|
||||
};
|
||||
|
||||
private readonly FilterV2Dto _filterV2Dto = new FilterV2Dto();
|
||||
private readonly ChapterSortComparerDefaultLast _chapterSortComparerDefaultLast = ChapterSortComparerDefaultLast.Default;
|
||||
private readonly FilterV2Dto _filterV2Dto = new();
|
||||
private const int PageSize = 20;
|
||||
public const string UserId = nameof(UserId);
|
||||
|
||||
@ -187,10 +187,10 @@ public class OpdsController : BaseApiController
|
||||
{
|
||||
Text = await _localizationService.Translate(userId, "browse-on-deck")
|
||||
},
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
Links =
|
||||
[
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/on-deck"),
|
||||
}
|
||||
]
|
||||
});
|
||||
break;
|
||||
case DashboardStreamType.NewlyAdded:
|
||||
@ -202,10 +202,10 @@ public class OpdsController : BaseApiController
|
||||
{
|
||||
Text = await _localizationService.Translate(userId, "browse-recently-added")
|
||||
},
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
Links =
|
||||
[
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/recently-added"),
|
||||
}
|
||||
]
|
||||
});
|
||||
break;
|
||||
case DashboardStreamType.RecentlyUpdated:
|
||||
@ -217,10 +217,10 @@ public class OpdsController : BaseApiController
|
||||
{
|
||||
Text = await _localizationService.Translate(userId, "browse-recently-updated")
|
||||
},
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
Links =
|
||||
[
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/recently-updated"),
|
||||
}
|
||||
]
|
||||
});
|
||||
break;
|
||||
case DashboardStreamType.MoreInGenre:
|
||||
@ -235,10 +235,10 @@ public class OpdsController : BaseApiController
|
||||
{
|
||||
Text = await _localizationService.Translate(userId, "browse-more-in-genre", randomGenre.Title)
|
||||
},
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
Links =
|
||||
[
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/more-in-genre?genreId={randomGenre.Id}"),
|
||||
}
|
||||
]
|
||||
});
|
||||
break;
|
||||
case DashboardStreamType.SmartFilter:
|
||||
@ -269,10 +269,10 @@ public class OpdsController : BaseApiController
|
||||
{
|
||||
Text = await _localizationService.Translate(userId, "browse-reading-lists")
|
||||
},
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
Links =
|
||||
[
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/reading-list"),
|
||||
}
|
||||
]
|
||||
});
|
||||
feed.Entries.Add(new FeedEntry()
|
||||
{
|
||||
@ -282,10 +282,10 @@ public class OpdsController : BaseApiController
|
||||
{
|
||||
Text = await _localizationService.Translate(userId, "browse-want-to-read")
|
||||
},
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
Links =
|
||||
[
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/want-to-read"),
|
||||
}
|
||||
]
|
||||
});
|
||||
feed.Entries.Add(new FeedEntry()
|
||||
{
|
||||
@ -295,10 +295,10 @@ public class OpdsController : BaseApiController
|
||||
{
|
||||
Text = await _localizationService.Translate(userId, "browse-libraries")
|
||||
},
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
Links =
|
||||
[
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/libraries"),
|
||||
}
|
||||
]
|
||||
});
|
||||
feed.Entries.Add(new FeedEntry()
|
||||
{
|
||||
@ -308,10 +308,10 @@ public class OpdsController : BaseApiController
|
||||
{
|
||||
Text = await _localizationService.Translate(userId, "browse-collections")
|
||||
},
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
Links =
|
||||
[
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/collections"),
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if ((_unitOfWork.AppUserSmartFilterRepository.GetAllDtosByUserId(userId)).Any())
|
||||
@ -324,30 +324,13 @@ public class OpdsController : BaseApiController
|
||||
{
|
||||
Text = await _localizationService.Translate(userId, "browse-smart-filters")
|
||||
},
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
Links =
|
||||
[
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/smart-filters"),
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// if ((await _unitOfWork.AppUserExternalSourceRepository.GetExternalSources(userId)).Any())
|
||||
// {
|
||||
// feed.Entries.Add(new FeedEntry()
|
||||
// {
|
||||
// Id = "allExternalSources",
|
||||
// Title = await _localizationService.Translate(userId, "external-sources"),
|
||||
// Content = new FeedEntryContent()
|
||||
// {
|
||||
// Text = await _localizationService.Translate(userId, "browse-external-sources")
|
||||
// },
|
||||
// Links = new List<FeedLink>()
|
||||
// {
|
||||
// CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/external-sources"),
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
|
||||
return CreateXmlResult(SerializeXml(feed));
|
||||
}
|
||||
|
||||
@ -396,12 +379,12 @@ public class OpdsController : BaseApiController
|
||||
|
||||
[HttpGet("{apiKey}/smart-filters")]
|
||||
[Produces("application/xml")]
|
||||
public async Task<IActionResult> GetSmartFilters(string apiKey)
|
||||
public async Task<IActionResult> GetSmartFilters(string apiKey, [FromQuery] int pageNumber = 0)
|
||||
{
|
||||
var userId = GetUserIdFromContext();
|
||||
var (_, prefix) = await GetPrefix();
|
||||
|
||||
var filters = _unitOfWork.AppUserSmartFilterRepository.GetAllDtosByUserId(userId);
|
||||
var filters = await _unitOfWork.AppUserSmartFilterRepository.GetPagedDtosByUserIdAsync(userId, GetUserParams(pageNumber));
|
||||
var feed = CreateFeed(await _localizationService.Translate(userId, "smartFilters"), $"{apiKey}/smart-filters", apiKey, prefix);
|
||||
SetFeedId(feed, "smartFilters");
|
||||
|
||||
@ -419,40 +402,10 @@ public class OpdsController : BaseApiController
|
||||
});
|
||||
}
|
||||
|
||||
AddPagination(feed, filters, $"{prefix}{apiKey}/smart-filters");
|
||||
return CreateXmlResult(SerializeXml(feed));
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}/external-sources")]
|
||||
[Produces("application/xml")]
|
||||
public async Task<IActionResult> GetExternalSources(string apiKey)
|
||||
{
|
||||
// NOTE: This doesn't seem possible in OPDS v2.1 due to the resulting stream using relative links and most apps resolve against source url. Even using full paths doesn't work
|
||||
var userId = GetUserIdFromContext();
|
||||
var (_, prefix) = await GetPrefix();
|
||||
|
||||
var externalSources = await _unitOfWork.AppUserExternalSourceRepository.GetExternalSources(userId);
|
||||
var feed = CreateFeed(await _localizationService.Translate(userId, "external-sources"), $"{apiKey}/external-sources", apiKey, prefix);
|
||||
SetFeedId(feed, "externalSources");
|
||||
foreach (var externalSource in externalSources)
|
||||
{
|
||||
var opdsUrl = $"{externalSource.Host}api/opds/{externalSource.ApiKey}";
|
||||
feed.Entries.Add(new FeedEntry()
|
||||
{
|
||||
Id = externalSource.Id.ToString(),
|
||||
Title = externalSource.Name,
|
||||
Summary = externalSource.Host,
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
CreateLink(FeedLinkRelation.Start, FeedLinkType.AtomNavigation, opdsUrl),
|
||||
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{opdsUrl}/favicon")
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return CreateXmlResult(SerializeXml(feed));
|
||||
}
|
||||
|
||||
|
||||
[HttpGet("{apiKey}/libraries")]
|
||||
[Produces("application/xml")]
|
||||
public async Task<IActionResult> GetLibraries(string apiKey)
|
||||
@ -463,7 +416,7 @@ public class OpdsController : BaseApiController
|
||||
SetFeedId(feed, "libraries");
|
||||
|
||||
// Ensure libraries follow SideNav order
|
||||
var userSideNavStreams = await _unitOfWork.UserRepository.GetSideNavStreams(userId, false);
|
||||
var userSideNavStreams = await _unitOfWork.UserRepository.GetSideNavStreams(userId);
|
||||
foreach (var library in userSideNavStreams.Where(s => s.StreamType == SideNavStreamType.Library).Select(sideNavStream => sideNavStream.Library))
|
||||
{
|
||||
feed.Entries.Add(new FeedEntry()
|
||||
@ -506,14 +459,14 @@ public class OpdsController : BaseApiController
|
||||
|
||||
[HttpGet("{apiKey}/collections")]
|
||||
[Produces("application/xml")]
|
||||
public async Task<IActionResult> GetCollections(string apiKey)
|
||||
public async Task<IActionResult> GetCollections(string apiKey, [FromQuery] int pageNumber = 0)
|
||||
{
|
||||
var userId = GetUserIdFromContext();
|
||||
|
||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
|
||||
if (user == null) return Unauthorized();
|
||||
|
||||
var tags = await _unitOfWork.CollectionTagRepository.GetCollectionDtosAsync(user.Id, true);
|
||||
var tags = await _unitOfWork.CollectionTagRepository.GetCollectionDtosPagedAsync(user.Id, GetUserParams(pageNumber), true);
|
||||
|
||||
var (baseUrl, prefix) = await GetPrefix();
|
||||
var feed = CreateFeed(await _localizationService.Translate(userId, "collections"), $"{apiKey}/collections", apiKey, prefix);
|
||||
@ -536,6 +489,7 @@ public class OpdsController : BaseApiController
|
||||
]
|
||||
}));
|
||||
|
||||
AddPagination(feed, tags, $"{prefix}{apiKey}/collections");
|
||||
return CreateXmlResult(SerializeXml(feed));
|
||||
}
|
||||
|
||||
@ -609,14 +563,6 @@ public class OpdsController : BaseApiController
|
||||
return CreateXmlResult(SerializeXml(feed));
|
||||
}
|
||||
|
||||
private static UserParams GetUserParams(int pageNumber)
|
||||
{
|
||||
return new UserParams()
|
||||
{
|
||||
PageNumber = pageNumber,
|
||||
PageSize = PageSize
|
||||
};
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}/reading-list/{readingListId}")]
|
||||
[Produces("application/xml")]
|
||||
@ -645,13 +591,22 @@ public class OpdsController : BaseApiController
|
||||
var feed = CreateFeed(readingList.Title + " " + await _localizationService.Translate(userId, "reading-list"), $"{apiKey}/reading-list/{readingListId}", apiKey, prefix);
|
||||
SetFeedId(feed, $"reading-list-{readingListId}");
|
||||
|
||||
var items = await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId);
|
||||
|
||||
var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId, GetUserParams(pageNumber))).ToList();
|
||||
|
||||
// Check if there is reading progress or not, if so, inject a "continue-reading" item
|
||||
var firstReadReadingListItem = items.FirstOrDefault(i => i.PagesRead > 0);
|
||||
if (firstReadReadingListItem != null)
|
||||
{
|
||||
await AddContinueReadingPoint(apiKey, firstReadReadingListItem, userId, feed, prefix, baseUrl);
|
||||
}
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(item.ChapterId);
|
||||
var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(item.ChapterId, userId);
|
||||
|
||||
// If there is only one file underneath, add a direct acquisition link, otherwise add a subsection
|
||||
if (chapterDto != null && chapterDto.Files.Count == 1)
|
||||
if (chapterDto is {Files.Count: 1})
|
||||
{
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(item.SeriesId, userId);
|
||||
feed.Entries.Add(await CreateChapterWithFile(userId, item.SeriesId, item.VolumeId, item.ChapterId,
|
||||
@ -668,6 +623,29 @@ public class OpdsController : BaseApiController
|
||||
return CreateXmlResult(SerializeXml(feed));
|
||||
}
|
||||
|
||||
private async Task AddContinueReadingPoint(string apiKey, int seriesId, ChapterDto chapterDto, int userId,
|
||||
Feed feed, string prefix, string baseUrl)
|
||||
{
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
|
||||
if (chapterDto is {Files.Count: 1})
|
||||
{
|
||||
feed.Entries.Add(await CreateContinueReadingFromFile(userId, seriesId, chapterDto.VolumeId, chapterDto.Id,
|
||||
chapterDto.Files.First(), series!, chapterDto, apiKey, prefix, baseUrl));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddContinueReadingPoint(string apiKey, ReadingListItemDto firstReadReadingListItem, int userId,
|
||||
Feed feed, string prefix, string baseUrl)
|
||||
{
|
||||
var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(firstReadReadingListItem.ChapterId, userId);
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(firstReadReadingListItem.SeriesId, userId);
|
||||
if (chapterDto is {Files.Count: 1})
|
||||
{
|
||||
feed.Entries.Add(await CreateContinueReadingFromFile(userId, firstReadReadingListItem.SeriesId, firstReadReadingListItem.VolumeId, firstReadReadingListItem.ChapterId,
|
||||
chapterDto.Files.First(), series!, chapterDto, apiKey, prefix, baseUrl));
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}/libraries/{libraryId}")]
|
||||
[Produces("application/xml")]
|
||||
public async Task<IActionResult> GetSeriesForLibrary(int libraryId, string apiKey, [FromQuery] int pageNumber = 0)
|
||||
@ -684,14 +662,14 @@ public class OpdsController : BaseApiController
|
||||
|
||||
var filter = new FilterV2Dto
|
||||
{
|
||||
Statements = new List<FilterStatementDto>() {
|
||||
Statements = [
|
||||
new ()
|
||||
{
|
||||
Comparison = FilterComparison.Equal,
|
||||
Field = FilterField.Libraries,
|
||||
Value = libraryId + string.Empty
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, GetUserParams(pageNumber), filter);
|
||||
@ -735,6 +713,7 @@ public class OpdsController : BaseApiController
|
||||
var userId = GetUserIdFromContext();
|
||||
var (baseUrl, prefix) = await GetPrefix();
|
||||
var genre = await _unitOfWork.GenreRepository.GetGenreById(genreId);
|
||||
if (genre == null) return BadRequest(await _localizationService.Translate(userId, "genre-doesnt-exist"));
|
||||
var seriesDtos = await _unitOfWork.SeriesRepository.GetMoreIn(userId, 0, genreId, GetUserParams(pageNumber));
|
||||
var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(seriesDtos.Select(s => s.Id));
|
||||
|
||||
@ -750,6 +729,13 @@ public class OpdsController : BaseApiController
|
||||
return CreateXmlResult(SerializeXml(feed));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns recently updated series. While pagination is avaible, total amount of pages is not due to implementation
|
||||
/// details
|
||||
/// </summary>
|
||||
/// <param name="apiKey"></param>
|
||||
/// <param name="pageNumber"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("{apiKey}/recently-updated")]
|
||||
[Produces("application/xml")]
|
||||
public async Task<IActionResult> GetRecentlyUpdated(string apiKey, [FromQuery] int pageNumber = 1)
|
||||
@ -832,7 +818,7 @@ public class OpdsController : BaseApiController
|
||||
return BadRequest(await _localizationService.Translate(userId, "query-required"));
|
||||
}
|
||||
query = query.Replace(@"%", string.Empty);
|
||||
// Get libraries user has access to
|
||||
|
||||
var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId)).ToList();
|
||||
if (libraries.Count == 0) return BadRequest(await _localizationService.Translate(userId, "libraries-restricted"));
|
||||
|
||||
@ -855,15 +841,15 @@ public class OpdsController : BaseApiController
|
||||
Id = collection.Id.ToString(),
|
||||
Title = collection.Title,
|
||||
Summary = collection.Summary,
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
Links =
|
||||
[
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation,
|
||||
$"{prefix}{apiKey}/collections/{collection.Id}"),
|
||||
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image,
|
||||
$"{baseUrl}api/image/collection-cover?collectionId={collection.Id}&apiKey={apiKey}"),
|
||||
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image,
|
||||
$"{baseUrl}api/image/collection-cover?collectionId={collection.Id}&apiKey={apiKey}")
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
@ -874,14 +860,14 @@ public class OpdsController : BaseApiController
|
||||
Id = readingListDto.Id.ToString(),
|
||||
Title = readingListDto.Title,
|
||||
Summary = readingListDto.Summary,
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
Links =
|
||||
[
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/reading-list/{readingListDto.Id}"),
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Search should allow Chapters/Files and more
|
||||
feed.Total = feed.Entries.Count;
|
||||
|
||||
return CreateXmlResult(SerializeXml(feed));
|
||||
}
|
||||
@ -926,20 +912,30 @@ public class OpdsController : BaseApiController
|
||||
SetFeedId(feed, $"series-{series.Id}");
|
||||
feed.Links.Add(CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/series-cover?seriesId={seriesId}&apiKey={apiKey}"));
|
||||
|
||||
|
||||
// Check if there is reading progress or not, if so, inject a "continue-reading" item
|
||||
var anyUserProgress = await _unitOfWork.AppUserProgressRepository.AnyUserProgressForSeriesAsync(seriesId, userId);
|
||||
if (anyUserProgress)
|
||||
{
|
||||
var chapterDto = await _readerService.GetContinuePoint(seriesId, userId);
|
||||
await AddContinueReadingPoint(apiKey, seriesId, chapterDto, userId, feed, prefix, baseUrl);
|
||||
}
|
||||
|
||||
|
||||
var chapterDict = new Dictionary<int, short>();
|
||||
var fileDict = new Dictionary<int, short>();
|
||||
|
||||
var seriesDetail = await _seriesService.GetSeriesDetail(seriesId, userId);
|
||||
foreach (var volume in seriesDetail.Volumes)
|
||||
{
|
||||
var chaptersForVolume = await _unitOfWork.ChapterRepository.GetChaptersAsync(volume.Id, ChapterIncludes.Files | ChapterIncludes.People);
|
||||
var chaptersForVolume = await _unitOfWork.ChapterRepository.GetChapterDtosAsync(volume.Id, userId);
|
||||
|
||||
foreach (var chapter in chaptersForVolume)
|
||||
foreach (var chapterDto in chaptersForVolume)
|
||||
{
|
||||
var chapterId = chapter.Id;
|
||||
var chapterId = chapterDto.Id;
|
||||
if (!chapterDict.TryAdd(chapterId, 0)) continue;
|
||||
|
||||
var chapterDto = _mapper.Map<ChapterDto>(chapter);
|
||||
foreach (var mangaFile in chapter.Files)
|
||||
foreach (var mangaFile in chapterDto.Files)
|
||||
{
|
||||
// If a chapter has multiple files that are within one chapter, this dict prevents duplicate key exception
|
||||
if (!fileDict.TryAdd(mangaFile.Id, 0)) continue;
|
||||
@ -999,25 +995,31 @@ public class OpdsController : BaseApiController
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId);
|
||||
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId, VolumeIncludes.Chapters);
|
||||
if (volume == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var feed = CreateFeed(series.Name + " - Volume " + volume!.Name + $" - {_seriesService.FormatChapterName(userId, libraryType)}s ",
|
||||
var feed = CreateFeed($"{series.Name} - Volume {volume!.Name}",
|
||||
$"{apiKey}/series/{seriesId}/volume/{volumeId}", apiKey, prefix);
|
||||
SetFeedId(feed, $"series-{series.Id}-volume-{volume.Id}-{_seriesService.FormatChapterName(userId, libraryType)}s");
|
||||
SetFeedId(feed, $"series-{series.Id}-volume-{volume.Id}");
|
||||
|
||||
foreach (var chapterId in volume.Chapters.Select(c => c.Id))
|
||||
var chapterDtos = await _unitOfWork.ChapterRepository.GetChapterDtoByIdsAsync(volume.Chapters.Select(c => c.Id), userId);
|
||||
|
||||
// Check if there is reading progress or not, if so, inject a "continue-reading" item
|
||||
var firstChapterWithProgress = chapterDtos.FirstOrDefault(c => c.PagesRead > 0);
|
||||
if (firstChapterWithProgress != null)
|
||||
{
|
||||
var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId, ChapterIncludes.Files | ChapterIncludes.People);
|
||||
if (chapterDto == null) continue;
|
||||
var chapterDto = await _readerService.GetContinuePoint(seriesId, userId);
|
||||
await AddContinueReadingPoint(apiKey, seriesId, chapterDto, userId, feed, prefix, baseUrl);
|
||||
}
|
||||
|
||||
foreach (var chapterDto in chapterDtos)
|
||||
{
|
||||
foreach (var mangaFile in chapterDto.Files)
|
||||
{
|
||||
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volumeId, chapterId, mangaFile, series, chapterDto!, apiKey, prefix, baseUrl));
|
||||
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volumeId, chapterDto.Id, mangaFile, series, chapterDto!, apiKey, prefix, baseUrl));
|
||||
}
|
||||
}
|
||||
|
||||
@ -1032,14 +1034,17 @@ public class OpdsController : BaseApiController
|
||||
var (baseUrl, prefix) = await GetPrefix();
|
||||
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
|
||||
if (series == null) return BadRequest(await _localizationService.Translate(userId, "series-doesnt-exist"));
|
||||
|
||||
var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId);
|
||||
var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId, ChapterIncludes.Files | ChapterIncludes.People);
|
||||
var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId, userId);
|
||||
|
||||
if (chapter == null) return BadRequest(await _localizationService.Translate(userId, "chapter-doesnt-exist"));
|
||||
|
||||
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId);
|
||||
|
||||
var feed = CreateFeed(series.Name + " - Volume " + volume!.Name + $" - {_seriesService.FormatChapterName(userId, libraryType)}s",
|
||||
var chapterName = await _seriesService.FormatChapterName(userId, libraryType);
|
||||
var feed = CreateFeed( $"{series.Name} - Volume {volume!.Name} - {chapterName} {chapterId}",
|
||||
$"{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}", apiKey, prefix);
|
||||
SetFeedId(feed, $"series-{series.Id}-volume-{volumeId}-{_seriesService.FormatChapterName(userId, libraryType)}-{chapterId}-files");
|
||||
|
||||
@ -1197,6 +1202,17 @@ public class OpdsController : BaseApiController
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<FeedEntry> CreateContinueReadingFromFile(int userId, int seriesId, int volumeId, int chapterId,
|
||||
MangaFileDto mangaFile, SeriesDto series, ChapterDto chapter, string apiKey, string prefix, string baseUrl)
|
||||
{
|
||||
var entry = await CreateChapterWithFile(userId, seriesId, volumeId, chapterId, mangaFile, series, chapter,
|
||||
apiKey, prefix, baseUrl);
|
||||
|
||||
entry.Title = await _localizationService.Translate(userId, "opds-continue-reading-title", entry.Title);
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
private async Task<FeedEntry> CreateChapterWithFile(int userId, int seriesId, int volumeId, int chapterId,
|
||||
MangaFileDto mangaFile, SeriesDto series, ChapterDto chapter, string apiKey, string prefix, string baseUrl)
|
||||
{
|
||||
@ -1216,6 +1232,7 @@ public class OpdsController : BaseApiController
|
||||
{
|
||||
var volumeLabel = await _localizationService.Translate(userId, "volume-num", string.Empty);
|
||||
SeriesService.RenameVolumeName(volume, libraryType, volumeLabel);
|
||||
|
||||
if (!volume.IsLooseLeaf())
|
||||
{
|
||||
title += $" - {volume.Name}";
|
||||
@ -1231,10 +1248,10 @@ public class OpdsController : BaseApiController
|
||||
}
|
||||
|
||||
// Chunky requires a file at the end. Our API ignores this
|
||||
var accLink =
|
||||
CreateLink(FeedLinkRelation.Acquisition, fileType,
|
||||
var accLink = CreateLink(FeedLinkRelation.Acquisition, fileType,
|
||||
$"{prefix}{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}/download/{filename}",
|
||||
filename);
|
||||
|
||||
accLink.TotalPages = chapter.Pages;
|
||||
|
||||
var entry = new FeedEntry()
|
||||
@ -1269,6 +1286,9 @@ public class OpdsController : BaseApiController
|
||||
entry.Links.Add(await CreatePageStreamLink(series.LibraryId, seriesId, volumeId, chapterId, mangaFile, apiKey, prefix));
|
||||
}
|
||||
|
||||
// Patch in reading status on the item (as OPDS is seriously lacking)
|
||||
entry.Title = $"{GetReadingProgressIcon(chapter.PagesRead, chapter.Pages)} {entry.Title}";
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
@ -1306,12 +1326,27 @@ public class OpdsController : BaseApiController
|
||||
|
||||
// Save progress for the user (except Panels, they will use a direct connection)
|
||||
var userAgent = Request.Headers["User-Agent"].ToString();
|
||||
|
||||
|
||||
|
||||
if (!userAgent.StartsWith("Panels", StringComparison.InvariantCultureIgnoreCase) || !saveProgress)
|
||||
{
|
||||
// Kavita expects 0-N for progress, KOReader doesn't respect the OPDS-PS spec and does some wierd stuff
|
||||
// https://github.com/Kareadita/Kavita/pull/4014#issuecomment-3313677492
|
||||
var koreaderOffset = 0;
|
||||
if (userAgent.StartsWith("Koreader", StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
var totalPages = await _unitOfWork.ChapterRepository.GetChapterTotalPagesAsync(chapterId);
|
||||
if (totalPages - pageNumber < 2)
|
||||
{
|
||||
koreaderOffset = 1;
|
||||
}
|
||||
}
|
||||
|
||||
await _readerService.SaveReadingProgress(new ProgressDto()
|
||||
{
|
||||
ChapterId = chapterId,
|
||||
PageNum = pageNumber,
|
||||
PageNum = pageNumber + koreaderOffset,
|
||||
SeriesId = seriesId,
|
||||
VolumeId = volumeId,
|
||||
LibraryId =libraryId
|
||||
@ -1322,7 +1357,7 @@ public class OpdsController : BaseApiController
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
_cacheService.CleanupChapters(new []{ chapterId });
|
||||
_cacheService.CleanupChapters([chapterId]);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
@ -1334,6 +1369,7 @@ public class OpdsController : BaseApiController
|
||||
var userId = GetUserIdFromContext();
|
||||
var files = _directoryService.GetFilesWithExtension(Path.Join(Directory.GetCurrentDirectory(), ".."), @"\.ico");
|
||||
if (files.Length == 0) return BadRequest(await _localizationService.Translate(userId, "favicon-doesnt-exist"));
|
||||
|
||||
var path = files[0];
|
||||
var content = await _directoryService.ReadFileAsync(path);
|
||||
var format = Path.GetExtension(path);
|
||||
@ -1452,8 +1488,35 @@ public class OpdsController : BaseApiController
|
||||
}
|
||||
}
|
||||
|
||||
private static UserParams GetUserParams(int pageNumber)
|
||||
{
|
||||
return new UserParams()
|
||||
{
|
||||
PageNumber = pageNumber,
|
||||
PageSize = PageSize
|
||||
};
|
||||
}
|
||||
|
||||
private static string RemoveInvalidXmlChars(string input)
|
||||
{
|
||||
return new string(input.Where(XmlConvert.IsXmlChar).ToArray());
|
||||
}
|
||||
|
||||
private static string GetReadingProgressIcon(int pagesRead, int totalPages)
|
||||
{
|
||||
if (pagesRead == 0) return "⭘";
|
||||
|
||||
var percentageRead = (double)pagesRead / totalPages;
|
||||
|
||||
return percentageRead switch
|
||||
{
|
||||
// 100%
|
||||
>= 1.0 => "⬤",
|
||||
// > 50% and < 100%
|
||||
> 0.5 => "◕",
|
||||
// > 25% and <= 50%
|
||||
> 0.25 => "◑",
|
||||
_ => "◔"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -516,7 +516,7 @@ public class ReaderController : BaseApiController
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress);
|
||||
if (user == null) return Unauthorized();
|
||||
user.Progresses ??= new List<AppUserProgress>();
|
||||
user.Progresses ??= [];
|
||||
|
||||
var volumes = await _unitOfWork.VolumeRepository.GetVolumesForSeriesAsync(dto.SeriesIds.ToArray(), true);
|
||||
foreach (var volume in volumes)
|
||||
@ -566,9 +566,11 @@ public class ReaderController : BaseApiController
|
||||
public async Task<ActionResult> SaveProgress(ProgressDto progressDto)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
if (!await _readerService.SaveReadingProgress(progressDto, userId))
|
||||
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-read-progress"));
|
||||
|
||||
if (!await _readerService.SaveReadingProgress(progressDto, userId))
|
||||
{
|
||||
return BadRequest(await _localizationService.Translate(userId, "generic-read-progress"));
|
||||
}
|
||||
|
||||
return Ok(true);
|
||||
}
|
||||
@ -627,7 +629,7 @@ public class ReaderController : BaseApiController
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks);
|
||||
if (user == null) return Unauthorized();
|
||||
if (user.Bookmarks == null) return Ok(await _localizationService.Translate(User.GetUserId(), "nothing-to-do"));
|
||||
if (user.Bookmarks == null || user.Bookmarks.Count == 0) return Ok(await _localizationService.Translate(User.GetUserId(), "nothing-to-do"));
|
||||
|
||||
try
|
||||
{
|
||||
@ -667,7 +669,7 @@ public class ReaderController : BaseApiController
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks);
|
||||
if (user == null) return Unauthorized();
|
||||
if (user.Bookmarks == null) return Ok(await _localizationService.Translate(User.GetUserId(), "nothing-to-do"));
|
||||
if (user.Bookmarks == null || user.Bookmarks.Count == 0) return Ok(await _localizationService.Translate(User.GetUserId(), "nothing-to-do"));
|
||||
|
||||
try
|
||||
{
|
||||
@ -882,7 +884,7 @@ public class ReaderController : BaseApiController
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
|
||||
var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId);
|
||||
var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId, userId);
|
||||
if (series == null || chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error"));
|
||||
|
||||
// Patch in the reading progress
|
||||
|
||||
@ -180,7 +180,7 @@ public class SeriesController : BaseApiController
|
||||
[HttpGet("chapter")]
|
||||
public async Task<ActionResult<ChapterDto>> GetChapter(int chapterId)
|
||||
{
|
||||
var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId);
|
||||
var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId, User.GetUserId());
|
||||
if (chapter == null) return NoContent();
|
||||
return Ok(await _unitOfWork.ChapterRepository.AddChapterModifiers(User.GetUserId(), chapter));
|
||||
}
|
||||
|
||||
@ -71,6 +71,9 @@ public class SettingsController : BaseApiController
|
||||
public async Task<ActionResult<ServerSettingDto>> GetSettings()
|
||||
{
|
||||
var settingsDto = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
|
||||
|
||||
// Do not send OIDC secret to user
|
||||
settingsDto.OidcConfig.Secret = "*".Repeat(settingsDto.OidcConfig.Secret.Length);
|
||||
return Ok(settingsDto);
|
||||
}
|
||||
|
||||
|
||||
@ -109,6 +109,7 @@ public class UsersController : BaseApiController
|
||||
existingPreferences.NoTransitions = preferencesDto.NoTransitions;
|
||||
existingPreferences.CollapseSeriesRelationships = preferencesDto.CollapseSeriesRelationships;
|
||||
existingPreferences.ShareReviews = preferencesDto.ShareReviews;
|
||||
existingPreferences.ColorScapeEnabled = preferencesDto.ColorScapeEnabled;
|
||||
existingPreferences.BookReaderHighlightSlots = preferencesDto.BookReaderHighlightSlots;
|
||||
|
||||
if (await _licenseService.HasActiveLicense())
|
||||
|
||||
@ -4,11 +4,6 @@ using System.Xml.Serialization;
|
||||
|
||||
namespace API.DTOs.OPDS;
|
||||
|
||||
// TODO: OPDS Dtos are internal state, shouldn't be in DTO directory
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
[XmlRoot("feed", Namespace = "http://www.w3.org/2005/Atom")]
|
||||
public sealed record Feed
|
||||
{
|
||||
@ -41,10 +36,10 @@ public sealed record Feed
|
||||
public int? StartIndex { get; set; } = null;
|
||||
|
||||
[XmlElement("link")]
|
||||
public List<FeedLink> Links { get; set; } = new List<FeedLink>() ;
|
||||
public List<FeedLink> Links { get; set; } = [];
|
||||
|
||||
[XmlElement("entry")]
|
||||
public List<FeedEntry> Entries { get; set; } = new List<FeedEntry>();
|
||||
public List<FeedEntry> Entries { get; set; } = [];
|
||||
|
||||
public bool ShouldSerializeTotal()
|
||||
{
|
||||
|
||||
@ -49,6 +49,7 @@ public sealed record AnnotationDto
|
||||
/// </summary>
|
||||
public int SelectedSlotIndex { get; set; }
|
||||
|
||||
|
||||
public required int ChapterId { get; set; }
|
||||
public required int VolumeId { get; set; }
|
||||
public required int SeriesId { get; set; }
|
||||
|
||||
16
API/DTOs/Reader/BookResourceResultDto.cs
Normal file
16
API/DTOs/Reader/BookResourceResultDto.cs
Normal file
@ -0,0 +1,16 @@
|
||||
namespace API.DTOs.Reader;
|
||||
|
||||
public sealed record BookResourceResultDto
|
||||
{
|
||||
public bool IsSuccess { get; init; }
|
||||
public string ErrorMessage { get; init; }
|
||||
public byte[] Content { get; init; }
|
||||
public string ContentType { get; init; }
|
||||
public string FileName { get; init; }
|
||||
|
||||
public static BookResourceResultDto Success(byte[] content, string contentType, string fileName) =>
|
||||
new() { IsSuccess = true, Content = content, ContentType = contentType, FileName = fileName };
|
||||
|
||||
public static BookResourceResultDto Error(string errorMessage) =>
|
||||
new() { IsSuccess = false, ErrorMessage = errorMessage };
|
||||
}
|
||||
@ -37,6 +37,9 @@ public sealed record UserPreferencesDto
|
||||
/// <inheritdoc cref="API.Entities.AppUserPreferences.Locale"/>
|
||||
[Required]
|
||||
public string Locale { get; set; }
|
||||
/// <inheritdoc cref="API.Entities.AppUserPreferences.ColorScapeEnabled"/>
|
||||
[Required]
|
||||
public bool ColorScapeEnabled { get; set; } = true;
|
||||
|
||||
/// <inheritdoc cref="API.Entities.AppUserPreferences.AniListScrobblingEnabled"/>
|
||||
public bool AniListScrobblingEnabled { get; set; }
|
||||
|
||||
@ -139,6 +139,9 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
|
||||
builder.Entity<AppUserPreferences>()
|
||||
.Property(b => b.AllowAutomaticWebtoonReaderDetection)
|
||||
.HasDefaultValue(true);
|
||||
builder.Entity<AppUserPreferences>()
|
||||
.Property(b => b.ColorScapeEnabled)
|
||||
.HasDefaultValue(true);
|
||||
|
||||
builder.Entity<Library>()
|
||||
.Property(b => b.AllowScrobbling)
|
||||
|
||||
3854
API/Data/Migrations/20250919114119_ColorScapeSetting.Designer.cs
generated
Normal file
3854
API/Data/Migrations/20250919114119_ColorScapeSetting.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
API/Data/Migrations/20250919114119_ColorScapeSetting.cs
Normal file
29
API/Data/Migrations/20250919114119_ColorScapeSetting.cs
Normal file
@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ColorScapeSetting : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "ColorScapeEnabled",
|
||||
table: "AppUserPreferences",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ColorScapeEnabled",
|
||||
table: "AppUserPreferences");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -551,6 +551,11 @@ namespace API.Data.Migrations
|
||||
b.Property<bool>("CollapseSeriesRelationships")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("ColorScapeEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("EmulateBook")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.DTOs.Dashboard;
|
||||
using API.Entities;
|
||||
using API.Helpers;
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@ -16,6 +17,7 @@ public interface IAppUserSmartFilterRepository
|
||||
void Attach(AppUserSmartFilter filter);
|
||||
void Delete(AppUserSmartFilter filter);
|
||||
IEnumerable<SmartFilterDto> GetAllDtosByUserId(int userId);
|
||||
Task<PagedList<SmartFilterDto>> GetPagedDtosByUserIdAsync(int userId, UserParams userParams);
|
||||
Task<AppUserSmartFilter?> GetById(int smartFilterId);
|
||||
|
||||
}
|
||||
@ -54,6 +56,15 @@ public class AppUserSmartFilterRepository : IAppUserSmartFilterRepository
|
||||
.AsEnumerable();
|
||||
}
|
||||
|
||||
public Task<PagedList<SmartFilterDto>> GetPagedDtosByUserIdAsync(int userId, UserParams userParams)
|
||||
{
|
||||
var filters = _context.AppUserSmartFilter
|
||||
.Where(f => f.AppUserId == userId)
|
||||
.ProjectTo<SmartFilterDto>(_mapper.ConfigurationProvider);
|
||||
|
||||
return PagedList<SmartFilterDto>.CreateAsync(filters, userParams);
|
||||
}
|
||||
|
||||
public async Task<AppUserSmartFilter?> GetById(int smartFilterId)
|
||||
{
|
||||
return await _context.AppUserSmartFilter
|
||||
|
||||
@ -40,10 +40,12 @@ public interface IChapterRepository
|
||||
Task<IChapterInfoDto?> GetChapterInfoDtoAsync(int chapterId);
|
||||
Task<int> GetChapterTotalPagesAsync(int chapterId);
|
||||
Task<Chapter?> GetChapterAsync(int chapterId, ChapterIncludes includes = ChapterIncludes.Files);
|
||||
Task<ChapterDto?> GetChapterDtoAsync(int chapterId, ChapterIncludes includes = ChapterIncludes.Files);
|
||||
Task<ChapterDto?> GetChapterDtoAsync(int chapterId, int userId);
|
||||
Task<IList<ChapterDto>> GetChapterDtoByIdsAsync(IEnumerable<int> chapterIds, int userId);
|
||||
Task<ChapterMetadataDto?> GetChapterMetadataDtoAsync(int chapterId, ChapterIncludes includes = ChapterIncludes.Files);
|
||||
Task<IList<MangaFile>> GetFilesForChapterAsync(int chapterId);
|
||||
Task<IList<Chapter>> GetChaptersAsync(int volumeId, ChapterIncludes includes = ChapterIncludes.None);
|
||||
Task<IList<ChapterDto>> GetChapterDtosAsync(int volumeId, int userId);
|
||||
Task<IList<MangaFile>> GetFilesForChaptersAsync(IReadOnlyList<int> chapterIds);
|
||||
Task<string?> GetChapterCoverImageAsync(int chapterId);
|
||||
Task<IList<string>> GetAllCoverImagesAsync();
|
||||
@ -153,18 +155,39 @@ public class ChapterRepository : IChapterRepository
|
||||
.Select(c => c.Pages)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
public async Task<ChapterDto?> GetChapterDtoAsync(int chapterId, ChapterIncludes includes = ChapterIncludes.Files)
|
||||
public async Task<ChapterDto?> GetChapterDtoAsync(int chapterId, int userId)
|
||||
{
|
||||
var chapter = await _context.Chapter
|
||||
.Includes(includes)
|
||||
.Includes(ChapterIncludes.Files | ChapterIncludes.People)
|
||||
.ProjectTo<ChapterDto>(_mapper.ConfigurationProvider)
|
||||
.AsNoTracking()
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(c => c.Id == chapterId);
|
||||
|
||||
if (userId > 0 && chapter != null)
|
||||
{
|
||||
await AddChapterModifiers(userId, chapter);
|
||||
}
|
||||
|
||||
return chapter;
|
||||
}
|
||||
|
||||
public async Task<IList<ChapterDto>> GetChapterDtoByIdsAsync(IEnumerable<int> chapterIds, int userId)
|
||||
{
|
||||
var chapters = await _context.Chapter
|
||||
.Where(c => chapterIds.Contains(c.Id))
|
||||
.Includes(ChapterIncludes.Files | ChapterIncludes.People)
|
||||
.ProjectTo<ChapterDto>(_mapper.ConfigurationProvider)
|
||||
.AsSplitQuery()
|
||||
.ToListAsync() ;
|
||||
|
||||
foreach (var chapter in chapters)
|
||||
{
|
||||
await AddChapterModifiers(userId, chapter);
|
||||
}
|
||||
|
||||
return chapters;
|
||||
}
|
||||
|
||||
public async Task<ChapterMetadataDto?> GetChapterMetadataDtoAsync(int chapterId, ChapterIncludes includes = ChapterIncludes.Files)
|
||||
{
|
||||
var chapter = await _context.Chapter
|
||||
@ -218,6 +241,28 @@ public class ChapterRepository : IChapterRepository
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns Chapters for a volume id with Progress
|
||||
/// </summary>
|
||||
/// <param name="volumeId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<IList<ChapterDto>> GetChapterDtosAsync(int volumeId, int userId)
|
||||
{
|
||||
var chapts = await _context.Chapter
|
||||
.Where(c => c.VolumeId == volumeId)
|
||||
.Includes(ChapterIncludes.Files | ChapterIncludes.People)
|
||||
.OrderBy(c => c.SortOrder)
|
||||
.ProjectTo<ChapterDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var chapter in chapts)
|
||||
{
|
||||
await AddChapterModifiers(userId, chapter);
|
||||
}
|
||||
|
||||
return chapts;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cover image for a chapter id.
|
||||
/// </summary>
|
||||
|
||||
@ -9,6 +9,7 @@ using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Extensions.QueryExtensions;
|
||||
using API.Extensions.QueryExtensions.Filtering;
|
||||
using API.Helpers;
|
||||
using API.Services.Plus;
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
@ -49,6 +50,7 @@ public interface ICollectionTagRepository
|
||||
/// <param name="includePromoted"></param>
|
||||
/// <returns></returns>
|
||||
Task<IEnumerable<AppUserCollectionDto>> GetCollectionDtosAsync(int userId, bool includePromoted = false);
|
||||
Task<PagedList<AppUserCollectionDto>> GetCollectionDtosPagedAsync(int userId, UserParams userParams, bool includePromoted = false);
|
||||
Task<IEnumerable<AppUserCollectionDto>> GetCollectionDtosBySeriesAsync(int userId, int seriesId, bool includePromoted = false);
|
||||
|
||||
Task<IList<string>> GetAllCoverImagesAsync();
|
||||
@ -117,6 +119,18 @@ public class CollectionTagRepository : ICollectionTagRepository
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<PagedList<AppUserCollectionDto>> GetCollectionDtosPagedAsync(int userId, UserParams userParams, bool includePromoted = false)
|
||||
{
|
||||
var ageRating = await _context.AppUser.GetUserAgeRestriction(userId);
|
||||
var collections = _context.AppUserCollection
|
||||
.Where(uc => uc.AppUserId == userId || (includePromoted && uc.Promoted))
|
||||
.WhereIf(ageRating.AgeRating != AgeRating.NotApplicable, uc => uc.AgeRating <= ageRating.AgeRating)
|
||||
.OrderBy(uc => uc.Title)
|
||||
.ProjectTo<AppUserCollectionDto>(_mapper.ConfigurationProvider);
|
||||
|
||||
return await PagedList<AppUserCollectionDto>.CreateAsync(collections, userParams);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<AppUserCollectionDto>> GetCollectionDtosBySeriesAsync(int userId, int seriesId, bool includePromoted = false)
|
||||
{
|
||||
var ageRating = await _context.AppUser.GetUserAgeRestriction(userId);
|
||||
|
||||
@ -26,8 +26,8 @@ public interface IGenreRepository
|
||||
Task RemoveAllGenreNoLongerAssociated(bool removeExternal = false);
|
||||
Task<IList<GenreTagDto>> GetAllGenreDtosForLibrariesAsync(int userId, IList<int>? libraryIds = null, QueryContext context = QueryContext.None);
|
||||
Task<int> GetCountAsync();
|
||||
Task<GenreTagDto> GetRandomGenre();
|
||||
Task<GenreTagDto> GetGenreById(int id);
|
||||
Task<GenreTagDto?> GetRandomGenre();
|
||||
Task<GenreTagDto?> GetGenreById(int id);
|
||||
Task<List<string>> GetAllGenresNotInListAsync(ICollection<string> genreNames);
|
||||
Task<PagedList<BrowseGenreDto>> GetBrowseableGenre(int userId, UserParams userParams);
|
||||
}
|
||||
@ -79,7 +79,7 @@ public class GenreRepository : IGenreRepository
|
||||
return await _context.Genre.CountAsync();
|
||||
}
|
||||
|
||||
public async Task<GenreTagDto> GetRandomGenre()
|
||||
public async Task<GenreTagDto?> GetRandomGenre()
|
||||
{
|
||||
var genreCount = await GetCountAsync();
|
||||
if (genreCount == 0) return null;
|
||||
@ -92,7 +92,7 @@ public class GenreRepository : IGenreRepository
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<GenreTagDto> GetGenreById(int id)
|
||||
public async Task<GenreTagDto?> GetGenreById(int id)
|
||||
{
|
||||
return await _context.Genre
|
||||
.Where(g => g.Id == id)
|
||||
|
||||
@ -31,7 +31,7 @@ public interface IReadingListRepository
|
||||
{
|
||||
Task<PagedList<ReadingListDto>> GetReadingListDtosForUserAsync(int userId, bool includePromoted, UserParams userParams, bool sortByLastModified = true);
|
||||
Task<ReadingList?> GetReadingListByIdAsync(int readingListId, ReadingListIncludes includes = ReadingListIncludes.None);
|
||||
Task<IEnumerable<ReadingListItemDto>> GetReadingListItemDtosByIdAsync(int readingListId, int userId);
|
||||
Task<IEnumerable<ReadingListItemDto>> GetReadingListItemDtosByIdAsync(int readingListId, int userId, UserParams? userParams = null);
|
||||
Task<ReadingListDto?> GetReadingListDtoByIdAsync(int readingListId, int userId);
|
||||
Task<IEnumerable<ReadingListItemDto>> AddReadingProgressModifiers(int userId, IList<ReadingListItemDto> items);
|
||||
Task<ReadingListDto?> GetReadingListDtoByTitleAsync(int userId, string title);
|
||||
@ -357,11 +357,11 @@ public class ReadingListRepository : IReadingListRepository
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ReadingListItemDto>> GetReadingListItemDtosByIdAsync(int readingListId, int userId)
|
||||
public async Task<IEnumerable<ReadingListItemDto>> GetReadingListItemDtosByIdAsync(int readingListId, int userId, UserParams? userParams = null)
|
||||
{
|
||||
var userLibraries = _context.Library.GetUserLibraries(userId);
|
||||
|
||||
var items = await _context.ReadingListItem
|
||||
var query = _context.ReadingListItem
|
||||
.Where(s => s.ReadingListId == readingListId)
|
||||
.Join(_context.Chapter, s => s.ChapterId, chapter => chapter.Id, (data, chapter) => new
|
||||
{
|
||||
@ -431,9 +431,17 @@ public class ReadingListRepository : IReadingListRepository
|
||||
})
|
||||
.Where(o => userLibraries.Contains(o.LibraryId))
|
||||
.OrderBy(rli => rli.Order)
|
||||
.AsSplitQuery()
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
.AsSplitQuery();
|
||||
|
||||
if (userParams != null)
|
||||
{
|
||||
query = query
|
||||
.Skip(userParams.PageNumber * userParams.PageSize)
|
||||
.Take(userParams.PageSize);
|
||||
}
|
||||
|
||||
|
||||
var items = await query.ToListAsync();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
|
||||
@ -29,20 +29,20 @@ namespace API.Data.Repositories;
|
||||
public enum AppUserIncludes
|
||||
{
|
||||
None = 1,
|
||||
Progress = 2,
|
||||
Bookmarks = 4,
|
||||
ReadingLists = 8,
|
||||
Ratings = 16,
|
||||
UserPreferences = 32,
|
||||
WantToRead = 64,
|
||||
ReadingListsWithItems = 128,
|
||||
Devices = 256,
|
||||
ScrobbleHolds = 512,
|
||||
SmartFilters = 1024,
|
||||
DashboardStreams = 2048,
|
||||
SideNavStreams = 4096,
|
||||
ExternalSources = 8192,
|
||||
Collections = 16384, // 2^14
|
||||
Progress = 1 << 1,
|
||||
Bookmarks = 1 << 2,
|
||||
ReadingLists = 1 << 3,
|
||||
Ratings = 1 << 4,
|
||||
UserPreferences = 1 << 5,
|
||||
WantToRead = 1 << 6,
|
||||
ReadingListsWithItems = 1 << 7,
|
||||
Devices = 1 << 8,
|
||||
ScrobbleHolds = 1 << 9,
|
||||
SmartFilters = 1 << 10,
|
||||
DashboardStreams = 1 << 11,
|
||||
SideNavStreams = 1 << 12,
|
||||
ExternalSources = 1 << 13,
|
||||
Collections = 1 << 14,
|
||||
ChapterRatings = 1 << 15,
|
||||
}
|
||||
|
||||
@ -118,6 +118,7 @@ public interface IUserRepository
|
||||
Task<AppUser?> GetByOidcId(string? oidcId, AppUserIncludes includes = AppUserIncludes.None);
|
||||
|
||||
Task<AnnotationDto?> GetAnnotationDtoById(int userId, int annotationId);
|
||||
Task<List<AnnotationDto>> GetAnnotationDtosBySeries(int userId, int seriesId);
|
||||
}
|
||||
|
||||
public class UserRepository : IUserRepository
|
||||
@ -612,6 +613,14 @@ public class UserRepository : IUserRepository
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<List<AnnotationDto>> GetAnnotationDtosBySeries(int userId, int seriesId)
|
||||
{
|
||||
return await _context.AppUserAnnotation
|
||||
.Where(a => a.AppUserId == userId && a.SeriesId == seriesId)
|
||||
.ProjectTo<AnnotationDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
|
||||
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
|
||||
{
|
||||
@ -629,6 +638,7 @@ public class UserRepository : IUserRepository
|
||||
var user = await _context.Users.FirstOrDefaultAsync(u => u.Id == userId);
|
||||
if (user == null) return ArraySegment<string>.Empty;
|
||||
|
||||
// ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
|
||||
if (_userManager == null)
|
||||
{
|
||||
// userManager is null on Unit Tests only
|
||||
|
||||
@ -169,6 +169,10 @@ public class AppUserPreferences
|
||||
/// UI Site Global Setting: The language locale that should be used for the user
|
||||
/// </summary>
|
||||
public string Locale { get; set; }
|
||||
/// <summary>
|
||||
/// UI Site Global Setting: Should Kavita render ColorScape gradients
|
||||
/// </summary>
|
||||
public bool ColorScapeEnabled { get; set; } = true;
|
||||
#endregion
|
||||
|
||||
#region KavitaPlus
|
||||
|
||||
@ -12,7 +12,8 @@ public enum LibraryType
|
||||
/// <summary>
|
||||
/// Uses Comic regex for filename parsing
|
||||
/// </summary>
|
||||
[Description("Comic (Legacy)")]
|
||||
/// <remarks>This was the original implementation and is much more flexible</remarks>
|
||||
[Description("Comic (Flexible)")]
|
||||
Comic = 1,
|
||||
/// <summary>
|
||||
/// Uses Manga regex for filename parsing also uses epub metadata
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace API.Extensions;
|
||||
@ -81,4 +82,15 @@ public static class StringExtensions
|
||||
return input[0] + new string('*', atIdx - 1) + input[atIdx..];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Repeat returns a string that is equal to the original string repeat n times
|
||||
/// </summary>
|
||||
/// <param name="input">String to repeat</param>
|
||||
/// <param name="n">Amount of times to repeat</param>
|
||||
/// <returns></returns>
|
||||
public static string Repeat(this string? input, int n)
|
||||
{
|
||||
return string.IsNullOrEmpty(input) ? string.Empty : string.Concat(Enumerable.Repeat(input, n));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -43,11 +43,13 @@ public static partial class AnnotationHelper
|
||||
var originalText = elem.InnerText;
|
||||
|
||||
// Calculate positions and sort by start position
|
||||
var normalizedOriginalText = NormalizeWhitespace(originalText);
|
||||
|
||||
var sortedAnnotations = elementAnnotations
|
||||
.Select(a => new
|
||||
{
|
||||
Annotation = a,
|
||||
StartPos = originalText.IndexOf(a.SelectedText, StringComparison.Ordinal)
|
||||
StartPos = normalizedOriginalText.IndexOf(NormalizeWhitespace(a.SelectedText), StringComparison.Ordinal)
|
||||
})
|
||||
.Where(a => a.StartPos >= 0)
|
||||
.OrderBy(a => a.StartPos)
|
||||
@ -79,9 +81,10 @@ public static partial class AnnotationHelper
|
||||
elem.AppendChild(HtmlNode.CreateNode(originalText.Substring(currentPos)));
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
catch (Exception ex)
|
||||
{
|
||||
/* Swallow */
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -388,7 +388,7 @@ public class AutoMapperProfiles : Profile
|
||||
|
||||
CreateMap<AppUserAnnotation, AnnotationDto>()
|
||||
.ForMember(dest => dest.OwnerUsername, opt => opt.MapFrom(src => src.AppUser.UserName))
|
||||
.ForMember(dest => dest.OwnerUserId, opt => opt.MapFrom(src => src.AppUserId));
|
||||
.ForMember(dest => dest.OwnerUserId, opt => opt.MapFrom(src => src.AppUserId)) ;
|
||||
|
||||
CreateMap<OidcConfigDto, OidcPublicConfigDto>();
|
||||
}
|
||||
|
||||
@ -23,6 +23,11 @@ public class PagedList<T> : List<T>
|
||||
public int PageSize { get; set; }
|
||||
public int TotalCount { get; set; }
|
||||
|
||||
public static async Task<PagedList<T>> CreateAsync(IQueryable<T> source, UserParams userParams)
|
||||
{
|
||||
return await CreateAsync(source, userParams.PageNumber, userParams.PageSize);
|
||||
}
|
||||
|
||||
public static async Task<PagedList<T>> CreateAsync(IQueryable<T> source, int pageNumber, int pageSize)
|
||||
{
|
||||
// NOTE: OrderBy warning being thrown here even if query has the orderby statement
|
||||
|
||||
@ -160,6 +160,7 @@
|
||||
"generic-user-pref": "There was an issue saving preferences",
|
||||
|
||||
"opds-disabled": "OPDS is not enabled on this server",
|
||||
"opds-continue-reading-title": "Continue Reading from: {0}",
|
||||
"on-deck": "On Deck",
|
||||
"browse-on-deck": "Browse On Deck",
|
||||
"recently-added": "Recently Added",
|
||||
@ -239,6 +240,7 @@
|
||||
"backup": "Backup",
|
||||
"update-yearly-stats": "Update Yearly Stats",
|
||||
|
||||
"generated-reading-profile-name": "Generated from {0}"
|
||||
"generated-reading-profile-name": "Generated from {0}",
|
||||
"genre-doesnt-exist": "Genre doesn't exist"
|
||||
|
||||
}
|
||||
|
||||
@ -54,6 +54,12 @@ public interface IAccountService
|
||||
/// <remarks>Does NOT commit</remarks>
|
||||
Task UpdateLibrariesForUser(AppUser user, IList<int> librariesIds, bool hasAdminRole);
|
||||
Task<IEnumerable<IdentityError>> UpdateRolesForUser(AppUser user, IList<string> roles);
|
||||
/// <summary>
|
||||
/// Seeds all information necessary for a new user
|
||||
/// </summary>
|
||||
/// <param name="user"></param>
|
||||
/// <returns></returns>
|
||||
Task SeedUser(AppUser user);
|
||||
void AddDefaultStreamsToUser(AppUser user);
|
||||
Task AddDefaultReadingProfileToUser(AppUser user);
|
||||
}
|
||||
@ -266,6 +272,17 @@ public partial class AccountService : IAccountService
|
||||
return [];
|
||||
}
|
||||
|
||||
public async Task SeedUser(AppUser user)
|
||||
{
|
||||
AddDefaultStreamsToUser(user);
|
||||
AddDefaultHighlightSlotsToUser(user);
|
||||
await AddDefaultReadingProfileToUser(user); // Commits
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assign default streams
|
||||
/// </summary>
|
||||
/// <param name="user"></param>
|
||||
public void AddDefaultStreamsToUser(AppUser user)
|
||||
{
|
||||
foreach (var newStream in Seed.DefaultStreams.Select(_mapper.Map<AppUserDashboardStream, AppUserDashboardStream>))
|
||||
@ -279,6 +296,18 @@ public partial class AccountService : IAccountService
|
||||
}
|
||||
}
|
||||
|
||||
private void AddDefaultHighlightSlotsToUser(AppUser user)
|
||||
{
|
||||
if (user.UserPreferences.BookReaderHighlightSlots.Any()) return;
|
||||
|
||||
user.UserPreferences.BookReaderHighlightSlots = Seed.DefaultHighlightSlots.ToList();
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assign default reading profile
|
||||
/// </summary>
|
||||
/// <param name="user"></param>
|
||||
public async Task AddDefaultReadingProfileToUser(AppUser user)
|
||||
{
|
||||
var profile = new AppUserReadingProfileBuilder(user.Id)
|
||||
|
||||
@ -63,6 +63,8 @@ public interface IBookService
|
||||
Task<Dictionary<string, int>> CreateKeyToPageMappingAsync(EpubBookRef book);
|
||||
Task<IDictionary<int, int>?> GetWordCountsPerPage(string bookFilePath);
|
||||
Task<string> CopyImageToTempFromBook(int chapterId, BookmarkDto bookmarkDto, string cachedBookPath);
|
||||
Task<BookResourceResultDto> GetResourceAsync(string bookFilePath, string requestedKey);
|
||||
|
||||
}
|
||||
|
||||
public partial class BookService : IBookService
|
||||
@ -315,7 +317,7 @@ public partial class BookService : IBookService
|
||||
/// </summary>
|
||||
/// <param name="doc"></param>
|
||||
/// <param name="ptocBookmarks"></param>
|
||||
private static void InjectPTOCBookmarks(HtmlDocument doc, List<PersonalToCDto> ptocBookmarks)
|
||||
private void InjectPTOCBookmarks(HtmlDocument doc, List<PersonalToCDto> ptocBookmarks)
|
||||
{
|
||||
if (ptocBookmarks.Count == 0) return;
|
||||
|
||||
@ -333,8 +335,9 @@ public partial class BookService : IBookService
|
||||
elem.PrependChild(HtmlNode.CreateNode(
|
||||
$"<i class='fa-solid fa-bookmark ps-1 pe-1' role='button' id='ptoc-{bookmark.Id}' title='{bookmark.Title}'></i>"));
|
||||
}
|
||||
catch (Exception)
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to inject a text (ptoc) bookmark into file");
|
||||
// Swallow
|
||||
}
|
||||
}
|
||||
@ -1075,6 +1078,27 @@ public partial class BookService : IBookService
|
||||
throw new KavitaException($"Page {bookmarkDto.Page} not found in epub");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to resolve a requested key path with some hacks to attempt to handle incorrect metadata
|
||||
/// </summary>
|
||||
/// <param name="bookFilePath"></param>
|
||||
/// <param name="requestedKey"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<BookResourceResultDto> GetResourceAsync(string bookFilePath, string requestedKey)
|
||||
{
|
||||
using var book = await EpubReader.OpenBookAsync(bookFilePath, LenientBookReaderOptions);
|
||||
var key = CoalesceKeyForAnyFile(book, requestedKey);
|
||||
|
||||
if (!book.Content.AllFiles.ContainsLocalFileRefWithKey(key))
|
||||
return BookResourceResultDto.Error("file-missing");
|
||||
|
||||
var bookFile = book.Content.AllFiles.GetLocalFileRefByKey(key);
|
||||
var content = await bookFile.ReadContentAsBytesAsync();
|
||||
var contentType = GetContentType(bookFile.ContentType);
|
||||
|
||||
return BookResourceResultDto.Success(content, contentType, requestedKey);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Parses out Title from book. Chapters and Volumes will always be "0". If there is any exception reading book (malformed books)
|
||||
@ -1219,7 +1243,7 @@ public partial class BookService : IBookService
|
||||
/// <param name="body">Body element from the epub</param>
|
||||
/// <param name="mappings">Epub mappings</param>
|
||||
/// <param name="page">Page number we are loading</param>
|
||||
/// <param name="ptocBookmarks">Ptoc Bookmarks to tie against</param>
|
||||
/// <param name="ptocBookmarks">Ptoc (Text) Bookmarks to tie against</param>
|
||||
/// <returns></returns>
|
||||
private async Task<string> ScopePage(HtmlDocument doc, EpubBookRef book, string apiBase, HtmlNode body,
|
||||
Dictionary<string, int> mappings, int page, List<PersonalToCDto> ptocBookmarks, List<AnnotationDto> annotations)
|
||||
@ -1228,7 +1252,6 @@ public partial class BookService : IBookService
|
||||
|
||||
RewriteAnchors(page, doc, mappings);
|
||||
|
||||
// TODO: Pass bookmarks here for state management
|
||||
ScopeImages(doc, book, apiBase);
|
||||
|
||||
InjectImages(doc, book, apiBase);
|
||||
@ -1285,13 +1308,13 @@ public partial class BookService : IBookService
|
||||
var cleanedKey = CleanContentKeys(key);
|
||||
if (book.Content.AllFiles.ContainsLocalFileRefWithKey(cleanedKey)) return cleanedKey;
|
||||
|
||||
// TODO: Figure this out
|
||||
// Fallback to searching for key (bad epub metadata)
|
||||
// var correctedKey = book.Content.AllFiles.Keys.SingleOrDefault(s => s.EndsWith(key));
|
||||
// if (!string.IsNullOrEmpty(correctedKey))
|
||||
// {
|
||||
// key = correctedKey;
|
||||
// }
|
||||
// Correct relative paths ./
|
||||
if (key.StartsWith("./"))
|
||||
{
|
||||
var nonPathKey = key.Replace("./", string.Empty);
|
||||
var correctedKey = book.Content.AllFiles.Local.SingleOrDefault(s => s.Key == nonPathKey);
|
||||
if (correctedKey != null) return correctedKey.Key;
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
@ -1329,8 +1352,8 @@ public partial class BookService : IBookService
|
||||
var tocPage = book.Content.Html.Local.Select(s => s.Key)
|
||||
.FirstOrDefault(k => k.Equals("TOC.XHTML", StringComparison.InvariantCultureIgnoreCase) ||
|
||||
k.Equals("NAVIGATION.XHTML", StringComparison.InvariantCultureIgnoreCase));
|
||||
if (string.IsNullOrEmpty(tocPage)) return chaptersList;
|
||||
|
||||
if (string.IsNullOrEmpty(tocPage)) return chaptersList;
|
||||
if (!book.Content.Html.TryGetLocalFileRefByKey(tocPage, out var file)) return chaptersList;
|
||||
var content = await file.ReadContentAsync();
|
||||
|
||||
|
||||
@ -47,7 +47,7 @@ public class KoreaderService : IKoreaderService
|
||||
var userProgressDto = await _unitOfWork.AppUserProgressRepository.GetUserProgressDtoAsync(file.ChapterId, userId);
|
||||
if (userProgressDto == null)
|
||||
{
|
||||
var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(file.ChapterId);
|
||||
var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(file.ChapterId, userId);
|
||||
if (chapterDto == null) throw new KavitaException(await _localizationService.Translate(userId, "chapter-doesnt-exist"));
|
||||
|
||||
var volumeDto = await _unitOfWork.VolumeRepository.GetVolumeByIdAsync(chapterDto.VolumeId);
|
||||
|
||||
@ -333,8 +333,7 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
|
||||
user.OidcId = externalId;
|
||||
user.IdentityProvider = IdentityProvider.OpenIdConnect;
|
||||
|
||||
accountService.AddDefaultStreamsToUser(user);
|
||||
await accountService.AddDefaultReadingProfileToUser(user);
|
||||
await accountService.SeedUser(user);
|
||||
|
||||
await SyncUserSettings(request, settings, claimsPrincipal, user);
|
||||
await SetDefaults(settings, user);
|
||||
|
||||
@ -598,11 +598,17 @@ public class SettingsService : ISettingsService
|
||||
updateSettingsDto.OidcConfig.RolesClaim = ClaimTypes.Role;
|
||||
}
|
||||
|
||||
var currentConfig = JsonSerializer.Deserialize<OidcConfigDto>(setting.Value)!;
|
||||
|
||||
// Patch Oidc Secret back in if not changed
|
||||
if ("*".Repeat(currentConfig.Secret.Length) == updateSettingsDto.OidcConfig.Secret)
|
||||
{
|
||||
updateSettingsDto.OidcConfig.Secret = currentConfig.Secret;
|
||||
}
|
||||
|
||||
var newValue = JsonSerializer.Serialize(updateSettingsDto.OidcConfig);
|
||||
if (setting.Value == newValue) return false;
|
||||
|
||||
var currentConfig = JsonSerializer.Deserialize<OidcConfigDto>(setting.Value)!;
|
||||
|
||||
if (currentConfig.Authority != updateSettingsDto.OidcConfig.Authority)
|
||||
{
|
||||
if (!await IsValidAuthority(updateSettingsDto.OidcConfig.Authority + string.Empty))
|
||||
|
||||
@ -95,7 +95,7 @@ public class TachiyomiService : ITachiyomiService
|
||||
}
|
||||
|
||||
// There is progress, we now need to figure out the highest volume or chapter and return that.
|
||||
var prevChapter = (await _unitOfWork.ChapterRepository.GetChapterDtoAsync(prevChapterId))!;
|
||||
var prevChapter = (await _unitOfWork.ChapterRepository.GetChapterDtoAsync(prevChapterId, userId))!;
|
||||
|
||||
var volumeWithProgress = (await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(prevChapter.VolumeId, userId))!;
|
||||
// We only encode for single-file volumes
|
||||
|
||||
@ -77,7 +77,6 @@ public class ThemeService : IThemeService
|
||||
private readonly IDirectoryService _directoryService;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IEventHub _eventHub;
|
||||
private readonly IFileService _fileService;
|
||||
private readonly ILogger<ThemeService> _logger;
|
||||
private readonly Markdown _markdown = new();
|
||||
private readonly IMemoryCache _cache;
|
||||
@ -91,12 +90,11 @@ public class ThemeService : IThemeService
|
||||
private const string GithubReadme = "https://raw.githubusercontent.com/Kareadita/Themes/main/README.md";
|
||||
|
||||
public ThemeService(IDirectoryService directoryService, IUnitOfWork unitOfWork,
|
||||
IEventHub eventHub, IFileService fileService, ILogger<ThemeService> logger, IMemoryCache cache)
|
||||
IEventHub eventHub, ILogger<ThemeService> logger, IMemoryCache cache)
|
||||
{
|
||||
_directoryService = directoryService;
|
||||
_unitOfWork = unitOfWork;
|
||||
_eventHub = eventHub;
|
||||
_fileService = fileService;
|
||||
_logger = logger;
|
||||
_cache = cache;
|
||||
|
||||
|
||||
@ -400,11 +400,13 @@ public class Startup
|
||||
app.UseStaticFiles(new StaticFileOptions
|
||||
{
|
||||
// bcmap files needed for PDF reader localizations (https://github.com/Kareadita/Kavita/issues/2970)
|
||||
// ftl files are needed for PDF zoom options (https://github.com/Kareadita/Kavita/issues/3995)
|
||||
ContentTypeProvider = new FileExtensionContentTypeProvider
|
||||
{
|
||||
Mappings =
|
||||
{
|
||||
[".bcmap"] = "application/octet-stream"
|
||||
[".bcmap"] = "application/octet-stream",
|
||||
[".ftl"] = "text/plain"
|
||||
}
|
||||
},
|
||||
HttpsCompression = HttpsCompressionMode.Compress,
|
||||
|
||||
10
README.md
10
README.md
@ -26,14 +26,14 @@ your reading collection with your friends and family!
|
||||
- First class responsive readers that work great on any device (phone, tablet, desktop)
|
||||
- Customizable theming support: [Theme Repo](https://github.com/Kareadita/Themes) and [Documentation](https://wiki.kavitareader.com/guides/themes)
|
||||
- External metadata integration and scrobbling for read status, ratings, and reviews (available via [Kavita+](https://wiki.kavitareader.com/kavita+))
|
||||
- Rich Metadata support with filtering and searching
|
||||
- Rich Metadata support with filtering, searching, and smart filters
|
||||
- Ways to group reading material: Collections, Reading Lists (CBL Import), Want to Read
|
||||
- Ability to manage users with rich Role-based management for age restrictions, abilities within the app, etc
|
||||
- Ability to manage users with rich Role-based management for age restrictions, abilities within the app, OIDC, etc
|
||||
- Rich web readers supporting webtoon, continuous reading mode (continue without leaving the reader), virtual pages (epub), etc
|
||||
- Ability to customize your dashboard and side nav with smart filters, custom order and visibility toggles
|
||||
- Full Localization Support
|
||||
- Ability to download metadata (available via [Kavita+](https://wiki.kavitareader.com/kavita+))
|
||||
|
||||
- Full Localization Support ([Weblate](https://hosted.weblate.org/engage/kavita/))
|
||||
- Ability to download metadata, reviews, ratings, and more (available via [Kavita+](https://wiki.kavitareader.com/kavita+))
|
||||
- Epub-based Annotation/Highlight support
|
||||
|
||||
## Support
|
||||
[](https://discord.gg/eczRp9eeem)
|
||||
|
||||
@ -91,6 +91,14 @@
|
||||
"maximumError": "30kb"
|
||||
}
|
||||
]
|
||||
},
|
||||
"proxy": {
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.proxy.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": ""
|
||||
@ -106,6 +114,9 @@
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "kavita-webui:build:production"
|
||||
},
|
||||
"proxy": {
|
||||
"buildTarget": "kavita-webui:build:proxy"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -4,8 +4,10 @@
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "npm run cache-locale && ng serve --host 0.0.0.0",
|
||||
"start-proxy": "npm run cache-locale && ng serve --configuration proxy --host 0.0.0.0 --proxy-config proxy.conf.json",
|
||||
"build": "npm run cache-locale && ng build",
|
||||
"build-backend": "ng build && rm -r ../../API/wwwroot/* && cp -r dist/browser/* ../../API/wwwroot",
|
||||
"build-backend-prod": "ng build --configuration production && rm -r ../../API/wwwroot/* && cp -r dist/browser/* ../../API/wwwroot",
|
||||
"minify-langs": "node minify-json.js",
|
||||
"cache-locale": "node hash-localization.js",
|
||||
"cache-locale-prime": "node hash-localization-prime.js",
|
||||
|
||||
37
UI/Web/proxy.conf.json
Normal file
37
UI/Web/proxy.conf.json
Normal file
@ -0,0 +1,37 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://localhost:5000",
|
||||
"secure": false,
|
||||
"changeOrigin": true,
|
||||
"logLevel": "debug"
|
||||
},
|
||||
"/hubs": {
|
||||
"target": "http://localhost:5000",
|
||||
"secure": false,
|
||||
"changeOrigin": true,
|
||||
"logLevel": "debug",
|
||||
"ws": true
|
||||
},
|
||||
"/oidc/login": {
|
||||
"target": "http://localhost:5000",
|
||||
"secure": false,
|
||||
"changeOrigin": true,
|
||||
"logLevel": "debug"
|
||||
},
|
||||
"/oidc/logout": {
|
||||
"target": "http://localhost:5000",
|
||||
"secure": false,
|
||||
"changeOrigin": true,
|
||||
"logLevel": "debug"
|
||||
},
|
||||
"/signin-oidc": {
|
||||
"target": "http://localhost:5000",
|
||||
"secure": false,
|
||||
"changeOrigin": true
|
||||
},
|
||||
"/signout-callback-oidc": {
|
||||
"target": "http://localhost:5000",
|
||||
"secure": false,
|
||||
"changeOrigin": true
|
||||
}
|
||||
}
|
||||
@ -11,16 +11,16 @@ import {APP_BASE_HREF} from "@angular/common";
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AuthGuard implements CanActivate {
|
||||
private accountService = inject(AccountService);
|
||||
private router = inject(Router);
|
||||
private toastr = inject(ToastrService);
|
||||
private translocoService = inject(TranslocoService);
|
||||
|
||||
|
||||
public static urlKey: string = 'kavita--auth-intersection-url';
|
||||
|
||||
baseURL = inject(APP_BASE_HREF);
|
||||
|
||||
constructor(private accountService: AccountService,
|
||||
private router: Router,
|
||||
private toastr: ToastrService,
|
||||
private translocoService: TranslocoService) {}
|
||||
|
||||
canActivate(): Observable<boolean> {
|
||||
return this.accountService.currentUser$.pipe(take(1),
|
||||
map((user) => {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { MemberService } from '../_services/member.service';
|
||||
@ -7,8 +7,8 @@ import { MemberService } from '../_services/member.service';
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class LibraryAccessGuard implements CanActivate {
|
||||
private memberService = inject(MemberService);
|
||||
|
||||
constructor(private memberService: MemberService) {}
|
||||
|
||||
canActivate(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> {
|
||||
const libraryId = parseInt(state.url.split('library/')[1], 10);
|
||||
|
||||
@ -11,13 +11,14 @@ import {APP_BASE_HREF} from "@angular/common";
|
||||
|
||||
@Injectable()
|
||||
export class ErrorInterceptor implements HttpInterceptor {
|
||||
private router = inject(Router);
|
||||
private toastr = inject(ToastrService);
|
||||
private accountService = inject(AccountService);
|
||||
private translocoService = inject(TranslocoService);
|
||||
|
||||
|
||||
baseURL = inject(APP_BASE_HREF);
|
||||
|
||||
constructor(private router: Router, private toastr: ToastrService,
|
||||
private accountService: AccountService,
|
||||
private translocoService: TranslocoService) {}
|
||||
|
||||
|
||||
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
|
||||
return next.handle(request).pipe(
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor } from '@angular/common/http';
|
||||
import {Observable, switchMap} from 'rxjs';
|
||||
import { AccountService } from '../_services/account.service';
|
||||
@ -6,8 +6,8 @@ import { take } from 'rxjs/operators';
|
||||
|
||||
@Injectable()
|
||||
export class JwtInterceptor implements HttpInterceptor {
|
||||
private accountService = inject(AccountService);
|
||||
|
||||
constructor(private accountService: AccountService) {}
|
||||
|
||||
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
|
||||
const user = this.accountService.currentUserSignal();
|
||||
|
||||
@ -14,6 +14,7 @@ export interface Preferences {
|
||||
shareReviews: boolean;
|
||||
locale: string;
|
||||
bookReaderHighlightSlots: HighlightSlot[];
|
||||
colorScapeEnabled: boolean;
|
||||
|
||||
// Kavita+
|
||||
aniListScrobblingEnabled: boolean;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
import { Pipe, PipeTransform, inject } from '@angular/core';
|
||||
import {TranslocoService} from "@jsverse/transloco";
|
||||
|
||||
@Pipe({
|
||||
@ -7,9 +7,8 @@ import {TranslocoService} from "@jsverse/transloco";
|
||||
standalone: true
|
||||
})
|
||||
export class DefaultDatePipe implements PipeTransform {
|
||||
private translocoService = inject(TranslocoService);
|
||||
|
||||
constructor(private translocoService: TranslocoService) {
|
||||
}
|
||||
transform(value: any, replacementString = 'default-date-pipe.never'): string {
|
||||
if (value === null || value === undefined || value === '' || value === Infinity || Number.isNaN(value) || value === '1/1/01') {
|
||||
return this.translocoService.translate(replacementString);
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
import { Pipe, PipeTransform, inject } from '@angular/core';
|
||||
import { map, Observable } from 'rxjs';
|
||||
import { MetadataService } from '../_services/metadata.service';
|
||||
import {shareReplay} from "rxjs/operators";
|
||||
@ -8,8 +8,8 @@ import {shareReplay} from "rxjs/operators";
|
||||
standalone: true
|
||||
})
|
||||
export class LanguageNamePipe implements PipeTransform {
|
||||
private metadataService = inject(MetadataService);
|
||||
|
||||
constructor(private metadataService: MetadataService) {}
|
||||
|
||||
transform(isoCode: string): Observable<string> {
|
||||
return this.metadataService.getLanguageNameForCode(isoCode).pipe(shareReplay());
|
||||
|
||||
30
UI/Web/src/app/_pipes/library-type-subtitle.pipe.ts
Normal file
30
UI/Web/src/app/_pipes/library-type-subtitle.pipe.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import {Pipe, PipeTransform} from '@angular/core';
|
||||
import {LibraryType} from "../_models/library/library";
|
||||
import {translate} from "@jsverse/transloco";
|
||||
|
||||
@Pipe({
|
||||
name: 'libraryTypeSubtitle'
|
||||
})
|
||||
export class LibraryTypeSubtitlePipe implements PipeTransform {
|
||||
|
||||
transform(value: LibraryType | null | undefined): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
|
||||
switch (value) {
|
||||
case LibraryType.Manga:
|
||||
return translate('library-type-subtitle-pipe.manga');
|
||||
case LibraryType.Comic:
|
||||
return translate('library-type-subtitle-pipe.comic');
|
||||
case LibraryType.Book:
|
||||
return translate('library-type-subtitle-pipe.book');
|
||||
case LibraryType.Images:
|
||||
return translate('library-type-subtitle-pipe.image');
|
||||
case LibraryType.LightNovel:
|
||||
return translate('library-type-subtitle-pipe.lightNovel');
|
||||
case LibraryType.ComicVine:
|
||||
return translate('library-type-subtitle-pipe.comicVine');
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import {Pipe, PipeTransform} from '@angular/core';
|
||||
import { Pipe, PipeTransform, inject } from '@angular/core';
|
||||
import { MangaFormat } from '../_models/manga-format';
|
||||
import {TranslocoService} from "@jsverse/transloco";
|
||||
|
||||
@ -10,8 +10,8 @@ import {TranslocoService} from "@jsverse/transloco";
|
||||
standalone: true
|
||||
})
|
||||
export class MangaFormatPipe implements PipeTransform {
|
||||
private translocoService = inject(TranslocoService);
|
||||
|
||||
constructor(private translocoService: TranslocoService) {}
|
||||
|
||||
transform(format: MangaFormat): string {
|
||||
switch (format) {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import {Pipe, PipeTransform} from '@angular/core';
|
||||
import { Pipe, PipeTransform, inject } from '@angular/core';
|
||||
import { PublicationStatus } from '../_models/metadata/publication-status';
|
||||
import {TranslocoService} from "@jsverse/transloco";
|
||||
|
||||
@ -7,7 +7,8 @@ import {TranslocoService} from "@jsverse/transloco";
|
||||
standalone: true
|
||||
})
|
||||
export class PublicationStatusPipe implements PipeTransform {
|
||||
constructor(private translocoService: TranslocoService) {}
|
||||
private translocoService = inject(TranslocoService);
|
||||
|
||||
|
||||
transform(value: PublicationStatus): string {
|
||||
switch (value) {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import {Pipe, PipeTransform} from '@angular/core';
|
||||
import { Pipe, PipeTransform, inject } from '@angular/core';
|
||||
import {TranslocoService} from "@jsverse/transloco";
|
||||
import {HourEstimateRange} from "../_models/series-detail/hour-estimate-range";
|
||||
|
||||
@ -7,8 +7,8 @@ import {HourEstimateRange} from "../_models/series-detail/hour-estimate-range";
|
||||
standalone: true
|
||||
})
|
||||
export class ReadTimeLeftPipe implements PipeTransform {
|
||||
private readonly translocoService = inject(TranslocoService);
|
||||
|
||||
constructor(private readonly translocoService: TranslocoService) {}
|
||||
|
||||
transform(readingTimeLeft: HourEstimateRange, includeLeftLabel = false): string {
|
||||
const hoursLabel = readingTimeLeft.avgHours > 1
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
import { Pipe, PipeTransform, inject } from '@angular/core';
|
||||
import {IHasReadingTime} from "../_models/common/i-has-reading-time";
|
||||
import {TranslocoService} from "@jsverse/transloco";
|
||||
|
||||
@ -7,7 +7,8 @@ import {TranslocoService} from "@jsverse/transloco";
|
||||
standalone: true
|
||||
})
|
||||
export class ReadTimePipe implements PipeTransform {
|
||||
constructor(private translocoService: TranslocoService) {}
|
||||
private translocoService = inject(TranslocoService);
|
||||
|
||||
|
||||
transform(readingTime: IHasReadingTime): string {
|
||||
if (readingTime.maxHoursToRead === 0 || readingTime.minHoursToRead === 0) {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import {Pipe, PipeTransform} from '@angular/core';
|
||||
import { Pipe, PipeTransform, inject } from '@angular/core';
|
||||
import {SortField} from "../_models/metadata/series-filter";
|
||||
import {TranslocoService} from "@jsverse/transloco";
|
||||
import {ValidFilterEntity} from "../metadata-filter/filter-settings";
|
||||
@ -9,9 +9,8 @@ import {PersonSortField} from "../_models/metadata/v2/person-sort-field";
|
||||
standalone: true
|
||||
})
|
||||
export class SortFieldPipe implements PipeTransform {
|
||||
private translocoService = inject(TranslocoService);
|
||||
|
||||
constructor(private translocoService: TranslocoService) {
|
||||
}
|
||||
|
||||
transform<T extends number>(value: T, entityType: ValidFilterEntity): string {
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import {ChangeDetectorRef, NgZone, OnDestroy, Pipe, PipeTransform} from '@angular/core';
|
||||
import { ChangeDetectorRef, NgZone, OnDestroy, Pipe, PipeTransform, inject } from '@angular/core';
|
||||
import {TranslocoService} from "@jsverse/transloco";
|
||||
|
||||
/**
|
||||
@ -34,10 +34,12 @@ and modified
|
||||
standalone: true
|
||||
})
|
||||
export class TimeAgoPipe implements PipeTransform, OnDestroy {
|
||||
private readonly changeDetectorRef = inject(ChangeDetectorRef);
|
||||
private ngZone = inject(NgZone);
|
||||
private translocoService = inject(TranslocoService);
|
||||
|
||||
|
||||
private timer: number | null = null;
|
||||
constructor(private readonly changeDetectorRef: ChangeDetectorRef, private ngZone: NgZone,
|
||||
private translocoService: TranslocoService) {}
|
||||
|
||||
transform(value: string | Date | null) {
|
||||
if (value === '' || value === null || value === undefined || (typeof value === 'string' && value.split('T')[0] === '0001-01-01')) {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import {ActivatedRouteSnapshot, Resolve, RouterStateSnapshot} from '@angular/router';
|
||||
import {Observable} from 'rxjs';
|
||||
import {ReadingProfileService} from "../_services/reading-profile.service";
|
||||
@ -7,8 +7,8 @@ import {ReadingProfileService} from "../_services/reading-profile.service";
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ReadingProfileResolver implements Resolve<any> {
|
||||
private readingProfileService = inject(ReadingProfileService);
|
||||
|
||||
constructor(private readingProfileService: ReadingProfileService) {}
|
||||
|
||||
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<any> {
|
||||
// Extract seriesId from route params or parent route
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import {Injectable} from "@angular/core";
|
||||
import { Injectable, inject } from "@angular/core";
|
||||
import {ActivatedRouteSnapshot, Resolve, RouterStateSnapshot} from "@angular/router";
|
||||
import {Observable, of} from "rxjs";
|
||||
import {FilterV2} from "../_models/metadata/v2/filter-v2";
|
||||
@ -12,8 +12,8 @@ import {FilterUtilitiesService} from "../shared/_services/filter-utilities.servi
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class UrlFilterResolver implements Resolve<any> {
|
||||
private filterUtilitiesService = inject(FilterUtilitiesService);
|
||||
|
||||
constructor(private filterUtilitiesService: FilterUtilitiesService) {}
|
||||
|
||||
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FilterV2 | null> {
|
||||
if (!state.url.includes('?')) return of(null);
|
||||
|
||||
@ -7,7 +7,6 @@ import {Preferences} from '../_models/preferences/preferences';
|
||||
import {User} from '../_models/user';
|
||||
import {Router} from '@angular/router';
|
||||
import {EVENTS, MessageHubService} from './message-hub.service';
|
||||
import {ThemeService} from './theme.service';
|
||||
import {InviteUserResponse} from '../_models/auth/invite-user-response';
|
||||
import {UserUpdateEvent} from '../_models/events/user-update-event';
|
||||
import {AgeRating} from '../_models/metadata/age-rating';
|
||||
@ -51,7 +50,6 @@ export class AccountService {
|
||||
private readonly httpClient = inject(HttpClient);
|
||||
private readonly router = inject(Router);
|
||||
private readonly messageHub = inject(MessageHubService);
|
||||
private readonly themeService = inject(ThemeService);
|
||||
|
||||
baseUrl = environment.apiUrl;
|
||||
userKey = 'kavita-user';
|
||||
@ -226,14 +224,6 @@ export class AccountService {
|
||||
if (user) {
|
||||
localStorage.setItem(this.userKey, JSON.stringify(user));
|
||||
localStorage.setItem(AccountService.lastLoginKey, user.username);
|
||||
|
||||
if (user.preferences && user.preferences.theme) {
|
||||
this.themeService.setTheme(user.preferences.theme.name);
|
||||
} else {
|
||||
this.themeService.setTheme(this.themeService.defaultTheme);
|
||||
}
|
||||
} else {
|
||||
this.themeService.setTheme(this.themeService.defaultTheme);
|
||||
}
|
||||
|
||||
this.currentUser = user;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import {map, Observable, shareReplay} from 'rxjs';
|
||||
import {Chapter} from '../_models/chapter';
|
||||
import {UserCollection} from '../_models/collection-tag';
|
||||
@ -180,6 +180,9 @@ export type ActionableEntity = Volume | Series | Chapter | ReadingList | UserCol
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ActionFactoryService {
|
||||
private accountService = inject(AccountService);
|
||||
private deviceService = inject(DeviceService);
|
||||
|
||||
private libraryActions: Array<ActionItem<Library>> = [];
|
||||
private seriesActions: Array<ActionItem<Series>> = [];
|
||||
private volumeActions: Array<ActionItem<Volume>> = [];
|
||||
@ -192,7 +195,7 @@ export class ActionFactoryService {
|
||||
private smartFilterActions: Array<ActionItem<SmartFilter>> = [];
|
||||
private sideNavHomeActions: Array<ActionItem<void>> = [];
|
||||
|
||||
constructor(private accountService: AccountService, private deviceService: DeviceService) {
|
||||
constructor() {
|
||||
this.accountService.currentUser$.subscribe((_) => {
|
||||
this._resetActions();
|
||||
});
|
||||
|
||||
@ -73,6 +73,10 @@ export class AnnotationService {
|
||||
}));
|
||||
}
|
||||
|
||||
getAnnotationsForSeries(seriesId: number) {
|
||||
return this.httpClient.get<Array<Annotation>>(this.baseUrl + 'annotation/all-for-series?seriesId=' + seriesId);
|
||||
}
|
||||
|
||||
|
||||
createAnnotation(data: Annotation) {
|
||||
return this.httpClient.post<Annotation>(this.baseUrl + 'annotation/create', data).pipe(
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import {environment} from "../../environments/environment";
|
||||
import {HttpClient} from "@angular/common/http";
|
||||
import {Chapter} from "../_models/chapter";
|
||||
@ -9,11 +9,11 @@ import {ChapterDetailPlus} from "../_models/chapter-detail-plus";
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ChapterService {
|
||||
private httpClient = inject(HttpClient);
|
||||
|
||||
|
||||
baseUrl = environment.apiUrl;
|
||||
|
||||
constructor(private httpClient: HttpClient) { }
|
||||
|
||||
getChapterMetadata(chapterId: number) {
|
||||
return this.httpClient.get<Chapter>(this.baseUrl + 'chapter?chapterId=' + chapterId);
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import {Injectable} from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import {environment} from 'src/environments/environment';
|
||||
import {UserCollection} from '../_models/collection-tag';
|
||||
import {TextResonse} from '../_types/text-response';
|
||||
@ -12,11 +12,12 @@ import {AccountService} from "./account.service";
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class CollectionTagService {
|
||||
private httpClient = inject(HttpClient);
|
||||
private accountService = inject(AccountService);
|
||||
|
||||
|
||||
baseUrl = environment.apiUrl;
|
||||
|
||||
constructor(private httpClient: HttpClient, private accountService: AccountService) { }
|
||||
|
||||
allCollections(ownedOnly = false) {
|
||||
return this.httpClient.get<UserCollection[]>(this.baseUrl + 'collection?ownedOnly=' + ownedOnly);
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ import {inject, Injectable} from '@angular/core';
|
||||
import {DOCUMENT} from '@angular/common';
|
||||
import {BehaviorSubject, filter, take, tap, timer} from 'rxjs';
|
||||
import {NavigationEnd, Router} from "@angular/router";
|
||||
import {environment} from "../../environments/environment";
|
||||
import {AccountService} from "./account.service";
|
||||
|
||||
interface ColorSpace {
|
||||
primary: string;
|
||||
@ -33,10 +33,10 @@ const colorScapeSelector = 'colorscape';
|
||||
export class ColorscapeService {
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly router = inject(Router);
|
||||
private readonly accountService = inject(AccountService);
|
||||
|
||||
private colorSubject = new BehaviorSubject<ColorSpaceRGBA | null>(null);
|
||||
private colorSeedSubject = new BehaviorSubject<{primary: string, complementary: string | null} | null>(null);
|
||||
public readonly colors$ = this.colorSubject.asObservable();
|
||||
|
||||
private minDuration = 1000; // minimum duration
|
||||
private maxDuration = 4000; // maximum duration
|
||||
@ -52,6 +52,7 @@ export class ColorscapeService {
|
||||
tap(() => this.checkAndResetColorscapeAfterDelay())
|
||||
).subscribe();
|
||||
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@ -175,7 +176,7 @@ export class ColorscapeService {
|
||||
* @param complementaryColor
|
||||
*/
|
||||
setColorScape(primaryColor: string, complementaryColor: string | null = null) {
|
||||
if (this.getCssVariable('--colorscape-enabled') === 'false') {
|
||||
if (this.accountService.currentUserSignal()?.preferences?.colorScapeEnabled === false || this.getCssVariable('--colorscape-enabled') === 'false') {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import {TextResonse} from "../_types/text-response";
|
||||
import {HttpClient} from "@angular/common/http";
|
||||
import {environment} from "../../environments/environment";
|
||||
@ -8,8 +8,9 @@ import {DashboardStream} from "../_models/dashboard/dashboard-stream";
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class DashboardService {
|
||||
private httpClient = inject(HttpClient);
|
||||
|
||||
baseUrl = environment.apiUrl;
|
||||
constructor(private httpClient: HttpClient) { }
|
||||
|
||||
getDashboardStreams(visibleOnly = true) {
|
||||
return this.httpClient.get<Array<DashboardStream>>(this.baseUrl + 'stream/dashboard?visibleOnly=' + visibleOnly);
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { ReplaySubject, shareReplay, tap } from 'rxjs';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { Device } from '../_models/device/device';
|
||||
@ -11,6 +11,9 @@ import { AccountService } from './account.service';
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class DeviceService {
|
||||
private httpClient = inject(HttpClient);
|
||||
private accountService = inject(AccountService);
|
||||
|
||||
|
||||
baseUrl = environment.apiUrl;
|
||||
|
||||
@ -18,7 +21,7 @@ export class DeviceService {
|
||||
public devices$ = this.devicesSource.asObservable().pipe(shareReplay());
|
||||
|
||||
|
||||
constructor(private httpClient: HttpClient, private accountService: AccountService) {
|
||||
constructor() {
|
||||
// Ensure we are authenticated before we make an authenticated api call.
|
||||
this.accountService.currentUser$.subscribe(user => {
|
||||
if (!user) {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import {environment} from "../../environments/environment";
|
||||
import {HttpClient} from "@angular/common/http";
|
||||
import {EmailHistory} from "../_models/email-history";
|
||||
@ -7,8 +7,9 @@ import {EmailHistory} from "../_models/email-history";
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class EmailService {
|
||||
private httpClient = inject(HttpClient);
|
||||
|
||||
baseUrl = environment.apiUrl;
|
||||
constructor(private httpClient: HttpClient) { }
|
||||
|
||||
getEmailHistory() {
|
||||
return this.httpClient.get<EmailHistory[]>(`${this.baseUrl}email/all`);
|
||||
|
||||
@ -44,6 +44,23 @@ export class EpubReaderMenuService {
|
||||
(ref.componentInstance as ViewEditAnnotationDrawerComponent).mode.set(AnnotationMode.Create);
|
||||
|
||||
this.isDrawerOpen.set(true);
|
||||
|
||||
// Set CSS variable for drawer height
|
||||
setTimeout(() => {
|
||||
var drawerElement = document.querySelector('view-edit-annotation-drawer, app-view-edit-annotation-drawer');
|
||||
if (!drawerElement) return;
|
||||
var setDrawerHeightVar = function() {
|
||||
if (!drawerElement) return;
|
||||
var height = (drawerElement as HTMLElement).offsetHeight;
|
||||
document.documentElement.style.setProperty('--drawer-height', height + 'px');
|
||||
};
|
||||
setDrawerHeightVar();
|
||||
var resizeObserver = new window.ResizeObserver(function() {
|
||||
setDrawerHeightVar();
|
||||
});
|
||||
resizeObserver.observe(drawerElement as HTMLElement);
|
||||
// Optionally store observer for cleanup if needed
|
||||
}, 0);
|
||||
}
|
||||
|
||||
|
||||
@ -156,6 +173,23 @@ export class EpubReaderMenuService {
|
||||
ref.dismissed.subscribe(() => this.setDrawerClosed());
|
||||
|
||||
this.isDrawerOpen.set(true);
|
||||
|
||||
// Set CSS variable for drawer height
|
||||
setTimeout(() => {
|
||||
var drawerElement = document.querySelector('view-edit-annotation-drawer, app-view-edit-annotation-drawer');
|
||||
if (!drawerElement) return;
|
||||
var setDrawerHeightVar = function() {
|
||||
if (!drawerElement) return;
|
||||
var height = (drawerElement as HTMLElement).offsetHeight;
|
||||
document.documentElement.style.setProperty('--drawer-height', height + 'px');
|
||||
};
|
||||
setDrawerHeightVar();
|
||||
var resizeObserver = new window.ResizeObserver(function() {
|
||||
setDrawerHeightVar();
|
||||
});
|
||||
resizeObserver.observe(drawerElement as HTMLElement);
|
||||
// Optionally store observer for cleanup if needed
|
||||
}, 0);
|
||||
}
|
||||
|
||||
closeAll() {
|
||||
|
||||
@ -9,7 +9,7 @@ import {ReadingProfile, ReadingProfileKind} from "../_models/preferences/reading
|
||||
import {BookService, FontFamily} from "../book-reader/_services/book.service";
|
||||
import {ThemeService} from './theme.service';
|
||||
import {ReadingProfileService} from "./reading-profile.service";
|
||||
import {debounceTime, distinctUntilChanged, filter, skip, tap} from "rxjs/operators";
|
||||
import {debounceTime, distinctUntilChanged, filter, tap} from "rxjs/operators";
|
||||
import {BookTheme} from "../_models/preferences/book-theme";
|
||||
import {DOCUMENT} from "@angular/common";
|
||||
import {translate} from "@jsverse/transloco";
|
||||
@ -335,6 +335,13 @@ export class EpubReaderSettingsService {
|
||||
? WritingStyle.Vertical
|
||||
: WritingStyle.Horizontal;
|
||||
|
||||
// Default back to Col 1 in this case
|
||||
if (newStyle === WritingStyle.Vertical ) {
|
||||
if (this._layoutMode() === BookPageLayoutMode.Column2) {
|
||||
this.updateLayoutMode(BookPageLayoutMode.Column1);
|
||||
}
|
||||
}
|
||||
|
||||
this._writingStyle.set(newStyle);
|
||||
this.settingsForm.get('bookReaderWritingStyle')!.setValue(newStyle);
|
||||
}
|
||||
@ -554,6 +561,14 @@ export class EpubReaderSettingsService {
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe((layoutMode: BookPageLayoutMode) => {
|
||||
this.isUpdatingFromForm = true;
|
||||
|
||||
if (this.writingStyle() === WritingStyle.Vertical && layoutMode === BookPageLayoutMode.Column2) {
|
||||
this.toastr.info(translate('book-reader.forced-vertical-switch'));
|
||||
this.isUpdatingFromForm = false;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
this._layoutMode.set(layoutMode);
|
||||
this.isUpdatingFromForm = false;
|
||||
});
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import {environment} from "../../environments/environment";
|
||||
import { HttpClient } from "@angular/common/http";
|
||||
import {ExternalSource} from "../_models/sidenav/external-source";
|
||||
@ -9,9 +9,10 @@ import {map} from "rxjs/operators";
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ExternalSourceService {
|
||||
private httpClient = inject(HttpClient);
|
||||
|
||||
|
||||
baseUrl = environment.apiUrl;
|
||||
constructor(private httpClient: HttpClient) { }
|
||||
|
||||
getExternalSources() {
|
||||
return this.httpClient.get<Array<ExternalSource>>(this.baseUrl + 'stream/external-sources');
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import {FilterV2} from "../_models/metadata/v2/filter-v2";
|
||||
import {environment} from "../../environments/environment";
|
||||
import {HttpClient} from "@angular/common/http";
|
||||
@ -8,9 +8,10 @@ import {SmartFilter} from "../_models/metadata/v2/smart-filter";
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class FilterService {
|
||||
private httpClient = inject(HttpClient);
|
||||
|
||||
|
||||
baseUrl = environment.apiUrl;
|
||||
constructor(private httpClient: HttpClient) { }
|
||||
|
||||
saveFilter(filter: FilterV2<number>) {
|
||||
return this.httpClient.post(this.baseUrl + 'filter/update', filter);
|
||||
|
||||
@ -9,6 +9,9 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ImageService {
|
||||
private accountService = inject(AccountService);
|
||||
private themeService = inject(ThemeService);
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
baseUrl = environment.apiUrl;
|
||||
apiKey: string = '';
|
||||
@ -20,7 +23,7 @@ export class ImageService {
|
||||
public nextChapterImage = 'assets/images/image-placeholder.dark-min.png';
|
||||
public noPersonImage = 'assets/images/error-person-missing.dark.min.png';
|
||||
|
||||
constructor(private accountService: AccountService, private themeService: ThemeService) {
|
||||
constructor() {
|
||||
this.themeService.currentTheme$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(theme => {
|
||||
if (this.themeService.isDarkTheme()) {
|
||||
this.placeholderImage = 'assets/images/image-placeholder.dark-min.png';
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import {DestroyRef, Injectable} from '@angular/core';
|
||||
import { DestroyRef, Injectable, inject } from '@angular/core';
|
||||
import {of} from 'rxjs';
|
||||
import {filter, map, tap} from 'rxjs/operators';
|
||||
import {environment} from 'src/environments/environment';
|
||||
@ -14,13 +14,17 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class LibraryService {
|
||||
private httpClient = inject(HttpClient);
|
||||
private readonly messageHub = inject(MessageHubService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
|
||||
baseUrl = environment.apiUrl;
|
||||
|
||||
private libraryNames: {[key:number]: string} | undefined = undefined;
|
||||
private libraryTypes: {[key: number]: LibraryType} | undefined = undefined;
|
||||
|
||||
constructor(private httpClient: HttpClient, private readonly messageHub: MessageHubService, private readonly destroyRef: DestroyRef) {
|
||||
constructor() {
|
||||
this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef), filter(e => e.event === EVENTS.LibraryModified),
|
||||
tap((e) => {
|
||||
console.log('LibraryModified event came in, clearing library name cache');
|
||||
|
||||
@ -9,6 +9,8 @@ import {TranslocoService} from "@jsverse/transloco";
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class LocalizationService {
|
||||
private httpClient = inject(HttpClient);
|
||||
|
||||
|
||||
private readonly translocoService = inject(TranslocoService);
|
||||
|
||||
@ -17,8 +19,6 @@ export class LocalizationService {
|
||||
private readonly localeSubject = new ReplaySubject<KavitaLocale[]>(1);
|
||||
public readonly locales$ = this.localeSubject.asObservable();
|
||||
|
||||
constructor(private httpClient: HttpClient) { }
|
||||
|
||||
getLocales() {
|
||||
return this.httpClient.get<KavitaLocale[]>(this.baseUrl + 'locale').pipe(tap(locales => {
|
||||
this.localeSubject.next(locales);
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { Member } from '../_models/auth/member';
|
||||
import {UserTokenInfo} from "../_models/kavitaplus/user-token-info";
|
||||
@ -8,11 +8,11 @@ import {UserTokenInfo} from "../_models/kavitaplus/user-token-info";
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class MemberService {
|
||||
private httpClient = inject(HttpClient);
|
||||
|
||||
|
||||
baseUrl = environment.apiUrl;
|
||||
|
||||
constructor(private httpClient: HttpClient) { }
|
||||
|
||||
getMembers(includePending: boolean = false) {
|
||||
return this.httpClient.get<Member[]>(this.baseUrl + 'users?includePending=' + includePending);
|
||||
}
|
||||
|
||||
@ -38,6 +38,8 @@ import {PersonSortField} from "../_models/metadata/v2/person-sort-field";
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class MetadataService {
|
||||
private httpClient = inject(HttpClient);
|
||||
|
||||
|
||||
private readonly translocoService = inject(TranslocoService);
|
||||
private readonly libraryService = inject(LibraryService);
|
||||
@ -47,11 +49,9 @@ export class MetadataService {
|
||||
baseUrl = environment.apiUrl;
|
||||
private validLanguages: Array<Language> = [];
|
||||
private ageRatingPipe = new AgeRatingPipe();
|
||||
private mangaFormatPipe = new MangaFormatPipe(this.translocoService);
|
||||
private mangaFormatPipe = new MangaFormatPipe();
|
||||
private personRolePipe = new PersonRolePipe();
|
||||
|
||||
constructor(private httpClient: HttpClient) { }
|
||||
|
||||
getSeriesMetadataFromPlus(seriesId: number, libraryType: LibraryType) {
|
||||
return this.httpClient.get<SeriesDetailPlus | null>(this.baseUrl + 'metadata/series-detail-plus?seriesId=' + seriesId + '&libraryType=' + libraryType);
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import {DOCUMENT} from '@angular/common';
|
||||
import {DestroyRef, inject, 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";
|
||||
@ -34,6 +34,9 @@ interface NavItem {
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class NavService {
|
||||
private document = inject<Document>(DOCUMENT);
|
||||
private httpClient = inject(HttpClient);
|
||||
|
||||
|
||||
private readonly accountService = inject(AccountService);
|
||||
private readonly router = inject(Router);
|
||||
@ -103,7 +106,9 @@ export class NavService {
|
||||
private renderer: Renderer2;
|
||||
baseUrl = environment.apiUrl;
|
||||
|
||||
constructor(@Inject(DOCUMENT) private document: Document, rendererFactory: RendererFactory2, private httpClient: HttpClient) {
|
||||
constructor() {
|
||||
const rendererFactory = inject(RendererFactory2);
|
||||
|
||||
this.renderer = rendererFactory.createRenderer(null, null);
|
||||
|
||||
// To avoid flashing, let's check if we are authenticated before we show
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import {HttpClient, HttpParams} from "@angular/common/http";
|
||||
import {environment} from "../../environments/environment";
|
||||
import {Person, PersonRole} from "../_models/metadata/person";
|
||||
@ -17,11 +17,12 @@ import {PersonSortField} from "../_models/metadata/v2/person-sort-field";
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class PersonService {
|
||||
private httpClient = inject(HttpClient);
|
||||
private utilityService = inject(UtilityService);
|
||||
|
||||
|
||||
baseUrl = environment.apiUrl;
|
||||
|
||||
constructor(private httpClient: HttpClient, private utilityService: UtilityService) { }
|
||||
|
||||
updatePerson(person: Person) {
|
||||
return this.httpClient.post<Person>(this.baseUrl + "person/update", person);
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import {HttpClient, HttpParams} from '@angular/common/http';
|
||||
import {Injectable} from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import {map} from 'rxjs/operators';
|
||||
import {environment} from 'src/environments/environment';
|
||||
import {UtilityService} from '../shared/_services/utility.service';
|
||||
@ -14,11 +14,12 @@ import {Action, ActionItem} from './action-factory.service';
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ReadingListService {
|
||||
private httpClient = inject(HttpClient);
|
||||
private utilityService = inject(UtilityService);
|
||||
|
||||
|
||||
baseUrl = environment.apiUrl;
|
||||
|
||||
constructor(private httpClient: HttpClient, private utilityService: UtilityService) { }
|
||||
|
||||
getReadingList(readingListId: number) {
|
||||
return this.httpClient.get<ReadingList | null>(this.baseUrl + 'readinglist?readingListId=' + readingListId);
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { map } from 'rxjs';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { UtilityService } from '../shared/_services/utility.service';
|
||||
@ -10,11 +10,12 @@ import { Series } from '../_models/series';
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class RecommendationService {
|
||||
private httpClient = inject(HttpClient);
|
||||
private utilityService = inject(UtilityService);
|
||||
|
||||
|
||||
private baseUrl = environment.apiUrl;
|
||||
|
||||
constructor(private httpClient: HttpClient, private utilityService: UtilityService) { }
|
||||
|
||||
getQuickReads(libraryId: number, pageNum?: number, itemsPerPage?: number) {
|
||||
let params = new HttpParams();
|
||||
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import {UserReview} from "../_single-module/review-card/user-review";
|
||||
import {environment} from "../../environments/environment";
|
||||
import {HttpClient} from "@angular/common/http";
|
||||
@ -8,11 +8,11 @@ import {Rating} from "../_models/rating";
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ReviewService {
|
||||
private httpClient = inject(HttpClient);
|
||||
|
||||
|
||||
private baseUrl = environment.apiUrl;
|
||||
|
||||
constructor(private httpClient: HttpClient) { }
|
||||
|
||||
deleteReview(seriesId: number, chapterId?: number) {
|
||||
if (chapterId) {
|
||||
return this.httpClient.delete(this.baseUrl + `review/chapter?chapterId=${chapterId}`);
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import {HttpClient, HttpParams} from '@angular/common/http';
|
||||
import {Injectable} from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import {map} from 'rxjs/operators';
|
||||
import {environment} from 'src/environments/environment';
|
||||
import {TextResonse} from '../_types/text-response';
|
||||
@ -22,12 +22,12 @@ export enum ScrobbleProvider {
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ScrobblingService {
|
||||
private httpClient = inject(HttpClient);
|
||||
private utilityService = inject(UtilityService);
|
||||
|
||||
|
||||
baseUrl = environment.apiUrl;
|
||||
|
||||
|
||||
constructor(private httpClient: HttpClient, private utilityService: UtilityService) {}
|
||||
|
||||
hasTokenExpired(provider: ScrobbleProvider) {
|
||||
return this.httpClient.get<string>(this.baseUrl + 'scrobbling/token-expired?provider=' + provider, TextResonse)
|
||||
.pipe(map(r => r === "true"));
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { of } from 'rxjs';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { SearchResultGroup } from '../_models/search/search-result-group';
|
||||
@ -9,11 +9,11 @@ import { Series } from '../_models/series';
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class SearchService {
|
||||
private httpClient = inject(HttpClient);
|
||||
|
||||
|
||||
baseUrl = environment.apiUrl;
|
||||
|
||||
constructor(private httpClient: HttpClient) { }
|
||||
|
||||
search(term: string, includeChapterAndFiles: boolean = false) {
|
||||
if (term === '') {
|
||||
return of(new SearchResultGroup());
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import {HttpClient, HttpParams} from '@angular/common/http';
|
||||
import {Injectable} from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import {Observable} from 'rxjs';
|
||||
import {map} from 'rxjs/operators';
|
||||
import {environment} from 'src/environments/environment';
|
||||
@ -26,13 +26,14 @@ import {FilterField} from "../_models/metadata/v2/filter-field";
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class SeriesService {
|
||||
private httpClient = inject(HttpClient);
|
||||
private utilityService = inject(UtilityService);
|
||||
|
||||
|
||||
baseUrl = environment.apiUrl;
|
||||
paginatedResults: PaginatedResult<Series[]> = new PaginatedResult<Series[]>();
|
||||
paginatedSeriesForTagsResults: PaginatedResult<Series[]> = new PaginatedResult<Series[]>();
|
||||
|
||||
constructor(private httpClient: HttpClient, private utilityService: UtilityService) { }
|
||||
|
||||
getAllSeriesV2(pageNum?: number, itemsPerPage?: number, filter?: FilterV2<FilterField>, context: QueryContext = QueryContext.None) {
|
||||
let params = new HttpParams();
|
||||
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import {ServerInfoSlim} from '../admin/_models/server-info';
|
||||
import { UpdateVersionEvent } from '../_models/events/update-version-event';
|
||||
@ -12,11 +12,11 @@ import {map} from "rxjs/operators";
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ServerService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
|
||||
baseUrl = environment.apiUrl;
|
||||
|
||||
constructor(private http: HttpClient) { }
|
||||
|
||||
getVersion(apiKey: string) {
|
||||
return this.http.get<string>(this.baseUrl + 'plugin/version?apiKey=' + apiKey, TextResonse);
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import {HttpClient, HttpParams} from '@angular/common/http';
|
||||
import {Inject, inject, Injectable} from '@angular/core';
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import {environment} from 'src/environments/environment';
|
||||
import {UserReadStatistics} from '../statistics/_models/user-read-statistics';
|
||||
import {PublicationStatusPipe} from '../_pipes/publication-status.pipe';
|
||||
@ -34,13 +34,14 @@ export enum DayOfWeek
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class StatisticsService {
|
||||
private httpClient = inject(HttpClient);
|
||||
private save = inject<Saver>(SAVER);
|
||||
|
||||
|
||||
baseUrl = environment.apiUrl;
|
||||
translocoService = inject(TranslocoService);
|
||||
publicationStatusPipe = new PublicationStatusPipe(this.translocoService);
|
||||
mangaFormatPipe = new MangaFormatPipe(this.translocoService);
|
||||
|
||||
constructor(private httpClient: HttpClient, @Inject(SAVER) private save: Saver) { }
|
||||
publicationStatusPipe = new PublicationStatusPipe();
|
||||
mangaFormatPipe = new MangaFormatPipe();
|
||||
|
||||
getUserStatistics(userId: number, libraryIds: Array<number> = []) {
|
||||
const url = `${this.baseUrl}stats/user/${userId}/read`;
|
||||
|
||||
@ -1,17 +1,9 @@
|
||||
import {DOCUMENT} from '@angular/common';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import {
|
||||
DestroyRef,
|
||||
inject,
|
||||
Inject,
|
||||
Injectable,
|
||||
Renderer2,
|
||||
RendererFactory2,
|
||||
SecurityContext
|
||||
} from '@angular/core';
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import {DestroyRef, effect, inject, Injectable, Renderer2, RendererFactory2, SecurityContext} from '@angular/core';
|
||||
import {DomSanitizer} from '@angular/platform-browser';
|
||||
import {ToastrService} from 'ngx-toastr';
|
||||
import {filter, map, ReplaySubject, take, tap} from 'rxjs';
|
||||
import {map, ReplaySubject, take, tap} from 'rxjs';
|
||||
import {environment} from 'src/environments/environment';
|
||||
import {ConfirmService} from '../shared/confirm.service';
|
||||
import {NotificationProgressEvent} from '../_models/events/notification-progress-event';
|
||||
@ -23,15 +15,21 @@ import {translate} from "@jsverse/transloco";
|
||||
import {DownloadableSiteTheme} from "../_models/theme/downloadable-site-theme";
|
||||
import {NgxFileDropEntry} from "ngx-file-drop";
|
||||
import {SiteThemeUpdatedEvent} from "../_models/events/site-theme-updated-event";
|
||||
import {NavigationEnd, Router} from "@angular/router";
|
||||
import {ColorscapeService} from "./colorscape.service";
|
||||
import {ColorScape} from "../_models/theme/colorscape";
|
||||
import {debounceTime} from "rxjs/operators";
|
||||
import {AccountService} from "./account.service";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ThemeService {
|
||||
private document = inject<Document>(DOCUMENT);
|
||||
private httpClient = inject(HttpClient);
|
||||
private domSanitizer = inject(DomSanitizer);
|
||||
private confirmService = inject(ConfirmService);
|
||||
private toastr = inject(ToastrService);
|
||||
private accountService = inject(AccountService);
|
||||
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly colorTransitionService = inject(ColorscapeService);
|
||||
@ -44,7 +42,7 @@ export class ThemeService {
|
||||
|
||||
private themesSource = new ReplaySubject<SiteTheme[]>(1);
|
||||
public themes$ = this.themesSource.asObservable();
|
||||
|
||||
|
||||
private darkModeSource = new ReplaySubject<boolean>(1);
|
||||
public isDarkMode$ = this.darkModeSource.asObservable();
|
||||
|
||||
@ -57,9 +55,10 @@ export class ThemeService {
|
||||
private baseUrl = environment.apiUrl;
|
||||
|
||||
|
||||
constructor(rendererFactory: RendererFactory2, @Inject(DOCUMENT) private document: Document, private httpClient: HttpClient,
|
||||
messageHub: MessageHubService, private domSanitizer: DomSanitizer, private confirmService: ConfirmService, private toastr: ToastrService,
|
||||
private router: Router) {
|
||||
constructor() {
|
||||
const rendererFactory = inject(RendererFactory2);
|
||||
const messageHub = inject(MessageHubService);
|
||||
|
||||
this.renderer = rendererFactory.createRenderer(null, null);
|
||||
|
||||
messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(message => {
|
||||
@ -83,8 +82,15 @@ export class ThemeService {
|
||||
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
effect(() => {
|
||||
const user = this.accountService.currentUserSignal();
|
||||
if (user?.preferences && user?.preferences.theme) {
|
||||
this.setTheme(user.preferences.theme.name);
|
||||
} else {
|
||||
this.setTheme(this.defaultTheme);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import {NavigationStart, Router} from '@angular/router';
|
||||
import {filter, ReplaySubject, take} from 'rxjs';
|
||||
|
||||
@ -13,7 +13,9 @@ export class ToggleService {
|
||||
private toggleStateSource: ReplaySubject<boolean> = new ReplaySubject<boolean>(1);
|
||||
public toggleState$ = this.toggleStateSource.asObservable();
|
||||
|
||||
constructor(router: Router) {
|
||||
constructor() {
|
||||
const router = inject(Router);
|
||||
|
||||
router.events
|
||||
.pipe(filter(event => event instanceof NavigationStart))
|
||||
.subscribe((event) => {
|
||||
|
||||
@ -10,12 +10,12 @@ import {tap} from "rxjs";
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class UploadService {
|
||||
private httpClient = inject(HttpClient);
|
||||
|
||||
|
||||
private baseUrl = environment.apiUrl;
|
||||
private readonly toastr = inject(ToastrService);
|
||||
|
||||
constructor(private httpClient: HttpClient) { }
|
||||
|
||||
|
||||
uploadByUrl(url: string) {
|
||||
return this.httpClient.post<string>(this.baseUrl + 'upload/upload-by-url', {url}, TextResonse);
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import {environment} from "../../environments/environment";
|
||||
import { HttpClient } from "@angular/common/http";
|
||||
import {Volume} from "../_models/volume";
|
||||
@ -8,11 +8,11 @@ import {TextResonse} from "../_types/text-response";
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class VolumeService {
|
||||
private httpClient = inject(HttpClient);
|
||||
|
||||
|
||||
baseUrl = environment.apiUrl;
|
||||
|
||||
constructor(private httpClient: HttpClient) { }
|
||||
|
||||
getVolumeMetadata(volumeId: number) {
|
||||
return this.httpClient.get<Volume>(this.baseUrl + 'volume?volumeId=' + volumeId);
|
||||
}
|
||||
|
||||
@ -1 +1 @@
|
||||
<app-image [imageUrl]="imageUrl" height="32px" width="32px" classes="clickable" ngbTooltip="{{rating | ageRating}}" (click)="openRating()"></app-image>
|
||||
<app-image [imageUrl]="imageUrl" height="32px" width="32px" classes="clickable" ngbTooltip="{{rating | ageRating}}" (click)="openRating()" />
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
<div class="mb-3" *transloco="let t;prefix:'annotations-tab'">
|
||||
<app-carousel-reel [items]="annotations()" [alwaysShow]="false">
|
||||
<ng-template #carouselItem let-item let-position="idx">
|
||||
<div style="min-width: 200px">
|
||||
<app-annotation-card [annotation]="item" [allowEdit]="false" [showPageLink]="false" [isInReader]="false" [showInReaderLink]="true"></app-annotation-card>
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
<virtual-scroller #scroll [items]="annotations()" [parentScroll]="scrollingBlock()" [childHeight]="1">
|
||||
<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" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</virtual-scroller>
|
||||
</div>
|
||||
|
||||
@ -1,23 +1,26 @@
|
||||
import {Component, input} from '@angular/core';
|
||||
import {CarouselReelComponent} from "../../carousel/_components/carousel-reel/carousel-reel.component";
|
||||
import {ChangeDetectionStrategy, Component, input} from '@angular/core';
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {Annotation} from "../../book-reader/_models/annotations/annotation";
|
||||
import {
|
||||
AnnotationCardComponent
|
||||
} from "../../book-reader/_components/_annotations/annotation-card/annotation-card.component";
|
||||
import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller";
|
||||
|
||||
@Component({
|
||||
selector: 'app-annotations-tab',
|
||||
imports: [
|
||||
CarouselReelComponent,
|
||||
TranslocoDirective,
|
||||
AnnotationCardComponent
|
||||
AnnotationCardComponent,
|
||||
VirtualScrollerModule
|
||||
],
|
||||
templateUrl: './annotations-tab.component.html',
|
||||
styleUrl: './annotations-tab.component.scss'
|
||||
styleUrl: './annotations-tab.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class AnnotationsTabComponent {
|
||||
|
||||
annotations = input.required<Annotation[]>();
|
||||
scrollingBlock = input.required<HTMLDivElement>();
|
||||
displaySeries = input<boolean>(false);
|
||||
|
||||
}
|
||||
|
||||
@ -1,20 +1,20 @@
|
||||
<ng-container *transloco="let t; prefix: 'actionable'">
|
||||
@if (actions.length > 0) {
|
||||
@if ((utilityService.activeBreakpoint$ | async)! <= Breakpoint.Tablet) {
|
||||
<button [disabled]="disabled" class="btn {{btnClass}} px-3" id="actions-{{labelBy}}"
|
||||
<button [disabled]="disabled" class="btn {{btnClass}}" id="actions-{{labelBy}}"
|
||||
(click)="openMobileActionableMenu($event)">
|
||||
{{label}}
|
||||
<i class="fa {{iconClass}}" aria-hidden="true"></i>
|
||||
</button>
|
||||
} @else {
|
||||
<div ngbDropdown container="body" class="d-inline-block">
|
||||
<button [disabled]="disabled" class="btn {{btnClass}} px-3" id="actions-{{labelBy}}" ngbDropdownToggle
|
||||
<button [disabled]="disabled" class="btn {{btnClass}}" id="actions-{{labelBy}}" ngbDropdownToggle
|
||||
(click)="preventEvent($event)">
|
||||
{{label}}
|
||||
<i class="fa {{iconClass}}" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div ngbDropdownMenu attr.aria-labelledby="actions-{{labelBy}}">
|
||||
<ng-container *ngTemplateOutlet="submenu; context: { list: actions }"></ng-container>
|
||||
<ng-container *ngTemplateOutlet="submenu; context: { list: actions }" />
|
||||
</div>
|
||||
</div>
|
||||
<ng-template #submenu let-list="list">
|
||||
@ -45,7 +45,7 @@
|
||||
}
|
||||
|
||||
<div ngbDropdownMenu attr.aria-labelledby="actions-{{action.title}}">
|
||||
<ng-container *ngTemplateOutlet="submenu; context: { list: action.children }"></ng-container>
|
||||
<ng-container *ngTemplateOutlet="submenu; context: { list: action.children }" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
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