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:
Joe Milazzo 2025-09-20 15:16:21 -05:00 committed by GitHub
parent 9891df898f
commit 26ff71f42b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
339 changed files with 6923 additions and 1971 deletions

View File

@ -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);
}
}

View File

@ -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);

View File

@ -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()

View File

@ -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;

View File

@ -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>

View File

@ -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>

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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");
}
}

View File

@ -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 => "◑",
_ => "◔"
};
}
}

View File

@ -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

View File

@ -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));
}

View File

@ -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);
}

View File

@ -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())

View File

@ -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()
{

View File

@ -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; }

View 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 };
}

View File

@ -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; }

View File

@ -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)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class 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");
}
}
}

View File

@ -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");

View File

@ -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

View File

@ -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>

View File

@ -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);

View File

@ -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)

View File

@ -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)
{

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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));
}
}

View File

@ -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;
}
}
}

View File

@ -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>();
}

View File

@ -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

View File

@ -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"
}

View File

@ -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)

View File

@ -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();

View File

@ -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);

View File

@ -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);

View File

@ -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))

View File

@ -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

View File

@ -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;

View File

@ -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,

View File

@ -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
[![Discord](https://img.shields.io/badge/discord-chat-7289DA.svg?maxAge=60)](https://discord.gg/eczRp9eeem)

View File

@ -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"
}
}
},

View File

@ -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
View 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
}
}

View File

@ -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) => {

View File

@ -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);

View File

@ -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(

View File

@ -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();

View File

@ -14,6 +14,7 @@ export interface Preferences {
shareReviews: boolean;
locale: string;
bookReaderHighlightSlots: HighlightSlot[];
colorScapeEnabled: boolean;
// Kavita+
aniListScrobblingEnabled: boolean;

View File

@ -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);

View File

@ -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());

View 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');
}
}
}

View File

@ -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) {

View File

@ -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) {

View File

@ -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

View File

@ -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) {

View File

@ -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 {

View File

@ -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')) {

View File

@ -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

View File

@ -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);

View File

@ -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;

View File

@ -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();
});

View File

@ -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(

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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);

View File

@ -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) {

View File

@ -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`);

View File

@ -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() {

View File

@ -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;
});

View File

@ -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');

View File

@ -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);

View File

@ -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';

View File

@ -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');

View File

@ -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);

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);

View File

@ -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}`);

View File

@ -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"));

View File

@ -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());

View File

@ -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);

View File

@ -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);
}

View File

@ -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`;

View File

@ -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);
}
});
}

View File

@ -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) => {

View File

@ -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);

View File

@ -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);
}

View File

@ -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()" />

View File

@ -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>

View File

@ -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);
}

View File

@ -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