mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-31 14:33:50 -04:00
Continuous Reading for Webtoons & I Just Couldn't Stop Coding (#574)
* Fixed an issue from perf tuning where I forgot to send Pages to frontend, breaking reader. * Built out continuous reading for webtoon reader. Still has some issues with triggering. * Refactored GetUserByUsernameAsync to have a new flavor and allow the caller to pass in bitwise flags for what to include. This has a get by username or id variant. Code is much cleaner and snappier as we avoid many extra joins when not needed. * Cleanup old code from UserRepository.cs * Refactored OPDS to use faster API lookups for User * Refactored more code to be cleaner and faster. * Refactored GetNext/Prev ChapterIds to ReaderService. * Refactored Repository methods to their correct entity repos. * Refactored DTOs and overall cleanup of the code. * Added ability to press 'b' to bookmark a page * On hitting last page, save progress forcing last page to be read. Adjusted logic for the top and bottom spacers for triggering next/prev chapter load * When at top or moving between chapters, scrolling down then up will now trigger page load. Show a toastr to inform the user of a change in chapter (it can be really fast to switch) * Cleaned up scroll code * Fixed an issue where loading a chapter with last page bookmarked, we'd load lastpage - 1 * Fixed last page of webtoon reader not being resumed on loading said chapter due to a difference in how max page is handled between infinite scroller and manga reader. * Removed some comments * Book reader shouldn't look at left/right tap to paginate elems for position bookmarking. Missed a few areas for saving while in incognito mode * Added a benchmark to test out a sort code * Updated the read status on reading list to use same style as other places * Refactored GetNextChapterId to bring the average response time from 1.2 seconds to 400ms. * Added a filter to add to list when there are more than 5 reading lists * Added download reading list (will be removed, just saving for later). Fixes around styling on reading lists * Removed ability to download reading lists * Tweaked the logic for infinite scroller to be much smoother loading next/prev chapter. Added a bug marker for a concurrency bug. * Updated the top spacer so that when you hit the top, you stay at the page height and can now just scroll up. * Got the logic for scrolling up. Now just need the CSS then cont infinite scroller will be working * More polishing on infinite scroller * Removed IsSpecial on volumeDto, which is not used anywhere. * Cont Reading inf scroller edition is done. * Code smells and fixed package.json explore script
This commit is contained in:
parent
38c313adc7
commit
83f8e25478
@ -12,7 +12,8 @@ namespace API.Benchmark
|
||||
{
|
||||
static void Main(string[] args)
|
||||
{
|
||||
BenchmarkRunner.Run<ParseScannedFilesBenchmarks>();
|
||||
//BenchmarkRunner.Run<ParseScannedFilesBenchmarks>();
|
||||
BenchmarkRunner.Run<TestBenchmark>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
69
API.Benchmark/TestBenchmark.cs
Normal file
69
API.Benchmark/TestBenchmark.cs
Normal file
@ -0,0 +1,69 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using API.Comparators;
|
||||
using API.DTOs;
|
||||
using BenchmarkDotNet.Attributes;
|
||||
using BenchmarkDotNet.Order;
|
||||
|
||||
namespace API.Benchmark
|
||||
{
|
||||
/// <summary>
|
||||
/// This is used as a scratchpad for testing
|
||||
/// </summary>
|
||||
[MemoryDiagnoser]
|
||||
[Orderer(SummaryOrderPolicy.FastestToSlowest)]
|
||||
[RankColumn]
|
||||
public class TestBenchmark
|
||||
{
|
||||
private readonly NaturalSortComparer _naturalSortComparer = new ();
|
||||
|
||||
|
||||
private static IEnumerable<VolumeDto> GenerateVolumes(int max)
|
||||
{
|
||||
var random = new Random();
|
||||
var maxIterations = random.Next(max) + 1;
|
||||
var list = new List<VolumeDto>();
|
||||
for (var i = 0; i < maxIterations; i++)
|
||||
{
|
||||
list.Add(new VolumeDto()
|
||||
{
|
||||
Number = random.Next(10) > 5 ? 1 : 0,
|
||||
Chapters = GenerateChapters()
|
||||
});
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private static List<ChapterDto> GenerateChapters()
|
||||
{
|
||||
var list = new List<ChapterDto>();
|
||||
for (var i = 1; i < 40; i++)
|
||||
{
|
||||
list.Add(new ChapterDto()
|
||||
{
|
||||
Range = i + string.Empty
|
||||
});
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private void SortSpecialChapters(IEnumerable<VolumeDto> volumes)
|
||||
{
|
||||
foreach (var v in volumes.Where(vDto => vDto.Number == 0))
|
||||
{
|
||||
v.Chapters = v.Chapters.OrderBy(x => x.Range, _naturalSortComparer).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
[Benchmark]
|
||||
public void TestSortSpecialChapters()
|
||||
{
|
||||
var volumes = GenerateVolumes(10);
|
||||
SortSpecialChapters(volumes);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
@ -2,8 +2,17 @@
|
||||
|
||||
namespace API.Comparators
|
||||
{
|
||||
/// <summary>
|
||||
/// Sorts chapters based on their Number. Uses natural ordering of doubles.
|
||||
/// </summary>
|
||||
public class ChapterSortComparer : IComparer<double>
|
||||
{
|
||||
/// <summary>
|
||||
/// Normal sort for 2 doubles. 0 always comes before anything else
|
||||
/// </summary>
|
||||
/// <param name="x"></param>
|
||||
/// <param name="y"></param>
|
||||
/// <returns></returns>
|
||||
public int Compare(double x, double y)
|
||||
{
|
||||
if (x == 0.0 && y == 0.0) return 0;
|
||||
|
@ -23,9 +23,9 @@ namespace API.Comparators
|
||||
{
|
||||
if (x == y) return 0;
|
||||
|
||||
// BUG: Operations that change non-concurrent collections must have exclusive access. A concurrent update was performed on this collection and corrupted its state. The collection's state is no longer correct.
|
||||
if (!_table.TryGetValue(x ?? Empty, out var x1))
|
||||
{
|
||||
// .Replace(" ", Empty)
|
||||
x1 = Regex.Split(x ?? Empty, "([0-9]+)");
|
||||
_table.Add(x ?? Empty, x1);
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
using API.Constants;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Account;
|
||||
using API.Entities;
|
||||
using API.Errors;
|
||||
using API.Extensions;
|
||||
|
@ -39,7 +39,7 @@ namespace API.Controllers
|
||||
var bookTitle = string.Empty;
|
||||
if (dto.SeriesFormat == MangaFormat.Epub)
|
||||
{
|
||||
var mangaFile = (await _unitOfWork.VolumeRepository.GetFilesForChapterAsync(chapterId)).First();
|
||||
var mangaFile = (await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId)).First();
|
||||
using var book = await EpubReader.OpenBookAsync(mangaFile.FilePath);
|
||||
bookTitle = book.Title;
|
||||
}
|
||||
@ -62,7 +62,7 @@ namespace API.Controllers
|
||||
[HttpGet("{chapterId}/book-resources")]
|
||||
public async Task<ActionResult> GetBookPageResources(int chapterId, [FromQuery] string file)
|
||||
{
|
||||
var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(chapterId);
|
||||
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId);
|
||||
var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath);
|
||||
|
||||
var key = BookService.CleanContentKeys(file);
|
||||
@ -81,7 +81,7 @@ namespace API.Controllers
|
||||
{
|
||||
// This will return a list of mappings from ID -> pagenum. ID will be the xhtml key and pagenum will be the reading order
|
||||
// this is used to rewrite anchors in the book text so that we always load properly in FE
|
||||
var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(chapterId);
|
||||
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId);
|
||||
using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath);
|
||||
var mappings = await _bookService.CreateKeyToPageMappingAsync(book);
|
||||
|
||||
|
@ -48,7 +48,7 @@ namespace API.Controllers
|
||||
[HttpGet("chapter-size")]
|
||||
public async Task<ActionResult<long>> GetChapterSize(int chapterId)
|
||||
{
|
||||
var files = await _unitOfWork.VolumeRepository.GetFilesForChapterAsync(chapterId);
|
||||
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId);
|
||||
return Ok(DirectoryService.GetTotalSize(files.Select(c => c.FilePath)));
|
||||
}
|
||||
|
||||
@ -90,8 +90,8 @@ namespace API.Controllers
|
||||
[HttpGet("chapter")]
|
||||
public async Task<ActionResult> DownloadChapter(int chapterId)
|
||||
{
|
||||
var files = await _unitOfWork.VolumeRepository.GetFilesForChapterAsync(chapterId);
|
||||
var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(chapterId);
|
||||
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId);
|
||||
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId);
|
||||
var volume = await _unitOfWork.SeriesRepository.GetVolumeByIdAsync(chapter.VolumeId);
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId);
|
||||
try
|
||||
@ -154,7 +154,7 @@ namespace API.Controllers
|
||||
var chapterExtractPath = Path.Join(fullExtractPath, $"{series.Id}_bookmark_{chapterId}");
|
||||
var chapterPages = downloadBookmarkDto.Bookmarks.Where(b => b.ChapterId == chapterId)
|
||||
.Select(b => b.Page).ToList();
|
||||
var mangaFiles = await _unitOfWork.VolumeRepository.GetFilesForChapterAsync(chapterId);
|
||||
var mangaFiles = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId);
|
||||
switch (series.Format)
|
||||
{
|
||||
case MangaFormat.Image:
|
||||
|
@ -27,7 +27,7 @@ namespace API.Controllers
|
||||
[HttpGet("chapter-cover")]
|
||||
public async Task<ActionResult> GetChapterCoverImage(int chapterId)
|
||||
{
|
||||
var content = await _unitOfWork.VolumeRepository.GetChapterCoverImageAsync(chapterId);
|
||||
var content = await _unitOfWork.ChapterRepository.GetChapterCoverImageAsync(chapterId);
|
||||
if (content == null) return BadRequest("No cover image");
|
||||
|
||||
Response.AddCacheHeader(content);
|
||||
@ -42,7 +42,7 @@ namespace API.Controllers
|
||||
[HttpGet("volume-cover")]
|
||||
public async Task<ActionResult> GetVolumeCoverImage(int volumeId)
|
||||
{
|
||||
var content = await _unitOfWork.SeriesRepository.GetVolumeCoverImageAsync(volumeId);
|
||||
var content = await _unitOfWork.VolumeRepository.GetVolumeCoverImageAsync(volumeId);
|
||||
if (content == null) return BadRequest("No cover image");
|
||||
|
||||
Response.AddCacheHeader(content);
|
||||
|
@ -141,8 +141,8 @@ namespace API.Controllers
|
||||
{
|
||||
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
||||
return BadRequest("OPDS is not enabled on this server");
|
||||
var user = await GetUser(apiKey);
|
||||
var libraries = await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id);
|
||||
var userId = await GetUser(apiKey);
|
||||
var libraries = await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId);
|
||||
|
||||
var feed = CreateFeed("All Libraries", $"{apiKey}/libraries", apiKey);
|
||||
|
||||
@ -168,7 +168,8 @@ namespace API.Controllers
|
||||
{
|
||||
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
||||
return BadRequest("OPDS is not enabled on this server");
|
||||
var user = await GetUser(apiKey);
|
||||
var userId = await GetUser(apiKey);
|
||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
|
||||
var isAdmin = await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole);
|
||||
|
||||
IEnumerable <CollectionTagDto> tags;
|
||||
@ -210,7 +211,8 @@ namespace API.Controllers
|
||||
{
|
||||
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
||||
return BadRequest("OPDS is not enabled on this server");
|
||||
var user = await GetUser(apiKey);
|
||||
var userId = await GetUser(apiKey);
|
||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
|
||||
var isAdmin = await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole);
|
||||
|
||||
IEnumerable <CollectionTagDto> tags;
|
||||
@ -229,7 +231,7 @@ namespace API.Controllers
|
||||
return BadRequest("Collection does not exist or you don't have access");
|
||||
}
|
||||
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForCollectionAsync(collectionId, user.Id, new UserParams()
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForCollectionAsync(collectionId, userId, new UserParams()
|
||||
{
|
||||
PageNumber = pageNumber,
|
||||
PageSize = 20
|
||||
@ -253,9 +255,9 @@ namespace API.Controllers
|
||||
{
|
||||
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
||||
return BadRequest("OPDS is not enabled on this server");
|
||||
var user = await GetUser(apiKey);
|
||||
var userId = await GetUser(apiKey);
|
||||
|
||||
var readingLists = await _unitOfWork.ReadingListRepository.GetReadingListDtosForUserAsync(user.Id, true, new UserParams()
|
||||
var readingLists = await _unitOfWork.ReadingListRepository.GetReadingListDtosForUserAsync(userId, true, new UserParams()
|
||||
{
|
||||
PageNumber = pageNumber
|
||||
});
|
||||
@ -286,7 +288,8 @@ namespace API.Controllers
|
||||
{
|
||||
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
||||
return BadRequest("OPDS is not enabled on this server");
|
||||
var user = await GetUser(apiKey);
|
||||
var userId = await GetUser(apiKey);
|
||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
|
||||
|
||||
var userWithLists = await _unitOfWork.UserRepository.GetUserWithReadingListsByUsernameAsync(user.UserName);
|
||||
var readingList = userWithLists.ReadingLists.SingleOrDefault(t => t.Id == readingListId);
|
||||
@ -297,7 +300,7 @@ namespace API.Controllers
|
||||
|
||||
var feed = CreateFeed(readingList.Title + " Reading List", $"{apiKey}/reading-list/{readingListId}", apiKey);
|
||||
|
||||
var items = await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, user.Id);
|
||||
var items = await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId);
|
||||
foreach (var item in items)
|
||||
{
|
||||
feed.Entries.Add(new FeedEntry()
|
||||
@ -323,16 +326,16 @@ namespace API.Controllers
|
||||
{
|
||||
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
||||
return BadRequest("OPDS is not enabled on this server");
|
||||
var user = await GetUser(apiKey);
|
||||
var userId = await GetUser(apiKey);
|
||||
var library =
|
||||
(await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).SingleOrDefault(l =>
|
||||
(await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId)).SingleOrDefault(l =>
|
||||
l.Id == libraryId);
|
||||
if (library == null)
|
||||
{
|
||||
return BadRequest("User does not have access to this library");
|
||||
}
|
||||
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, user.Id, new UserParams()
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, userId, new UserParams()
|
||||
{
|
||||
PageNumber = pageNumber,
|
||||
PageSize = 20
|
||||
@ -355,8 +358,8 @@ namespace API.Controllers
|
||||
{
|
||||
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
||||
return BadRequest("OPDS is not enabled on this server");
|
||||
var user = await GetUser(apiKey);
|
||||
var recentlyAdded = await _unitOfWork.SeriesRepository.GetRecentlyAdded(0, user.Id, new UserParams()
|
||||
var userId = await GetUser(apiKey);
|
||||
var recentlyAdded = await _unitOfWork.SeriesRepository.GetRecentlyAdded(0, userId, new UserParams()
|
||||
{
|
||||
PageNumber = pageNumber,
|
||||
PageSize = 20
|
||||
@ -380,13 +383,13 @@ namespace API.Controllers
|
||||
{
|
||||
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
||||
return BadRequest("OPDS is not enabled on this server");
|
||||
var user = await GetUser(apiKey);
|
||||
var userId = await GetUser(apiKey);
|
||||
var userParams = new UserParams()
|
||||
{
|
||||
PageNumber = pageNumber,
|
||||
PageSize = 20
|
||||
};
|
||||
var results = await _unitOfWork.SeriesRepository.GetInProgress(user.Id, 0, userParams, _filterDto);
|
||||
var results = await _unitOfWork.SeriesRepository.GetInProgress(userId, 0, userParams, _filterDto);
|
||||
var listResults = results.DistinctBy(s => s.Name).Skip((userParams.PageNumber - 1) * userParams.PageSize)
|
||||
.Take(userParams.PageSize).ToList();
|
||||
var pagedList = new PagedList<SeriesDto>(listResults, listResults.Count, userParams.PageNumber, userParams.PageSize);
|
||||
@ -410,14 +413,14 @@ namespace API.Controllers
|
||||
{
|
||||
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
||||
return BadRequest("OPDS is not enabled on this server");
|
||||
var user = await GetUser(apiKey);
|
||||
var userId = await GetUser(apiKey);
|
||||
if (string.IsNullOrEmpty(query))
|
||||
{
|
||||
return BadRequest("You must pass a query parameter");
|
||||
}
|
||||
query = query.Replace(@"%", "");
|
||||
// Get libraries user has access to
|
||||
var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).ToList();
|
||||
var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId)).ToList();
|
||||
|
||||
if (!libraries.Any()) return BadRequest("User does not have access to any libraries");
|
||||
|
||||
@ -462,9 +465,9 @@ namespace API.Controllers
|
||||
{
|
||||
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
||||
return BadRequest("OPDS is not enabled on this server");
|
||||
var user = await GetUser(apiKey);
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, user.Id);
|
||||
var volumes = await _unitOfWork.SeriesRepository.GetVolumesDtoAsync(seriesId, user.Id);
|
||||
var userId = await GetUser(apiKey);
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
|
||||
var volumes = await _unitOfWork.SeriesRepository.GetVolumesDtoAsync(seriesId, userId);
|
||||
var feed = CreateFeed(series.Name + " - Volumes", $"{apiKey}/series/{series.Id}", apiKey);
|
||||
feed.Links.Add(CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/series-cover?seriesId={seriesId}"));
|
||||
foreach (var volumeDto in volumes)
|
||||
@ -481,11 +484,11 @@ namespace API.Controllers
|
||||
{
|
||||
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
||||
return BadRequest("OPDS is not enabled on this server");
|
||||
var user = await GetUser(apiKey);
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, user.Id);
|
||||
var userId = await GetUser(apiKey);
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
|
||||
var volume = await _unitOfWork.SeriesRepository.GetVolumeAsync(volumeId);
|
||||
var chapters =
|
||||
(await _unitOfWork.VolumeRepository.GetChaptersAsync(volumeId)).OrderBy(x => double.Parse(x.Number),
|
||||
(await _unitOfWork.ChapterRepository.GetChaptersAsync(volumeId)).OrderBy(x => double.Parse(x.Number),
|
||||
_chapterSortComparer);
|
||||
|
||||
var feed = CreateFeed(series.Name + " - Volume " + volume.Name + " - Chapters ", $"{apiKey}/series/{seriesId}/volume/{volumeId}", apiKey);
|
||||
@ -512,11 +515,11 @@ namespace API.Controllers
|
||||
{
|
||||
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
||||
return BadRequest("OPDS is not enabled on this server");
|
||||
var user = await GetUser(apiKey);
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, user.Id);
|
||||
var userId = await GetUser(apiKey);
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
|
||||
var volume = await _unitOfWork.SeriesRepository.GetVolumeAsync(volumeId);
|
||||
var chapter = await _unitOfWork.VolumeRepository.GetChapterDtoAsync(chapterId);
|
||||
var files = await _unitOfWork.VolumeRepository.GetFilesForChapterAsync(chapterId);
|
||||
var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId);
|
||||
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId);
|
||||
|
||||
var feed = CreateFeed(series.Name + " - Volume " + volume.Name + " - Chapters ", $"{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}", apiKey);
|
||||
foreach (var mangaFile in files)
|
||||
@ -540,7 +543,7 @@ namespace API.Controllers
|
||||
{
|
||||
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
||||
return BadRequest("OPDS is not enabled on this server");
|
||||
var files = await _unitOfWork.VolumeRepository.GetFilesForChapterAsync(chapterId);
|
||||
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId);
|
||||
var (bytes, contentType, fileDownloadName) = await _downloadService.GetFirstFileDownload(files);
|
||||
return File(bytes, contentType, fileDownloadName);
|
||||
}
|
||||
@ -628,7 +631,7 @@ namespace API.Controllers
|
||||
return new FeedEntry()
|
||||
{
|
||||
Id = volumeDto.Id.ToString(),
|
||||
Title = volumeDto.IsSpecial ? "Specials" : "Volume " + volumeDto.Name,
|
||||
Title = "Volume " + volumeDto.Name,
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/series/{seriesId}/volume/{volumeDto.Id}"),
|
||||
@ -723,16 +726,19 @@ namespace API.Controllers
|
||||
/// Gets the user from the API key
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
private async Task<AppUser> GetUser(string apiKey)
|
||||
private async Task<int> GetUser(string apiKey)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByApiKeyAsync(apiKey);
|
||||
if (user == null)
|
||||
try
|
||||
{
|
||||
throw new KavitaException("User does not exist");
|
||||
}
|
||||
|
||||
var user = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
|
||||
return user;
|
||||
}
|
||||
catch
|
||||
{
|
||||
/* Do nothing */
|
||||
}
|
||||
throw new KavitaException("User does not exist");
|
||||
}
|
||||
|
||||
private static FeedLink CreatePageStreamLink(int seriesId, int volumeId, int chapterId, MangaFile mangaFile, string apiKey)
|
||||
{
|
||||
|
@ -4,6 +4,7 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Comparators;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Reader;
|
||||
using API.Entities;
|
||||
@ -25,9 +26,6 @@ namespace API.Controllers
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILogger<ReaderController> _logger;
|
||||
private readonly IReaderService _readerService;
|
||||
private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer();
|
||||
private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst();
|
||||
private readonly NaturalSortComparer _naturalSortComparer = new NaturalSortComparer();
|
||||
|
||||
/// <inheritdoc />
|
||||
public ReaderController(IDirectoryService directoryService, ICacheService cacheService,
|
||||
@ -85,7 +83,7 @@ namespace API.Controllers
|
||||
if (chapter == null) return BadRequest("Could not find Chapter");
|
||||
|
||||
var dto = await _unitOfWork.ChapterRepository.GetChapterInfoDtoAsync(chapterId);
|
||||
var mangaFile = (await _unitOfWork.VolumeRepository.GetFilesForChapterAsync(chapterId)).First();
|
||||
var mangaFile = (await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId)).First();
|
||||
|
||||
return Ok(new ChapterInfoDto()
|
||||
{
|
||||
@ -106,7 +104,7 @@ namespace API.Controllers
|
||||
[HttpPost("mark-read")]
|
||||
public async Task<ActionResult> MarkRead(MarkReadDto markReadDto)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress);
|
||||
var volumes = await _unitOfWork.SeriesRepository.GetVolumes(markReadDto.SeriesId);
|
||||
user.Progresses ??= new List<AppUserProgress>();
|
||||
foreach (var volume in volumes)
|
||||
@ -178,7 +176,7 @@ namespace API.Controllers
|
||||
[HttpPost("mark-unread")]
|
||||
public async Task<ActionResult> MarkUnread(MarkReadDto markReadDto)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress);
|
||||
var volumes = await _unitOfWork.SeriesRepository.GetVolumes(markReadDto.SeriesId);
|
||||
user.Progresses ??= new List<AppUserProgress>();
|
||||
foreach (var volume in volumes)
|
||||
@ -213,9 +211,9 @@ namespace API.Controllers
|
||||
[HttpPost("mark-volume-unread")]
|
||||
public async Task<ActionResult> MarkVolumeAsUnread(MarkVolumeReadDto markVolumeReadDto)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress);
|
||||
|
||||
var chapters = await _unitOfWork.VolumeRepository.GetChaptersAsync(markVolumeReadDto.VolumeId);
|
||||
var chapters = await _unitOfWork.ChapterRepository.GetChaptersAsync(markVolumeReadDto.VolumeId);
|
||||
foreach (var chapter in chapters)
|
||||
{
|
||||
user.Progresses ??= new List<AppUserProgress>();
|
||||
@ -257,9 +255,9 @@ namespace API.Controllers
|
||||
[HttpPost("mark-volume-read")]
|
||||
public async Task<ActionResult> MarkVolumeAsRead(MarkVolumeReadDto markVolumeReadDto)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress);
|
||||
|
||||
var chapters = await _unitOfWork.VolumeRepository.GetChaptersAsync(markVolumeReadDto.VolumeId);
|
||||
var chapters = await _unitOfWork.ChapterRepository.GetChaptersAsync(markVolumeReadDto.VolumeId);
|
||||
foreach (var chapter in chapters)
|
||||
{
|
||||
user.Progresses ??= new List<AppUserProgress>();
|
||||
@ -301,7 +299,7 @@ namespace API.Controllers
|
||||
[HttpGet("get-progress")]
|
||||
public async Task<ActionResult<ProgressDto>> GetProgress(int chapterId)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress);
|
||||
var progressBookmark = new ProgressDto()
|
||||
{
|
||||
PageNum = 0,
|
||||
@ -331,7 +329,8 @@ namespace API.Controllers
|
||||
public async Task<ActionResult> BookmarkProgress(ProgressDto progressDto)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||
if (await _readerService.SaveReadingProgress(progressDto, user)) return Ok(true);
|
||||
|
||||
if (await _readerService.SaveReadingProgress(progressDto, user.Id)) return Ok(true);
|
||||
|
||||
return BadRequest("Could not save progress");
|
||||
}
|
||||
@ -344,7 +343,7 @@ namespace API.Controllers
|
||||
[HttpGet("get-bookmarks")]
|
||||
public async Task<ActionResult<IEnumerable<BookmarkDto>>> GetBookmarks(int chapterId)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks);
|
||||
if (user.Bookmarks == null) return Ok(Array.Empty<BookmarkDto>());
|
||||
return Ok(await _unitOfWork.UserRepository.GetBookmarkDtosForChapter(user.Id, chapterId));
|
||||
}
|
||||
@ -356,7 +355,7 @@ namespace API.Controllers
|
||||
[HttpGet("get-all-bookmarks")]
|
||||
public async Task<ActionResult<IEnumerable<BookmarkDto>>> GetAllBookmarks()
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks);
|
||||
if (user.Bookmarks == null) return Ok(Array.Empty<BookmarkDto>());
|
||||
return Ok(await _unitOfWork.UserRepository.GetAllBookmarkDtos(user.Id));
|
||||
}
|
||||
@ -369,7 +368,7 @@ namespace API.Controllers
|
||||
[HttpPost("remove-bookmarks")]
|
||||
public async Task<ActionResult> RemoveBookmarks(RemoveBookmarkForSeriesDto dto)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks);
|
||||
if (user.Bookmarks == null) return Ok("Nothing to remove");
|
||||
try
|
||||
{
|
||||
@ -399,7 +398,7 @@ namespace API.Controllers
|
||||
[HttpGet("get-volume-bookmarks")]
|
||||
public async Task<ActionResult<IEnumerable<BookmarkDto>>> GetBookmarksForVolume(int volumeId)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks);
|
||||
if (user.Bookmarks == null) return Ok(Array.Empty<BookmarkDto>());
|
||||
return Ok(await _unitOfWork.UserRepository.GetBookmarkDtosForVolume(user.Id, volumeId));
|
||||
}
|
||||
@ -412,7 +411,7 @@ namespace API.Controllers
|
||||
[HttpGet("get-series-bookmarks")]
|
||||
public async Task<ActionResult<IEnumerable<BookmarkDto>>> GetBookmarksForSeries(int seriesId)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks);
|
||||
if (user.Bookmarks == null) return Ok(Array.Empty<BookmarkDto>());
|
||||
|
||||
return Ok(await _unitOfWork.UserRepository.GetBookmarkDtosForSeries(user.Id, seriesId));
|
||||
@ -426,29 +425,18 @@ namespace API.Controllers
|
||||
[HttpPost("bookmark")]
|
||||
public async Task<ActionResult> BookmarkPage(BookmarkDto bookmarkDto)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||
|
||||
// Don't let user save past total pages.
|
||||
var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(bookmarkDto.ChapterId);
|
||||
if (bookmarkDto.Page > chapter.Pages)
|
||||
{
|
||||
bookmarkDto.Page = chapter.Pages;
|
||||
}
|
||||
|
||||
if (bookmarkDto.Page < 0)
|
||||
{
|
||||
bookmarkDto.Page = 0;
|
||||
}
|
||||
|
||||
bookmarkDto.Page = await _readerService.CapPageToChapter(bookmarkDto.ChapterId, bookmarkDto.Page);
|
||||
|
||||
try
|
||||
{
|
||||
user.Bookmarks ??= new List<AppUserBookmark>();
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks);
|
||||
var userBookmark =
|
||||
user.Bookmarks.SingleOrDefault(x => x.ChapterId == bookmarkDto.ChapterId && x.AppUserId == user.Id && x.Page == bookmarkDto.Page);
|
||||
await _unitOfWork.UserRepository.GetBookmarkForPage(bookmarkDto.Page, bookmarkDto.ChapterId, user.Id);
|
||||
|
||||
if (userBookmark == null)
|
||||
{
|
||||
user.Bookmarks ??= new List<AppUserBookmark>();
|
||||
user.Bookmarks.Add(new AppUserBookmark()
|
||||
{
|
||||
Page = bookmarkDto.Page,
|
||||
@ -456,15 +444,9 @@ namespace API.Controllers
|
||||
SeriesId = bookmarkDto.SeriesId,
|
||||
ChapterId = bookmarkDto.ChapterId,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
userBookmark.Page = bookmarkDto.Page;
|
||||
userBookmark.SeriesId = bookmarkDto.SeriesId;
|
||||
userBookmark.VolumeId = bookmarkDto.VolumeId;
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
}
|
||||
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
|
||||
if (await _unitOfWork.CommitAsync())
|
||||
{
|
||||
@ -487,7 +469,7 @@ namespace API.Controllers
|
||||
[HttpPost("unbookmark")]
|
||||
public async Task<ActionResult> UnBookmarkPage(BookmarkDto bookmarkDto)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks);
|
||||
|
||||
if (user.Bookmarks == null) return Ok();
|
||||
try {
|
||||
@ -496,7 +478,6 @@ namespace API.Controllers
|
||||
&& x.AppUserId == user.Id
|
||||
&& x.Page != bookmarkDto.Page).ToList();
|
||||
|
||||
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
|
||||
if (await _unitOfWork.CommitAsync())
|
||||
@ -526,57 +507,9 @@ namespace API.Controllers
|
||||
public async Task<ActionResult<int>> GetNextChapter(int seriesId, int volumeId, int currentChapterId)
|
||||
{
|
||||
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
|
||||
var volumes = await _unitOfWork.SeriesRepository.GetVolumesDtoAsync(seriesId, userId);
|
||||
var currentVolume = await _unitOfWork.SeriesRepository.GetVolumeAsync(volumeId);
|
||||
var currentChapter = await _unitOfWork.VolumeRepository.GetChapterAsync(currentChapterId);
|
||||
if (currentVolume.Number == 0)
|
||||
{
|
||||
// Handle specials by sorting on their Filename aka Range
|
||||
var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => x.Range, _naturalSortComparer), currentChapter.Number);
|
||||
if (chapterId > 0) return Ok(chapterId);
|
||||
return await _readerService.GetNextChapterIdAsync(seriesId, volumeId, currentChapterId, userId);
|
||||
}
|
||||
|
||||
foreach (var volume in volumes)
|
||||
{
|
||||
if (volume.Number == currentVolume.Number && volume.Chapters.Count > 1)
|
||||
{
|
||||
// Handle Chapters within current Volume
|
||||
// In this case, i need 0 first because 0 represents a full volume file.
|
||||
var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting), currentChapter.Number);
|
||||
if (chapterId > 0) return Ok(chapterId);
|
||||
}
|
||||
|
||||
if (volume.Number == currentVolume.Number + 1)
|
||||
{
|
||||
// Handle Chapters within next Volume
|
||||
// ! When selecting the chapter for the next volume, we need to make sure a c0 comes before a c1+
|
||||
var chapters = volume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparer).ToList();
|
||||
if (currentChapter.Number.Equals("0") && chapters.Last().Number.Equals("0"))
|
||||
{
|
||||
return chapters.Last().Id;
|
||||
}
|
||||
|
||||
return Ok(chapters.FirstOrDefault()?.Id);
|
||||
}
|
||||
}
|
||||
return Ok(-1);
|
||||
}
|
||||
|
||||
private static int GetNextChapterId(IEnumerable<Chapter> chapters, string currentChapterNumber)
|
||||
{
|
||||
var next = false;
|
||||
var chaptersList = chapters.ToList();
|
||||
foreach (var chapter in chaptersList)
|
||||
{
|
||||
if (next)
|
||||
{
|
||||
return chapter.Id;
|
||||
}
|
||||
if (currentChapterNumber.Equals(chapter.Number)) next = true;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the previous logical chapter from the series.
|
||||
@ -592,29 +525,7 @@ namespace API.Controllers
|
||||
public async Task<ActionResult<int>> GetPreviousChapter(int seriesId, int volumeId, int currentChapterId)
|
||||
{
|
||||
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
|
||||
var volumes = await _unitOfWork.SeriesRepository.GetVolumesDtoAsync(seriesId, userId);
|
||||
var currentVolume = await _unitOfWork.SeriesRepository.GetVolumeAsync(volumeId);
|
||||
var currentChapter = await _unitOfWork.VolumeRepository.GetChapterAsync(currentChapterId);
|
||||
|
||||
if (currentVolume.Number == 0)
|
||||
{
|
||||
var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => x.Range, _naturalSortComparer).Reverse(), currentChapter.Number);
|
||||
if (chapterId > 0) return Ok(chapterId);
|
||||
}
|
||||
|
||||
foreach (var volume in volumes.Reverse())
|
||||
{
|
||||
if (volume.Number == currentVolume.Number)
|
||||
{
|
||||
var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting).Reverse(), currentChapter.Number);
|
||||
if (chapterId > 0) return Ok(chapterId);
|
||||
}
|
||||
if (volume.Number == currentVolume.Number - 1)
|
||||
{
|
||||
return Ok(volume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting).LastOrDefault()?.Id);
|
||||
}
|
||||
}
|
||||
return Ok(-1);
|
||||
return await _readerService.GetPrevChapterIdAsync(seriesId, volumeId, currentChapterId, userId);
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -265,7 +265,7 @@ namespace API.Controllers
|
||||
var readingList = user.ReadingLists.SingleOrDefault(l => l.Id == dto.ReadingListId);
|
||||
if (readingList == null) return BadRequest("Reading List does not exist");
|
||||
var chapterIdsForVolume =
|
||||
(await _unitOfWork.VolumeRepository.GetChaptersAsync(dto.VolumeId)).Select(c => c.Id).ToList();
|
||||
(await _unitOfWork.ChapterRepository.GetChaptersAsync(dto.VolumeId)).Select(c => c.Id).ToList();
|
||||
|
||||
// If there are adds, tell tracking this has been modified
|
||||
if (await AddChaptersToReadingList(dto.SeriesId, chapterIdsForVolume, readingList))
|
||||
|
@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Filtering;
|
||||
using API.Entities;
|
||||
@ -109,16 +110,14 @@ namespace API.Controllers
|
||||
[HttpGet("chapter")]
|
||||
public async Task<ActionResult<VolumeDto>> GetChapter(int chapterId)
|
||||
{
|
||||
return Ok(await _unitOfWork.VolumeRepository.GetChapterDtoAsync(chapterId));
|
||||
return Ok(await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId));
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
[HttpPost("update-rating")]
|
||||
public async Task<ActionResult> UpdateSeriesRating(UpdateSeriesRatingDto updateSeriesRatingDto)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Ratings);
|
||||
var userRating = await _unitOfWork.UserRepository.GetUserRating(updateSeriesRatingDto.SeriesId, user.Id) ??
|
||||
new AppUserRating();
|
||||
|
||||
|
@ -142,7 +142,7 @@ namespace API.Controllers
|
||||
|
||||
if (bytes.Length > 0)
|
||||
{
|
||||
var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(uploadFileDto.Id);
|
||||
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(uploadFileDto.Id);
|
||||
chapter.CoverImage = bytes;
|
||||
chapter.CoverImageLocked = true;
|
||||
_unitOfWork.ChapterRepository.Update(chapter);
|
||||
@ -178,7 +178,7 @@ namespace API.Controllers
|
||||
{
|
||||
try
|
||||
{
|
||||
var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(uploadFileDto.Id);
|
||||
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(uploadFileDto.Id);
|
||||
chapter.CoverImage = Array.Empty<byte>();
|
||||
chapter.CoverImageLocked = false;
|
||||
_unitOfWork.ChapterRepository.Update(chapter);
|
||||
|
@ -1,6 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace API.DTOs
|
||||
namespace API.DTOs.Account
|
||||
{
|
||||
public class ResetPasswordDto
|
||||
{
|
@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using API.DTOs.Reader;
|
||||
|
||||
namespace API.DTOs.Downloads
|
||||
{
|
||||
|
@ -1,15 +0,0 @@
|
||||
namespace API.DTOs
|
||||
{
|
||||
public class ImageDto
|
||||
{
|
||||
public int Page { get; init; }
|
||||
public string Filename { get; init; }
|
||||
public string FullPath { get; init; }
|
||||
public int Width { get; init; }
|
||||
public int Height { get; init; }
|
||||
public string Format { get; init; }
|
||||
public byte[] Content { get; init; }
|
||||
public string MangaFileName { get; init; }
|
||||
public bool NeedsSplitting { get; init; }
|
||||
}
|
||||
}
|
@ -1,24 +0,0 @@
|
||||
namespace API.DTOs
|
||||
{
|
||||
public class InProgressChapterDto
|
||||
{
|
||||
public int Id { get; init; }
|
||||
/// <summary>
|
||||
/// Range of chapters. Chapter 2-4 -> "2-4". Chapter 2 -> "2".
|
||||
/// </summary>
|
||||
public string Range { get; init; }
|
||||
/// <summary>
|
||||
/// Smallest number of the Range.
|
||||
/// </summary>
|
||||
public string Number { get; init; }
|
||||
/// <summary>
|
||||
/// Total number of pages in all MangaFiles
|
||||
/// </summary>
|
||||
public int Pages { get; init; }
|
||||
public int SeriesId { get; init; }
|
||||
public int LibraryId { get; init; }
|
||||
public string SeriesName { get; init; }
|
||||
public int VolumeId { get; init; }
|
||||
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
namespace API.DTOs
|
||||
namespace API.DTOs.Reader
|
||||
{
|
||||
public class BookmarkDto
|
||||
{
|
@ -1,4 +1,4 @@
|
||||
namespace API.DTOs
|
||||
namespace API.DTOs.Reader
|
||||
{
|
||||
public class MarkReadDto
|
||||
{
|
@ -1,4 +1,4 @@
|
||||
namespace API.DTOs
|
||||
namespace API.DTOs.Reader
|
||||
{
|
||||
public class MarkVolumeReadDto
|
||||
{
|
@ -1,4 +1,4 @@
|
||||
namespace API.DTOs
|
||||
namespace API.DTOs.Reader
|
||||
{
|
||||
public class RemoveBookmarkForSeriesDto
|
||||
{
|
@ -13,7 +13,6 @@ namespace API.DTOs
|
||||
public int PagesRead { get; set; }
|
||||
public DateTime LastModified { get; set; }
|
||||
public DateTime Created { get; set; }
|
||||
public bool IsSpecial { get; set; }
|
||||
public int SeriesId { get; set; }
|
||||
public ICollection<ChapterDto> Chapters { get; set; }
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Interfaces.Repositories;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@ -15,6 +16,11 @@ namespace API.Data.Repositories
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public void Update(AppUserProgress userProgress)
|
||||
{
|
||||
_context.Entry(userProgress).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This will remove any entries that have chapterIds that no longer exists. This will execute the save as well.
|
||||
/// </summary>
|
||||
@ -62,5 +68,12 @@ namespace API.Data.Repositories
|
||||
.AsNoTracking()
|
||||
.AnyAsync();
|
||||
}
|
||||
|
||||
public async Task<AppUserProgress> GetUserProgressAsync(int chapterId, int userId)
|
||||
{
|
||||
return await _context.AppUserProgresses
|
||||
.Where(p => p.ChapterId == chapterId && p.AppUserId == userId)
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +1,12 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Reader;
|
||||
using API.Entities;
|
||||
using API.Interfaces.Repositories;
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace API.Data.Repositories
|
||||
@ -11,10 +14,12 @@ namespace API.Data.Repositories
|
||||
public class ChapterRepository : IChapterRepository
|
||||
{
|
||||
private readonly DataContext _context;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
public ChapterRepository(DataContext context)
|
||||
public ChapterRepository(DataContext context, IMapper mapper)
|
||||
{
|
||||
_context = context;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
public void Update(Chapter chapter)
|
||||
@ -30,8 +35,6 @@ namespace API.Data.Repositories
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
// TODO: Move over Chapter based queries here
|
||||
|
||||
/// <summary>
|
||||
/// Populates a partial IChapterInfoDto
|
||||
/// </summary>
|
||||
@ -76,5 +79,87 @@ namespace API.Data.Repositories
|
||||
.AsNoTracking()
|
||||
.SingleAsync();
|
||||
}
|
||||
|
||||
public Task<int> GetChapterTotalPagesAsync(int chapterId)
|
||||
{
|
||||
return _context.Chapter
|
||||
.Where(c => c.Id == chapterId)
|
||||
.Select(c => c.Pages)
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
public async Task<ChapterDto> GetChapterDtoAsync(int chapterId)
|
||||
{
|
||||
var chapter = await _context.Chapter
|
||||
.Include(c => c.Files)
|
||||
.ProjectTo<ChapterDto>(_mapper.ConfigurationProvider)
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(c => c.Id == chapterId);
|
||||
|
||||
return chapter;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns non-tracked files for a given chapterId
|
||||
/// </summary>
|
||||
/// <param name="chapterId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<IList<MangaFile>> GetFilesForChapterAsync(int chapterId)
|
||||
{
|
||||
return await _context.MangaFile
|
||||
.Where(c => chapterId == c.ChapterId)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a Chapter for an Id. Includes linked <see cref="MangaFile"/>s.
|
||||
/// </summary>
|
||||
/// <param name="chapterId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<Chapter> GetChapterAsync(int chapterId)
|
||||
{
|
||||
return await _context.Chapter
|
||||
.Include(c => c.Files)
|
||||
.SingleOrDefaultAsync(c => c.Id == chapterId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns Chapters for a volume id.
|
||||
/// </summary>
|
||||
/// <param name="volumeId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<IList<Chapter>> GetChaptersAsync(int volumeId)
|
||||
{
|
||||
return await _context.Chapter
|
||||
.Where(c => c.VolumeId == volumeId)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cover image for a chapter id.
|
||||
/// </summary>
|
||||
/// <param name="chapterId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<byte[]> GetChapterCoverImageAsync(int chapterId)
|
||||
{
|
||||
return await _context.Chapter
|
||||
.Where(c => c.Id == chapterId)
|
||||
.Select(c => c.CoverImage)
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns non-tracked files for a set of chapterIds
|
||||
/// </summary>
|
||||
/// <param name="chapterIds"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<IList<MangaFile>> GetFilesForChaptersAsync(IReadOnlyList<int> chapterIds)
|
||||
{
|
||||
return await _context.MangaFile
|
||||
.Where(c => chapterIds.Contains(c.ChapterId))
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -115,8 +115,6 @@ namespace API.Data.Repositories
|
||||
await AddVolumeModifiers(userId, volumes);
|
||||
SortSpecialChapters(volumes);
|
||||
|
||||
|
||||
|
||||
return volumes;
|
||||
}
|
||||
|
||||
@ -258,15 +256,6 @@ namespace API.Data.Repositories
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<byte[]> GetVolumeCoverImageAsync(int volumeId)
|
||||
{
|
||||
return await _context.Volume
|
||||
.Where(v => v.Id == volumeId)
|
||||
.Select(v => v.CoverImage)
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<byte[]> GetSeriesCoverImageAsync(int seriesId)
|
||||
{
|
||||
return await _context.Series
|
||||
@ -278,8 +267,9 @@ namespace API.Data.Repositories
|
||||
|
||||
private async Task AddVolumeModifiers(int userId, IReadOnlyCollection<VolumeDto> volumes)
|
||||
{
|
||||
var volIds = volumes.Select(s => s.Id);
|
||||
var userProgress = await _context.AppUserProgresses
|
||||
.Where(p => p.AppUserId == userId && volumes.Select(s => s.Id).Contains(p.VolumeId))
|
||||
.Where(p => p.AppUserId == userId && volIds.Contains(p.VolumeId))
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
|
@ -1,8 +1,10 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Constants;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Reader;
|
||||
using API.Entities;
|
||||
using API.Interfaces.Repositories;
|
||||
using AutoMapper;
|
||||
@ -12,6 +14,16 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace API.Data.Repositories
|
||||
{
|
||||
[Flags]
|
||||
public enum AppUserIncludes
|
||||
{
|
||||
None = 1,
|
||||
Progress = 2,
|
||||
Bookmarks = 4,
|
||||
ReadingLists = 8,
|
||||
Ratings = 16
|
||||
}
|
||||
|
||||
public class UserRepository : IUserRepository
|
||||
{
|
||||
private readonly DataContext _context;
|
||||
@ -35,24 +47,81 @@ namespace API.Data.Repositories
|
||||
_context.Entry(preferences).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
public void Update(AppUserBookmark bookmark)
|
||||
{
|
||||
_context.Entry(bookmark).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
public void Delete(AppUser user)
|
||||
{
|
||||
_context.AppUser.Remove(user);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an AppUser by username. Returns back Progress information.
|
||||
/// A one stop shop to get a tracked AppUser instance with any number of JOINs generated by passing bitwise flags.
|
||||
/// </summary>
|
||||
/// <param name="username"></param>
|
||||
/// <param name="includeFlags">Includes() you want. Pass multiple with flag1 | flag2 </param>
|
||||
/// <returns></returns>
|
||||
public async Task<AppUser> GetUserByUsernameAsync(string username)
|
||||
public async Task<AppUser> GetUserByUsernameAsync(string username, AppUserIncludes includeFlags = AppUserIncludes.None)
|
||||
{
|
||||
return await _context.Users
|
||||
.Include(u => u.Progresses)
|
||||
.Include(u => u.Bookmarks)
|
||||
.SingleOrDefaultAsync(x => x.UserName == username);
|
||||
var query = _context.Users
|
||||
.Where(x => x.UserName == username);
|
||||
|
||||
query = AddIncludesToQuery(query, includeFlags);
|
||||
|
||||
return await query.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A one stop shop to get a tracked AppUser instance with any number of JOINs generated by passing bitwise flags.
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="includeFlags">Includes() you want. Pass multiple with flag1 | flag2 </param>
|
||||
/// <returns></returns>
|
||||
public async Task<AppUser> GetUserByIdAsync(int userId, AppUserIncludes includeFlags = AppUserIncludes.None)
|
||||
{
|
||||
var query = _context.Users
|
||||
.Where(x => x.Id == userId);
|
||||
|
||||
query = AddIncludesToQuery(query, includeFlags);
|
||||
|
||||
return await query.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<AppUserBookmark> GetBookmarkForPage(int page, int chapterId, int userId)
|
||||
{
|
||||
return await _context.AppUserBookmark
|
||||
.Where(b => b.Page == page && b.ChapterId == chapterId && b.AppUserId == userId)
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
private static IQueryable<AppUser> AddIncludesToQuery(IQueryable<AppUser> query, AppUserIncludes includeFlags)
|
||||
{
|
||||
if (includeFlags.HasFlag(AppUserIncludes.Bookmarks))
|
||||
{
|
||||
query = query.Include(u => u.Bookmarks);
|
||||
}
|
||||
|
||||
if (includeFlags.HasFlag(AppUserIncludes.Progress))
|
||||
{
|
||||
query = query.Include(u => u.Progresses);
|
||||
}
|
||||
|
||||
if (includeFlags.HasFlag(AppUserIncludes.ReadingLists))
|
||||
{
|
||||
query = query.Include(u => u.ReadingLists);
|
||||
}
|
||||
|
||||
if (includeFlags.HasFlag(AppUserIncludes.Ratings))
|
||||
{
|
||||
query = query.Include(u => u.Ratings);
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// This fetches the Id for a user. Use whenever you just need an ID.
|
||||
/// </summary>
|
||||
@ -79,19 +148,6 @@ namespace API.Data.Repositories
|
||||
.SingleOrDefaultAsync(x => x.UserName == username);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an AppUser by id. Returns back Progress information.
|
||||
/// </summary>
|
||||
/// <param name="id"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<AppUser> GetUserByIdAsync(int id)
|
||||
{
|
||||
return await _context.Users
|
||||
.Include(u => u.Progresses)
|
||||
.Include(u => u.Bookmarks)
|
||||
.SingleOrDefaultAsync(x => x.Id == id);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
|
||||
{
|
||||
return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole);
|
||||
@ -103,11 +159,6 @@ namespace API.Data.Repositories
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
public void AddRatingTracking(AppUserRating userRating)
|
||||
{
|
||||
_context.AppUserRating.Add(userRating);
|
||||
}
|
||||
|
||||
public async Task<AppUserPreferences> GetPreferencesAsync(string username)
|
||||
{
|
||||
return await _context.AppUserPreferences
|
||||
@ -155,10 +206,17 @@ namespace API.Data.Repositories
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<AppUser> GetUserByApiKeyAsync(string apiKey)
|
||||
/// <summary>
|
||||
/// Fetches the UserId by API Key. This does not include any extra information
|
||||
/// </summary>
|
||||
/// <param name="apiKey"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<int> GetUserIdByApiKeyAsync(string apiKey)
|
||||
{
|
||||
return await _context.AppUser
|
||||
.SingleOrDefaultAsync(u => u.ApiKey.Equals(apiKey));
|
||||
.Where(u => u.ApiKey.Equals(apiKey))
|
||||
.Select(u => u.Id)
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
|
||||
|
@ -2,6 +2,7 @@
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Reader;
|
||||
using API.Entities;
|
||||
using API.Interfaces.Repositories;
|
||||
using AutoMapper;
|
||||
@ -13,12 +14,10 @@ namespace API.Data.Repositories
|
||||
public class VolumeRepository : IVolumeRepository
|
||||
{
|
||||
private readonly DataContext _context;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
public VolumeRepository(DataContext context, IMapper mapper)
|
||||
public VolumeRepository(DataContext context)
|
||||
{
|
||||
_context = context;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
public void Update(Volume volume)
|
||||
@ -26,83 +25,6 @@ namespace API.Data.Repositories
|
||||
_context.Entry(volume).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a Chapter for an Id. Includes linked <see cref="MangaFile"/>s.
|
||||
/// </summary>
|
||||
/// <param name="chapterId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<Chapter> GetChapterAsync(int chapterId)
|
||||
{
|
||||
return await _context.Chapter
|
||||
.Include(c => c.Files)
|
||||
.SingleOrDefaultAsync(c => c.Id == chapterId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns Chapters for a volume id.
|
||||
/// </summary>
|
||||
/// <param name="volumeId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<IList<Chapter>> GetChaptersAsync(int volumeId)
|
||||
{
|
||||
return await _context.Chapter
|
||||
.Where(c => c.VolumeId == volumeId)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cover image for a chapter id.
|
||||
/// </summary>
|
||||
/// <param name="chapterId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<byte[]> GetChapterCoverImageAsync(int chapterId)
|
||||
{
|
||||
return await _context.Chapter
|
||||
.Where(c => c.Id == chapterId)
|
||||
.Select(c => c.CoverImage)
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
public async Task<ChapterDto> GetChapterDtoAsync(int chapterId)
|
||||
{
|
||||
var chapter = await _context.Chapter
|
||||
.Include(c => c.Files)
|
||||
.ProjectTo<ChapterDto>(_mapper.ConfigurationProvider)
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(c => c.Id == chapterId);
|
||||
|
||||
return chapter;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns non-tracked files for a given chapterId
|
||||
/// </summary>
|
||||
/// <param name="chapterId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<IList<MangaFile>> GetFilesForChapterAsync(int chapterId)
|
||||
{
|
||||
return await _context.MangaFile
|
||||
.Where(c => chapterId == c.ChapterId)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
}
|
||||
/// <summary>
|
||||
/// Returns non-tracked files for a set of chapterIds
|
||||
/// </summary>
|
||||
/// <param name="chapterIds"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<IList<MangaFile>> GetFilesForChaptersAsync(IReadOnlyList<int> chapterIds)
|
||||
{
|
||||
return await _context.MangaFile
|
||||
.Where(c => chapterIds.Contains(c.ChapterId))
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IList<MangaFile>> GetFilesForVolume(int volumeId)
|
||||
{
|
||||
return await _context.Chapter
|
||||
@ -112,5 +34,14 @@ namespace API.Data.Repositories
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<byte[]> GetVolumeCoverImageAsync(int volumeId)
|
||||
{
|
||||
return await _context.Volume
|
||||
.Where(v => v.Id == volumeId)
|
||||
.Select(v => v.CoverImage)
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -25,14 +25,14 @@ namespace API.Data
|
||||
public IUserRepository UserRepository => new UserRepository(_context, _userManager, _mapper);
|
||||
public ILibraryRepository LibraryRepository => new LibraryRepository(_context, _mapper);
|
||||
|
||||
public IVolumeRepository VolumeRepository => new VolumeRepository(_context, _mapper);
|
||||
public IVolumeRepository VolumeRepository => new VolumeRepository(_context);
|
||||
|
||||
public ISettingsRepository SettingsRepository => new SettingsRepository(_context, _mapper);
|
||||
|
||||
public IAppUserProgressRepository AppUserProgressRepository => new AppUserProgressRepository(_context);
|
||||
public ICollectionTagRepository CollectionTagRepository => new CollectionTagRepository(_context, _mapper);
|
||||
public IFileRepository FileRepository => new FileRepository(_context);
|
||||
public IChapterRepository ChapterRepository => new ChapterRepository(_context);
|
||||
public IChapterRepository ChapterRepository => new ChapterRepository(_context, _mapper);
|
||||
public IReadingListRepository ReadingListRepository => new ReadingListRepository(_context, _mapper);
|
||||
|
||||
/// <summary>
|
||||
|
@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using API.Entities.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace API.Entities
|
||||
{
|
||||
|
@ -1,6 +1,7 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Reader;
|
||||
using API.DTOs.ReadingLists;
|
||||
using API.Entities;
|
||||
using API.Helpers.Converters;
|
||||
|
@ -1,11 +1,14 @@
|
||||
using System.Threading.Tasks;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.Interfaces.Repositories
|
||||
{
|
||||
public interface IAppUserProgressRepository
|
||||
{
|
||||
void Update(AppUserProgress userProgress);
|
||||
Task<int> CleanupAbandonedChapters();
|
||||
Task<bool> UserHasProgress(LibraryType libraryType, int userId);
|
||||
Task<AppUserProgress> GetUserProgressAsync(int chapterId, int userId);
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Reader;
|
||||
using API.Entities;
|
||||
|
||||
@ -10,5 +11,12 @@ namespace API.Interfaces.Repositories
|
||||
void Update(Chapter chapter);
|
||||
Task<IEnumerable<Chapter>> GetChaptersByIdsAsync(IList<int> chapterIds);
|
||||
Task<IChapterInfoDto> GetChapterInfoDtoAsync(int chapterId);
|
||||
Task<int> GetChapterTotalPagesAsync(int chapterId);
|
||||
Task<Chapter> GetChapterAsync(int chapterId);
|
||||
Task<ChapterDto> GetChapterDtoAsync(int chapterId);
|
||||
Task<IList<MangaFile>> GetFilesForChapterAsync(int chapterId);
|
||||
Task<IList<Chapter>> GetChaptersAsync(int volumeId);
|
||||
Task<IList<MangaFile>> GetFilesForChaptersAsync(IReadOnlyList<int> chapterIds);
|
||||
Task<byte[]> GetChapterCoverImageAsync(int chapterId);
|
||||
}
|
||||
}
|
||||
|
@ -56,7 +56,7 @@ namespace API.Interfaces.Repositories
|
||||
/// <returns></returns>
|
||||
Task AddSeriesModifiers(int userId, List<SeriesDto> series);
|
||||
|
||||
Task<byte[]> GetVolumeCoverImageAsync(int volumeId);
|
||||
|
||||
Task<byte[]> GetSeriesCoverImageAsync(int seriesId);
|
||||
Task<IEnumerable<SeriesDto>> GetInProgress(int userId, int libraryId, UserParams userParams, FilterDto filter);
|
||||
Task<PagedList<SeriesDto>> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter);
|
||||
|
@ -1,6 +1,8 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Reader;
|
||||
using API.Entities;
|
||||
|
||||
namespace API.Interfaces.Repositories
|
||||
@ -9,20 +11,21 @@ namespace API.Interfaces.Repositories
|
||||
{
|
||||
void Update(AppUser user);
|
||||
void Update(AppUserPreferences preferences);
|
||||
void Update(AppUserBookmark bookmark);
|
||||
public void Delete(AppUser user);
|
||||
Task<AppUser> GetUserByUsernameAsync(string username);
|
||||
Task<int> GetUserIdByUsernameAsync(string username);
|
||||
Task<AppUser> GetUserWithReadingListsByUsernameAsync(string username);
|
||||
Task<AppUser> GetUserByIdAsync(int id);
|
||||
Task<IEnumerable<MemberDto>> GetMembersAsync();
|
||||
Task<IEnumerable<AppUser>> GetAdminUsersAsync();
|
||||
Task<AppUserRating> GetUserRating(int seriesId, int userId);
|
||||
void AddRatingTracking(AppUserRating userRating);
|
||||
Task<AppUserPreferences> GetPreferencesAsync(string username);
|
||||
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForSeries(int userId, int seriesId);
|
||||
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForVolume(int userId, int volumeId);
|
||||
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForChapter(int userId, int chapterId);
|
||||
Task<IEnumerable<BookmarkDto>> GetAllBookmarkDtos(int userId);
|
||||
Task<AppUser> GetUserByApiKeyAsync(string apiKey);
|
||||
Task<AppUserBookmark> GetBookmarkForPage(int page, int chapterId, int userId);
|
||||
Task<int> GetUserIdByApiKeyAsync(string apiKey);
|
||||
Task<AppUser> GetUserByUsernameAsync(string username, AppUserIncludes includeFlags = AppUserIncludes.None);
|
||||
Task<AppUser> GetUserByIdAsync(int userId, AppUserIncludes includeFlags = AppUserIncludes.None);
|
||||
Task<int> GetUserIdByUsernameAsync(string username);
|
||||
Task<AppUser> GetUserWithReadingListsByUsernameAsync(string username);
|
||||
}
|
||||
}
|
||||
|
@ -8,12 +8,7 @@ namespace API.Interfaces.Repositories
|
||||
public interface IVolumeRepository
|
||||
{
|
||||
void Update(Volume volume);
|
||||
Task<Chapter> GetChapterAsync(int chapterId);
|
||||
Task<ChapterDto> GetChapterDtoAsync(int chapterId);
|
||||
Task<IList<MangaFile>> GetFilesForChapterAsync(int chapterId);
|
||||
Task<IList<MangaFile>> GetFilesForChaptersAsync(IReadOnlyList<int> chapterIds);
|
||||
Task<IList<Chapter>> GetChaptersAsync(int volumeId);
|
||||
Task<byte[]> GetChapterCoverImageAsync(int chapterId);
|
||||
Task<IList<MangaFile>> GetFilesForVolume(int volumeId);
|
||||
Task<byte[]> GetVolumeCoverImageAsync(int volumeId);
|
||||
}
|
||||
}
|
||||
|
@ -1,11 +1,13 @@
|
||||
using System.Threading.Tasks;
|
||||
using API.DTOs;
|
||||
using API.Entities;
|
||||
|
||||
namespace API.Interfaces.Services
|
||||
{
|
||||
public interface IReaderService
|
||||
{
|
||||
Task<bool> SaveReadingProgress(ProgressDto progressDto, AppUser user);
|
||||
Task<bool> SaveReadingProgress(ProgressDto progressDto, int userId);
|
||||
Task<int> CapPageToChapter(int chapterId, int page);
|
||||
Task<int> GetNextChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId);
|
||||
Task<int> GetPrevChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId);
|
||||
}
|
||||
}
|
||||
|
@ -3,49 +3,50 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Comparators;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.Entities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Interfaces.Services
|
||||
{
|
||||
public class ReaderService : IReaderService
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILogger<ReaderService> _logger;
|
||||
private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer();
|
||||
private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst();
|
||||
private readonly NaturalSortComparer _naturalSortComparer = new NaturalSortComparer();
|
||||
|
||||
public ReaderService(IUnitOfWork unitOfWork)
|
||||
public ReaderService(IUnitOfWork unitOfWork, ILogger<ReaderService> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves progress to DB
|
||||
/// </summary>
|
||||
/// <param name="progressDto"></param>
|
||||
/// <param name="user"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<bool> SaveReadingProgress(ProgressDto progressDto, AppUser user)
|
||||
public async Task<bool> SaveReadingProgress(ProgressDto progressDto, int userId)
|
||||
{
|
||||
// Don't let user save past total pages.
|
||||
var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(progressDto.ChapterId);
|
||||
if (progressDto.PageNum > chapter.Pages)
|
||||
{
|
||||
progressDto.PageNum = chapter.Pages;
|
||||
}
|
||||
|
||||
if (progressDto.PageNum < 0)
|
||||
{
|
||||
progressDto.PageNum = 0;
|
||||
}
|
||||
progressDto.PageNum = await CapPageToChapter(progressDto.ChapterId, progressDto.PageNum);
|
||||
|
||||
try
|
||||
{
|
||||
user.Progresses ??= new List<AppUserProgress>();
|
||||
var userProgress =
|
||||
user.Progresses.FirstOrDefault(x => x.ChapterId == progressDto.ChapterId && x.AppUserId == user.Id);
|
||||
await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(progressDto.ChapterId, userId);
|
||||
|
||||
if (userProgress == null)
|
||||
{
|
||||
user.Progresses.Add(new AppUserProgress
|
||||
// Create a user object
|
||||
var userWithProgress = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.Progress);
|
||||
userWithProgress.Progresses ??= new List<AppUserProgress>();
|
||||
userWithProgress.Progresses.Add(new AppUserProgress
|
||||
{
|
||||
PagesRead = progressDto.PageNum,
|
||||
VolumeId = progressDto.VolumeId,
|
||||
@ -54,6 +55,7 @@ namespace API.Interfaces.Services
|
||||
BookScrollId = progressDto.BookScrollId,
|
||||
LastModified = DateTime.Now
|
||||
});
|
||||
_unitOfWork.UserRepository.Update(userWithProgress);
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -62,21 +64,149 @@ namespace API.Interfaces.Services
|
||||
userProgress.VolumeId = progressDto.VolumeId;
|
||||
userProgress.BookScrollId = progressDto.BookScrollId;
|
||||
userProgress.LastModified = DateTime.Now;
|
||||
_unitOfWork.AppUserProgressRepository.Update(userProgress);
|
||||
}
|
||||
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
|
||||
if (await _unitOfWork.CommitAsync())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
catch (Exception exception)
|
||||
{
|
||||
// When opening a fresh chapter, this seems to fail (sometimes)
|
||||
_logger.LogError(exception, "Could not save progress");
|
||||
await _unitOfWork.RollbackAsync();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<int> CapPageToChapter(int chapterId, int page)
|
||||
{
|
||||
var totalPages = await _unitOfWork.ChapterRepository.GetChapterTotalPagesAsync(chapterId);
|
||||
if (page > totalPages)
|
||||
{
|
||||
page = totalPages;
|
||||
}
|
||||
|
||||
if (page < 0)
|
||||
{
|
||||
page = 0;
|
||||
}
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to find the next logical Chapter
|
||||
/// </summary>
|
||||
/// <example>
|
||||
/// V1 → V2 → V3 chapter 0 → V3 chapter 10 → SP 01 → SP 02
|
||||
/// </example>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <param name="volumeId"></param>
|
||||
/// <param name="currentChapterId"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <returns>-1 if nothing can be found</returns>
|
||||
public async Task<int> GetNextChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId)
|
||||
{
|
||||
var volumes = (await _unitOfWork.SeriesRepository.GetVolumesDtoAsync(seriesId, userId)).ToList();
|
||||
var currentVolume = volumes.Single(v => v.Id == volumeId);
|
||||
var currentChapter = currentVolume.Chapters.Single(c => c.Id == currentChapterId);
|
||||
|
||||
if (currentVolume.Number == 0)
|
||||
{
|
||||
// Handle specials by sorting on their Filename aka Range
|
||||
var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => x.Range, _naturalSortComparer), currentChapter.Number);
|
||||
if (chapterId > 0) return chapterId;
|
||||
}
|
||||
|
||||
foreach (var volume in volumes)
|
||||
{
|
||||
if (volume.Number == currentVolume.Number && volume.Chapters.Count > 1)
|
||||
{
|
||||
// Handle Chapters within current Volume
|
||||
// In this case, i need 0 first because 0 represents a full volume file.
|
||||
var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting), currentChapter.Number);
|
||||
if (chapterId > 0) return chapterId;
|
||||
}
|
||||
|
||||
if (volume.Number != currentVolume.Number + 1) continue;
|
||||
|
||||
// Handle Chapters within next Volume
|
||||
// ! When selecting the chapter for the next volume, we need to make sure a c0 comes before a c1+
|
||||
var chapters = volume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparer).ToList();
|
||||
if (currentChapter.Number.Equals("0") && chapters.Last().Number.Equals("0"))
|
||||
{
|
||||
return chapters.Last().Id;
|
||||
}
|
||||
|
||||
var firstChapter = chapters.FirstOrDefault();
|
||||
if (firstChapter == null) return -1;
|
||||
return firstChapter.Id;
|
||||
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
/// <summary>
|
||||
/// Tries to find the prev logical Chapter
|
||||
/// </summary>
|
||||
/// <example>
|
||||
/// V1 ← V2 ← V3 chapter 0 ← V3 chapter 10 ← SP 01 ← SP 02
|
||||
/// </example>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <param name="volumeId"></param>
|
||||
/// <param name="currentChapterId"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <returns>-1 if nothing can be found</returns>
|
||||
public async Task<int> GetPrevChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId)
|
||||
{
|
||||
var volumes = (await _unitOfWork.SeriesRepository.GetVolumesDtoAsync(seriesId, userId)).Reverse().ToList();
|
||||
var currentVolume = volumes.Single(v => v.Id == volumeId);
|
||||
var currentChapter = currentVolume.Chapters.Single(c => c.Id == currentChapterId);
|
||||
|
||||
if (currentVolume.Number == 0)
|
||||
{
|
||||
var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => x.Range, _naturalSortComparer).Reverse(), currentChapter.Number);
|
||||
if (chapterId > 0) return chapterId;
|
||||
}
|
||||
|
||||
foreach (var volume in volumes)
|
||||
{
|
||||
if (volume.Number == currentVolume.Number)
|
||||
{
|
||||
var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting).Reverse(), currentChapter.Number);
|
||||
if (chapterId > 0) return chapterId;
|
||||
}
|
||||
if (volume.Number == currentVolume.Number - 1)
|
||||
{
|
||||
var lastChapter = volume.Chapters
|
||||
.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting).LastOrDefault();
|
||||
if (lastChapter == null) return -1;
|
||||
return lastChapter.Id;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static int GetNextChapterId(IEnumerable<ChapterDto> chapters, string currentChapterNumber)
|
||||
{
|
||||
var next = false;
|
||||
var chaptersList = chapters.ToList();
|
||||
foreach (var chapter in chaptersList)
|
||||
{
|
||||
if (next)
|
||||
{
|
||||
return chapter.Id;
|
||||
}
|
||||
if (currentChapterNumber.Equals(chapter.Number)) next = true;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -67,7 +67,7 @@ namespace API.Services
|
||||
public async Task<Chapter> Ensure(int chapterId)
|
||||
{
|
||||
EnsureCacheDirectory();
|
||||
var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(chapterId);
|
||||
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId);
|
||||
var extractPath = GetCachePath(chapterId);
|
||||
|
||||
if (!Directory.Exists(extractPath))
|
||||
@ -192,7 +192,7 @@ namespace API.Services
|
||||
{
|
||||
// Calculate what chapter the page belongs to
|
||||
var pagesSoFar = 0;
|
||||
var chapterFiles = chapter.Files ?? await _unitOfWork.VolumeRepository.GetFilesForChapterAsync(chapter.Id);
|
||||
var chapterFiles = chapter.Files ?? await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapter.Id);
|
||||
foreach (var mangaFile in chapterFiles)
|
||||
{
|
||||
if (page <= (mangaFile.Pages + pagesSoFar))
|
||||
|
126
UI/Web/package-lock.json
generated
126
UI/Web/package-lock.json
generated
@ -2602,6 +2602,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"@polka/url": {
|
||||
"version": "1.0.0-next.20",
|
||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.20.tgz",
|
||||
"integrity": "sha512-88p7+M0QGxKpmnkfXjS4V26AnoC/eiqZutE8GLdaI5X12NY75bXSdTY9NkmYb2Xyk1O+MmkuO6Frmsj84V6I8Q=="
|
||||
},
|
||||
"@schematics/angular": {
|
||||
"version": "11.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-11.2.11.tgz",
|
||||
@ -5784,6 +5789,11 @@
|
||||
"is-obj": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"duplexer": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz",
|
||||
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="
|
||||
},
|
||||
"duplexify": {
|
||||
"version": "3.7.1",
|
||||
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz",
|
||||
@ -6979,6 +6989,14 @@
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"gzip-size": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz",
|
||||
"integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==",
|
||||
"requires": {
|
||||
"duplexer": "^0.1.2"
|
||||
}
|
||||
},
|
||||
"handle-thing": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz",
|
||||
@ -11188,6 +11206,11 @@
|
||||
"is-wsl": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"opener": {
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz",
|
||||
"integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A=="
|
||||
},
|
||||
"opn": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/opn/-/opn-5.5.0.tgz",
|
||||
@ -14039,6 +14062,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"sirv": {
|
||||
"version": "1.0.17",
|
||||
"resolved": "https://registry.npmjs.org/sirv/-/sirv-1.0.17.tgz",
|
||||
"integrity": "sha512-qx9go5yraB7ekT7bCMqUHJ5jEaOC/GXBxUWv+jeWnb7WzHUFdcQPGWk7YmAwFBaQBrogpuSqd/azbC2lZRqqmw==",
|
||||
"requires": {
|
||||
"@polka/url": "^1.0.0-next.20",
|
||||
"mime": "^2.3.1",
|
||||
"totalist": "^1.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"mime": {
|
||||
"version": "2.5.2",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-2.5.2.tgz",
|
||||
"integrity": "sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"sisteransi": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
|
||||
@ -15088,6 +15128,11 @@
|
||||
"integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==",
|
||||
"dev": true
|
||||
},
|
||||
"totalist": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/totalist/-/totalist-1.1.0.tgz",
|
||||
"integrity": "sha512-gduQwd1rOdDMGxFG1gEvhV88Oirdo2p+KjoYFU7k2g+i7n6AFFbDQ5kMPUsW0pNbfQsB/cwXvT1i4Bue0s9g5g=="
|
||||
},
|
||||
"tough-cookie": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz",
|
||||
@ -16242,6 +16287,87 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"webpack-bundle-analyzer": {
|
||||
"version": "4.4.2",
|
||||
"resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.4.2.tgz",
|
||||
"integrity": "sha512-PIagMYhlEzFfhMYOzs5gFT55DkUdkyrJi/SxJp8EF3YMWhS+T9vvs2EoTetpk5qb6VsCq02eXTlRDOydRhDFAQ==",
|
||||
"requires": {
|
||||
"acorn": "^8.0.4",
|
||||
"acorn-walk": "^8.0.0",
|
||||
"chalk": "^4.1.0",
|
||||
"commander": "^6.2.0",
|
||||
"gzip-size": "^6.0.0",
|
||||
"lodash": "^4.17.20",
|
||||
"opener": "^1.5.2",
|
||||
"sirv": "^1.0.7",
|
||||
"ws": "^7.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"acorn": {
|
||||
"version": "8.5.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.5.0.tgz",
|
||||
"integrity": "sha512-yXbYeFy+jUuYd3/CDcg2NkIYE991XYX/bje7LmjJigUciaeO1JR4XxXgCIV1/Zc/dRuFEyw1L0pbA+qynJkW5Q=="
|
||||
},
|
||||
"acorn-walk": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz",
|
||||
"integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA=="
|
||||
},
|
||||
"ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"requires": {
|
||||
"color-convert": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"requires": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
}
|
||||
},
|
||||
"color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"requires": {
|
||||
"color-name": "~1.1.4"
|
||||
}
|
||||
},
|
||||
"color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
|
||||
},
|
||||
"commander": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz",
|
||||
"integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA=="
|
||||
},
|
||||
"has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="
|
||||
},
|
||||
"supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"requires": {
|
||||
"has-flag": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"ws": {
|
||||
"version": "7.5.5",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.5.tgz",
|
||||
"integrity": "sha512-BAkMFcAzl8as1G/hArkxOxq3G7pjUqQ3gzYbLL0/5zNkph70e+lCoxBGnm6AW1+/aiNeV4fnKqZ8m4GZewmH2w=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"webpack-dev-middleware": {
|
||||
"version": "3.7.2",
|
||||
"resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-3.7.2.tgz",
|
||||
|
@ -6,7 +6,7 @@
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"prod": "ng build --prod",
|
||||
"explore": "ng build --stats-json && webpack-bundle-analyzer ../kavita/API/wwwroot/stats.json",
|
||||
"explore": "ng build --stats-json && webpack-bundle-analyzer dist/stats.json",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
@ -46,6 +46,7 @@
|
||||
"rxjs": "~6.6.0",
|
||||
"swiper": "^6.5.8",
|
||||
"tslib": "^2.0.0",
|
||||
"webpack-bundle-analyzer": "^4.4.2",
|
||||
"zone.js": "~0.10.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -8,7 +8,6 @@ export interface Series {
|
||||
localizedName: string;
|
||||
sortName: string;
|
||||
summary: string;
|
||||
coverImage: string; // This is not passed from backend any longer. TODO: Remove this field
|
||||
coverImageLocked: boolean;
|
||||
volumes: Volume[];
|
||||
pages: number; // Total pages in series
|
||||
|
@ -9,7 +9,7 @@
|
||||
<div class="form-group">
|
||||
<label for="filter">Filter</label>
|
||||
<div class="input-group">
|
||||
<input id="filter" autocomplete="false" class="form-control" [(ngModel)]="filterQuery" type="text" aria-describedby="reset-input">
|
||||
<input id="filter" autocomplete="off" class="form-control" [(ngModel)]="filterQuery" type="text" aria-describedby="reset-input">
|
||||
<div class="input-group-append">
|
||||
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="filterQuery = '';">Clear</button>
|
||||
</div>
|
||||
|
@ -12,10 +12,10 @@ import { DirectoryPickerComponent } from './_modals/directory-picker/directory-p
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { ResetPasswordModalComponent } from './_modals/reset-password-modal/reset-password-modal.component';
|
||||
import { ManageSettingsComponent } from './manage-settings/manage-settings.component';
|
||||
import { FilterPipe } from './filter.pipe';
|
||||
import { EditRbsModalComponent } from './_modals/edit-rbs-modal/edit-rbs-modal.component';
|
||||
import { ManageSystemComponent } from './manage-system/manage-system.component';
|
||||
import { ChangelogComponent } from './changelog/changelog.component';
|
||||
import { PipeModule } from '../pipe/pipe.module';
|
||||
|
||||
|
||||
|
||||
@ -30,7 +30,6 @@ import { ChangelogComponent } from './changelog/changelog.component';
|
||||
DirectoryPickerComponent,
|
||||
ResetPasswordModalComponent,
|
||||
ManageSettingsComponent,
|
||||
FilterPipe,
|
||||
EditRbsModalComponent,
|
||||
ManageSystemComponent,
|
||||
ChangelogComponent,
|
||||
@ -44,6 +43,7 @@ import { ChangelogComponent } from './changelog/changelog.component';
|
||||
NgbTooltipModule,
|
||||
NgbDropdownModule,
|
||||
SharedModule,
|
||||
PipeModule
|
||||
],
|
||||
providers: []
|
||||
})
|
||||
|
@ -112,9 +112,9 @@
|
||||
<div #readingSection class="reading-section" [ngStyle]="{'padding-top': topOffset + 20 + 'px'}" [@isLoading]="isLoading ? true : false" (click)="handleReaderClick($event)">
|
||||
<div #readingHtml [innerHtml]="page" *ngIf="page !== undefined"></div>
|
||||
|
||||
<div class="left {{clickOverlayClass('left')}}" (click)="prevPage()" *ngIf="clickToPaginate">
|
||||
<div class="left {{clickOverlayClass('left')}} no-observe" (click)="prevPage()" *ngIf="clickToPaginate">
|
||||
</div>
|
||||
<div class="right {{clickOverlayClass('right')}}" (click)="nextPage()" *ngIf="clickToPaginate">
|
||||
<div class="right {{clickOverlayClass('right')}} no-observe" (click)="nextPage()" *ngIf="clickToPaginate">
|
||||
</div>
|
||||
|
||||
<div [ngStyle]="{'padding-top': topOffset + 20 + 'px'}" *ngIf="page !== undefined && scrollbarNeeded">
|
||||
|
@ -288,14 +288,17 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
const alreadyReached = Object.values(this.pageAnchors).filter((i: number) => i <= verticalOffset);
|
||||
if (alreadyReached.length > 0) {
|
||||
this.currentPageAnchor = Object.keys(this.pageAnchors)[alreadyReached.length - 1];
|
||||
|
||||
if (!this.incognitoMode) {
|
||||
this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, this.pageNum, this.lastSeenScrollPartPath).pipe(take(1)).subscribe(() => {/* No operation */});
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
this.currentPageAnchor = '';
|
||||
}
|
||||
}
|
||||
|
||||
if (this.lastSeenScrollPartPath !== '') {
|
||||
if (this.lastSeenScrollPartPath !== '' && !this.incognitoMode) {
|
||||
this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, this.pageNum, this.lastSeenScrollPartPath).pipe(take(1)).subscribe(() => {/* No operation */});
|
||||
}
|
||||
});
|
||||
@ -443,7 +446,9 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
handleIntersection(entries: IntersectionObserverEntry[]) {
|
||||
const intersectingEntries = Array.from(entries).filter(entry => entry.isIntersecting).map(entry => entry.target);
|
||||
let intersectingEntries = Array.from(entries)
|
||||
.filter(entry => entry.isIntersecting)
|
||||
.map(entry => entry.target)
|
||||
intersectingEntries.sort((a: Element, b: Element) => {
|
||||
const aTop = a.getBoundingClientRect().top;
|
||||
const bTop = b.getBoundingClientRect().top;
|
||||
@ -457,6 +462,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
return 0;
|
||||
});
|
||||
|
||||
|
||||
if (intersectingEntries.length > 0) {
|
||||
let path = this.getXPathTo(intersectingEntries[0]);
|
||||
if (path === '') { return; }
|
||||
@ -643,7 +649,9 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
setupPageAnchors() {
|
||||
this.readingSectionElemRef.nativeElement.querySelectorAll('div,o,p,ul,li,a,img,h1,h2,h3,h4,h5,h6,span').forEach(elem => {
|
||||
if (!elem.classList.contains('no-observe')) {
|
||||
this.intersectionObserver.observe(elem);
|
||||
}
|
||||
});
|
||||
|
||||
this.pageAnchors = {};
|
||||
|
@ -117,7 +117,6 @@
|
||||
</div>
|
||||
<div class="row no-gutters">
|
||||
<div class="col">
|
||||
<!-- Is Special: {{volume.isSpecial}} -->
|
||||
<button type="button" class="btn btn-outline-primary" (click)="collapse.toggle()" [attr.aria-expanded]="!volumeCollapsed[volume.name]">
|
||||
View Files
|
||||
</button>
|
||||
|
@ -6,10 +6,38 @@
|
||||
<strong>Prefetched</strong> {{minPageLoaded}}-{{maxPageLoaded}}
|
||||
<strong>Current Page:</strong>{{pageNum}}
|
||||
<strong>Width:</strong> {{webtoonImageWidth}}
|
||||
<strong>Pages:</strong> {{pageNum}} / {{totalPages}}
|
||||
<strong>At Top:</strong> {{atTop}}
|
||||
<strong>At Bottom:</strong> {{atBottom}}
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div *ngIf="atTop" class="spacer top" role="alert" (click)="loadPrevChapter.emit()">
|
||||
<div style="height: 200px"></div>
|
||||
<div>
|
||||
<button class="btn btn-icon mx-auto">
|
||||
<i class="fa fa-angle-double-up animate" aria-hidden="true"></i>
|
||||
</button>
|
||||
<span class="mx-auto text">Previous Chapter</span>
|
||||
<button class="btn btn-icon mx-auto">
|
||||
<i class="fa fa-angle-double-up animate" aria-hidden="true"></i>
|
||||
</button>
|
||||
<span class="sr-only">Scroll up to move to next chapter</span>
|
||||
</div>
|
||||
</div>
|
||||
<ng-container *ngFor="let item of webtoonImages | async; let index = index;">
|
||||
<img src="{{item.src}}" style="display: block" class="mx-auto {{pageNum === item.page && debug ? 'active': ''}}" *ngIf="pageNum >= pageNum - bufferPages && pageNum <= pageNum + bufferPages" rel="nofollow" alt="image" (load)="onImageLoad($event)" id="page-{{item.page}}" [attr.page]="item.page" ondragstart="return false;" onselectstart="return false;">
|
||||
</ng-container>
|
||||
|
||||
<div *ngIf="atBottom" class="spacer bottom" role="alert" (click)="loadPrevChapter.emit()">
|
||||
<div>
|
||||
<button class="btn btn-icon mx-auto">
|
||||
<i class="fa fa-angle-double-down animate" aria-hidden="true"></i>
|
||||
</button>
|
||||
<span class="mx-auto text">Next Chapter</span>
|
||||
<button class="btn btn-icon mx-auto">
|
||||
<i class="fa fa-angle-double-down animate" aria-hidden="true"></i>
|
||||
</button>
|
||||
<span class="sr-only">Scroll down to move to next chapter</span>
|
||||
</div>
|
||||
<div style="height: 200px"></div>
|
||||
</div>
|
||||
|
@ -5,3 +5,27 @@
|
||||
.active {
|
||||
border: 2px solid red;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
cursor: pointer;
|
||||
|
||||
.animate {
|
||||
animation: move-up-down 1s linear infinite;
|
||||
}
|
||||
|
||||
.text {
|
||||
z-index: 101;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes move-up-down {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
@ -1,10 +1,16 @@
|
||||
import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, Renderer2, SimpleChanges } from '@angular/core';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { BehaviorSubject, fromEvent, ReplaySubject, Subject } from 'rxjs';
|
||||
import { debounceTime, takeUntil } from 'rxjs/operators';
|
||||
import { debounceTime, take, takeUntil } from 'rxjs/operators';
|
||||
import { ReaderService } from '../../_services/reader.service';
|
||||
import { PAGING_DIRECTION } from '../_models/reader-enums';
|
||||
import { WebtoonImage } from '../_models/webtoon-image';
|
||||
|
||||
/**
|
||||
* How much additional space should pass, past the original bottom of the document height before we trigger the next chapter load
|
||||
*/
|
||||
const SPACER_SCROLL_INTO_PX = 200;
|
||||
|
||||
@Component({
|
||||
selector: 'app-infinite-scroller',
|
||||
templateUrl: './infinite-scroller.component.html',
|
||||
@ -29,6 +35,8 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
*/
|
||||
@Input() urlProvider!: (page: number) => string;
|
||||
@Output() pageNumberChange: EventEmitter<number> = new EventEmitter<number>();
|
||||
@Output() loadNextChapter: EventEmitter<void> = new EventEmitter<void>();
|
||||
@Output() loadPrevChapter: EventEmitter<void> = new EventEmitter<void>();
|
||||
|
||||
@Input() goToPage: ReplaySubject<number> = new ReplaySubject<number>();
|
||||
|
||||
@ -70,6 +78,18 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
* Denotes each page that has been loaded or not. If pruning is implemented, the key will be deleted.
|
||||
*/
|
||||
imagesLoaded: {[key: number]: number} = {};
|
||||
/**
|
||||
* If the user has scrolled all the way to the bottom. This is used solely for continuous reading
|
||||
*/
|
||||
atBottom: boolean = false;
|
||||
/**
|
||||
* If the user has scrolled all the way to the top. This is used solely for continuous reading
|
||||
*/
|
||||
atTop: boolean = false;
|
||||
/**
|
||||
* Keeps track of the previous scrolling height for restoring scroll position after we inject spacer block
|
||||
*/
|
||||
previousScrollHeightMinusTop: number = 0;
|
||||
/**
|
||||
* Debug mode. Will show extra information
|
||||
*/
|
||||
@ -87,7 +107,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
|
||||
private readonly onDestroy = new Subject<void>();
|
||||
|
||||
constructor(private readerService: ReaderService, private renderer: Renderer2) { }
|
||||
constructor(private readerService: ReaderService, private renderer: Renderer2, private toastr: ToastrService) {}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes.hasOwnProperty('totalPages') && changes['totalPages'].previousValue != changes['totalPages'].currentValue) {
|
||||
@ -145,6 +165,48 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.scrollingDirection = PAGING_DIRECTION.BACKWARDS;
|
||||
}
|
||||
this.prevScrollPosition = verticalOffset;
|
||||
|
||||
// Check if we hit the last page
|
||||
this.checkIfShouldTriggerContinuousReader();
|
||||
|
||||
}
|
||||
|
||||
checkIfShouldTriggerContinuousReader() {
|
||||
if (this.isScrolling) return;
|
||||
|
||||
if (this.scrollingDirection === PAGING_DIRECTION.FORWARD) {
|
||||
let totalHeight = 0;
|
||||
document.querySelectorAll('img[id^="page-"]').forEach(img => totalHeight += img.getBoundingClientRect().height);
|
||||
const totalScroll = document.documentElement.offsetHeight + document.documentElement.scrollTop;
|
||||
|
||||
// If we were at top but have started scrolling down past page 0, remove top spacer
|
||||
if (this.atTop && this.pageNum > 0) {
|
||||
this.atTop = false;
|
||||
}
|
||||
if (totalScroll === totalHeight) {
|
||||
this.atBottom = true;
|
||||
this.setPageNum(this.totalPages);
|
||||
// Scroll user back to original location
|
||||
this.previousScrollHeightMinusTop = document.documentElement.scrollTop;
|
||||
setTimeout(() => document.documentElement.scrollTop = this.previousScrollHeightMinusTop + (SPACER_SCROLL_INTO_PX / 2), 10);
|
||||
} else if (totalScroll >= totalHeight + SPACER_SCROLL_INTO_PX && this.atBottom) {
|
||||
// This if statement will fire once we scroll into the spacer at all
|
||||
this.loadNextChapter.emit();
|
||||
}
|
||||
} else {
|
||||
if (document.documentElement.scrollTop === 0 && this.pageNum === 0) {
|
||||
this.atBottom = false;
|
||||
if (this.atTop) {
|
||||
// If already at top, then we moving on
|
||||
this.loadPrevChapter.emit();
|
||||
}
|
||||
this.atTop = true;
|
||||
// Scroll user back to original location
|
||||
this.previousScrollHeightMinusTop = document.documentElement.scrollHeight - document.documentElement.scrollTop;
|
||||
setTimeout(() => document.documentElement.scrollTop = document.documentElement.scrollHeight - this.previousScrollHeightMinusTop - (SPACER_SCROLL_INTO_PX / 2), 10);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@ -170,6 +232,9 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
initWebtoonReader() {
|
||||
this.imagesLoaded = {};
|
||||
this.webtoonImages.next([]);
|
||||
this.atBottom = false;
|
||||
//this.atTop = document.documentElement.scrollTop === 0 && this.pageNum === 0;
|
||||
this.checkIfShouldTriggerContinuousReader();
|
||||
|
||||
const [startingIndex, endingIndex] = this.calculatePrefetchIndecies();
|
||||
|
||||
@ -236,6 +301,11 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
* @param scrollToPage Optional (default false) parameter to trigger scrolling to the newly set page
|
||||
*/
|
||||
setPageNum(pageNum: number, scrollToPage: boolean = false) {
|
||||
if (pageNum > this.totalPages) {
|
||||
pageNum = this.totalPages;
|
||||
} else if (pageNum < 0) {
|
||||
pageNum = 0;
|
||||
}
|
||||
this.pageNum = pageNum;
|
||||
this.pageNumberChange.emit(this.pageNum);
|
||||
|
||||
|
@ -28,7 +28,7 @@
|
||||
ondragstart="return false;" onselectstart="return false;">
|
||||
</canvas>
|
||||
<div class="webtoon-images" *ngIf="readerMode === READER_MODE.WEBTOON && !isLoading">
|
||||
<app-infinite-scroller [pageNum]="pageNum" [bufferPages]="5" [goToPage]="goToPageEvent" (pageNumberChange)="handleWebtoonPageChange($event)" [totalPages]="maxPages" [urlProvider]="getPageUrl"></app-infinite-scroller>
|
||||
<app-infinite-scroller [pageNum]="pageNum" [bufferPages]="5" [goToPage]="goToPageEvent" (pageNumberChange)="handleWebtoonPageChange($event)" [totalPages]="maxPages - 1" [urlProvider]="getPageUrl" (loadNextChapter)="loadNextChapter()" (loadPrevChapter)="loadPrevChapter()"></app-infinite-scroller>
|
||||
</div>
|
||||
<ng-container *ngIf="readerMode === READER_MODE.MANGA_LR || readerMode === READER_MODE.MANGA_UD"> <!--; else webtoonClickArea; See if people want this mode WEBTOON_WITH_CLICKS-->
|
||||
<div class="{{readerMode === READER_MODE.MANGA_LR ? 'right' : 'top'}} {{clickOverlayClass('right')}}" (click)="handlePageChange($event, 'right')"></div>
|
||||
|
@ -365,6 +365,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
const goToPageNum = this.promptForPage();
|
||||
if (goToPageNum === null) { return; }
|
||||
this.goToPage(parseInt(goToPageNum.trim(), 10));
|
||||
} else if (event.key === KEY_CODES.B) {
|
||||
this.bookmarkPage();
|
||||
}
|
||||
}
|
||||
|
||||
@ -374,7 +376,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.nextChapterDisabled = false;
|
||||
this.prevChapterDisabled = false;
|
||||
this.nextChapterPrefetched = false;
|
||||
this.pageNum = 0; // ?! Why was this 1
|
||||
this.pageNum = 0;
|
||||
|
||||
forkJoin({
|
||||
progress: this.readerService.getProgress(this.chapterId),
|
||||
@ -391,11 +393,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
this.volumeId = results.chapterInfo.volumeId;
|
||||
this.maxPages = results.chapterInfo.pages;
|
||||
console.log('results: ', results);
|
||||
let page = results.progress.pageNum;
|
||||
console.log('page: ', page);
|
||||
console.log('this.pageNum: ', this.pageNum);
|
||||
if (page >= this.maxPages) {
|
||||
if (page > this.maxPages) {
|
||||
page = this.maxPages - 1;
|
||||
}
|
||||
this.setPageNum(page);
|
||||
@ -704,10 +703,10 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
if (this.nextChapterId === CHAPTER_ID_NOT_FETCHED || this.nextChapterId === this.chapterId) {
|
||||
this.readerService.getNextChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).pipe(take(1)).subscribe(chapterId => {
|
||||
this.nextChapterId = chapterId;
|
||||
this.loadChapter(chapterId, 'next');
|
||||
this.loadChapter(chapterId, 'Next');
|
||||
});
|
||||
} else {
|
||||
this.loadChapter(this.nextChapterId, 'next');
|
||||
this.loadChapter(this.nextChapterId, 'Next');
|
||||
}
|
||||
}
|
||||
|
||||
@ -727,14 +726,14 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
if (this.prevChapterId === CHAPTER_ID_NOT_FETCHED || this.prevChapterId === this.chapterId) {
|
||||
this.readerService.getPrevChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).pipe(take(1)).subscribe(chapterId => {
|
||||
this.prevChapterId = chapterId;
|
||||
this.loadChapter(chapterId, 'prev');
|
||||
this.loadChapter(chapterId, 'Prev');
|
||||
});
|
||||
} else {
|
||||
this.loadChapter(this.prevChapterId, 'prev');
|
||||
this.loadChapter(this.prevChapterId, 'Prev');
|
||||
}
|
||||
}
|
||||
|
||||
loadChapter(chapterId: number, direction: 'next' | 'prev') {
|
||||
loadChapter(chapterId: number, direction: 'Next' | 'Prev') {
|
||||
if (chapterId >= 0) {
|
||||
this.chapterId = chapterId;
|
||||
this.continuousChaptersStack.push(chapterId);
|
||||
@ -742,11 +741,12 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
const newRoute = this.readerService.getNextChapterUrl(this.router.url, this.chapterId, this.incognitoMode, this.readingListMode, this.readingListId);
|
||||
window.history.replaceState({}, '', newRoute);
|
||||
this.init();
|
||||
this.toastr.info(direction + ' chapter loaded', '', {timeOut: 3000});
|
||||
} else {
|
||||
// This will only happen if no actual chapter can be found
|
||||
this.toastr.warning('Could not find ' + direction + ' chapter');
|
||||
this.toastr.warning('Could not find ' + direction.toLowerCase() + ' chapter');
|
||||
this.isLoading = false;
|
||||
if (direction === 'prev') {
|
||||
if (direction === 'Prev') {
|
||||
this.prevPageDisabled = true;
|
||||
} else {
|
||||
this.nextPageDisabled = true;
|
||||
@ -1010,7 +1010,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
bookmarkPage() {
|
||||
const pageNum = this.pageNum;
|
||||
if (this.pageBookmarked) {
|
||||
// Remove bookmark
|
||||
this.readerService.unbookmark(this.seriesId, this.volumeId, this.chapterId, pageNum).pipe(take(1)).subscribe(() => {
|
||||
delete this.bookmarks[pageNum];
|
||||
});
|
||||
|
18
UI/Web/src/app/pipe/pipe.module.ts
Normal file
18
UI/Web/src/app/pipe/pipe.module.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FilterPipe } from './filter.pipe';
|
||||
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
FilterPipe
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
],
|
||||
exports: [
|
||||
FilterPipe
|
||||
]
|
||||
})
|
||||
export class PipeModule { }
|
@ -5,11 +5,19 @@
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<form style="width: 100%" [formGroup]="listForm">
|
||||
<div class="modal-body">
|
||||
<!-- TODO: Put filter here -->
|
||||
<div class="form-group" *ngIf="lists.length >= 5">
|
||||
<label for="filter">Filter</label>
|
||||
<div class="input-group">
|
||||
<input id="filter" autocomplete="off" class="form-control" formControlName="filterQuery" type="text" aria-describedby="reset-input">
|
||||
<div class="input-group-append">
|
||||
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="listForm.get('filterQuery')?.setValue('');">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="list-group">
|
||||
<li class="list-group-item clickable" tabindex="0" role="button" *ngFor="let readingList of lists; let i = index" (click)="addToList(readingList)">
|
||||
<!-- Think about using radio buttons maybe for screen reader-->
|
||||
<li class="list-group-item clickable" tabindex="0" role="button" *ngFor="let readingList of lists | filter: filterList; let i = index" (click)="addToList(readingList)">
|
||||
{{readingList.title}} <i class="fa fa-angle-double-up" *ngIf="readingList.promoted" title="Promoted"></i>
|
||||
</li>
|
||||
<li class="list-group-item" *ngIf="lists.length === 0 && !loading">No lists created yet</li>
|
||||
@ -21,17 +29,18 @@
|
||||
</ul>
|
||||
</div>
|
||||
<div class="modal-footer" style="justify-content: normal">
|
||||
<form style="width: 100%" [formGroup]="listForm">
|
||||
<div style="width: 100%;">
|
||||
<div class="form-row">
|
||||
<div class="col-md-10">
|
||||
<div class="col-9 col-lg-10">
|
||||
<label class="sr-only" for="add-rlist">Reading List</label>
|
||||
<input width="100%" #title ngbAutofocus type="text" class="form-control mb-2" id="add-rlist" formControlName="title">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="col-2">
|
||||
<button type="submit" class="btn btn-primary" (click)="create()">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
|
@ -41,6 +41,7 @@ export class AddToListModalComponent implements OnInit, AfterViewInit {
|
||||
ngOnInit(): void {
|
||||
|
||||
this.listForm.addControl('title', new FormControl(this.title, []));
|
||||
this.listForm.addControl('filterQuery', new FormControl('', []));
|
||||
|
||||
this.loading = true;
|
||||
this.readingListService.getReadingLists(false).subscribe(lists => {
|
||||
@ -87,4 +88,8 @@ export class AddToListModalComponent implements OnInit, AfterViewInit {
|
||||
|
||||
}
|
||||
|
||||
filterList = (listItem: ReadingList) => {
|
||||
return listItem.title.toLowerCase().indexOf((this.listForm.value.filterQuery || '').toLowerCase()) >= 0;
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -29,7 +29,9 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2" *ngIf="readingList.summary.length > 0">{{readingList.summary}}</p>
|
||||
<div class="row no-gutters mt-2">
|
||||
<app-read-more [text]="readingList.summary" [maxLength]="250"></app-read-more>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -44,14 +46,21 @@
|
||||
<img width="74px" style="width: 74px;" class="img-top lazyload mr-3" [src]="imageService.placeholderImage" [attr.data-src]="imageService.getChapterCoverImage(item.chapterId)"
|
||||
(error)="imageService.updateErroredImage($event)">
|
||||
<div class="media-body">
|
||||
<h5 class="mt-0 mb-1" id="item.id--{{position}}">{{formatTitle(item)}}</h5>
|
||||
<i class="fa {{utilityService.mangaFormatIcon(item.seriesFormat)}}" aria-hidden="true" *ngIf="item.seriesFormat != MangaFormat.UNKNOWN" title="{{utilityService.mangaFormat(item.seriesFormat)}}"></i><span class="sr-only">{{utilityService.mangaFormat(item.seriesFormat)}}</span>
|
||||
<h5 class="mt-0 mb-1" id="item.id--{{position}}">{{formatTitle(item)}}
|
||||
<span class="badge badge-primary badge-pill">
|
||||
<span *ngIf="item.pagesRead > 0 && item.pagesRead < item.pagesTotal">{{item.pagesRead}} / {{item.pagesTotal}}</span>
|
||||
<span *ngIf="item.pagesRead === 0">UNREAD</span>
|
||||
<span *ngIf="item.pagesRead === item.pagesTotal">READ</span>
|
||||
</span>
|
||||
</h5>
|
||||
<i class="fa {{utilityService.mangaFormatIcon(item.seriesFormat)}}" aria-hidden="true" *ngIf="item.seriesFormat != MangaFormat.UNKNOWN" title="{{utilityService.mangaFormat(item.seriesFormat)}}"></i>
|
||||
<span class="sr-only">{{utilityService.mangaFormat(item.seriesFormat)}}</span>
|
||||
|
||||
<a href="/library/{{item.libraryId}}/series/{{item.seriesId}}">{{item.seriesName}}</a>
|
||||
<span *ngIf="item.promoted">
|
||||
<i class="fa fa-angle-double-up" aria-hidden="true"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="pull-right" *ngIf="item.pagesRead === item.pagesTotal"><i class="fa fa-check-square" aria-label="Read"></i></div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-dragable-ordered-list>
|
||||
|
@ -10,7 +10,6 @@ import { AccountService } from 'src/app/_services/account.service';
|
||||
import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/action-factory.service';
|
||||
import { ActionService } from 'src/app/_services/action.service';
|
||||
import { ImageService } from 'src/app/_services/image.service';
|
||||
import { ReaderService } from 'src/app/_services/reader.service';
|
||||
import { ReadingListService } from 'src/app/_services/reading-list.service';
|
||||
import { IndexUpdateEvent, ItemRemoveEvent } from '../dragable-ordered-list/dragable-ordered-list.component';
|
||||
|
||||
@ -28,6 +27,11 @@ export class ReadingListDetailComponent implements OnInit {
|
||||
isAdmin: boolean = false;
|
||||
isLoading: boolean = false;
|
||||
|
||||
// Downloading
|
||||
hasDownloadingRole: boolean = false;
|
||||
downloadInProgress: boolean = false;
|
||||
|
||||
|
||||
get MangaFormat(): typeof MangaFormat {
|
||||
return MangaFormat;
|
||||
}
|
||||
@ -58,6 +62,7 @@ export class ReadingListDetailComponent implements OnInit {
|
||||
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||
if (user) {
|
||||
this.isAdmin = this.accountService.hasAdminRole(user);
|
||||
this.hasDownloadingRole = this.accountService.hasDownloadRole(user);
|
||||
|
||||
this.actions = this.actionFactoryService.getReadingListActions(this.handleReadingListActionCallback.bind(this)).filter(action => this.readingListService.actionListFilter(action, readingList, this.isAdmin));
|
||||
}
|
||||
|
@ -9,6 +9,8 @@ import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { CardsModule } from '../cards/cards.module';
|
||||
import { ReadingListsComponent } from './reading-lists/reading-lists.component';
|
||||
import { EditReadingListModalComponent } from './_modals/edit-reading-list-modal/edit-reading-list-modal.component';
|
||||
import { PipeModule } from '../pipe/pipe.module';
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
|
||||
|
||||
|
||||
@ -25,7 +27,9 @@ import { EditReadingListModalComponent } from './_modals/edit-reading-list-modal
|
||||
ReadingListRoutingModule,
|
||||
ReactiveFormsModule,
|
||||
DragDropModule,
|
||||
CardsModule
|
||||
CardsModule,
|
||||
PipeModule,
|
||||
SharedModule
|
||||
],
|
||||
exports: [
|
||||
AddToListModalComponent,
|
||||
|
@ -7,11 +7,10 @@ const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
runGuardsAndResolvers: 'always',
|
||||
canActivate: [AuthGuard], // TODO: Add a guard if they have access to said :id
|
||||
canActivate: [AuthGuard],
|
||||
children: [
|
||||
{path: '', component: ReadingListDetailComponent, pathMatch: 'full'},
|
||||
{path: ':id', component: ReadingListDetailComponent, pathMatch: 'full'},
|
||||
// {path: ':id', component: CollectionDetailComponent},
|
||||
]
|
||||
}
|
||||
];
|
||||
|
@ -6,6 +6,7 @@ import { PaginatedResult, Pagination } from 'src/app/_models/pagination';
|
||||
import { ReadingList } from 'src/app/_models/reading-list';
|
||||
import { AccountService } from 'src/app/_services/account.service';
|
||||
import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/action-factory.service';
|
||||
import { ActionService } from 'src/app/_services/action.service';
|
||||
import { ImageService } from 'src/app/_services/image.service';
|
||||
import { ReadingListService } from 'src/app/_services/reading-list.service';
|
||||
|
||||
@ -23,7 +24,7 @@ export class ReadingListsComponent implements OnInit {
|
||||
isAdmin: boolean = false;
|
||||
|
||||
constructor(private readingListService: ReadingListService, public imageService: ImageService, private actionFactoryService: ActionFactoryService,
|
||||
private accountService: AccountService, private toastr: ToastrService, private router: Router) { }
|
||||
private accountService: AccountService, private toastr: ToastrService, private router: Router, private actionService: ActionService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadPage();
|
||||
@ -53,6 +54,13 @@ export class ReadingListsComponent implements OnInit {
|
||||
this.toastr.success('Reading list deleted');
|
||||
this.loadPage();
|
||||
});
|
||||
break;
|
||||
case Action.Edit:
|
||||
this.actionService.editReadingList(readingList, (updatedList: ReadingList) => {
|
||||
// Reload information around list
|
||||
readingList = updatedList;
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -73,7 +73,7 @@ export class DownloadService {
|
||||
}));
|
||||
}
|
||||
|
||||
async confirmSize(size: number, entityType: 'volume' | 'chapter' | 'series') {
|
||||
async confirmSize(size: number, entityType: 'volume' | 'chapter' | 'series' | 'reading list') {
|
||||
return (size < this.SIZE_WARNING || await this.confirmService.confirm('The ' + entityType + ' is ' + this.humanFileSize(size) + '. Are you sure you want to continue?'));
|
||||
}
|
||||
|
||||
@ -85,6 +85,8 @@ export class DownloadService {
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Format bytes as human-readable text.
|
||||
*
|
||||
|
@ -13,6 +13,7 @@ export enum KEY_CODES {
|
||||
SPACE = ' ',
|
||||
ENTER = 'Enter',
|
||||
G = 'g',
|
||||
B = 'b',
|
||||
BACKSPACE = 'Backspace',
|
||||
DELETE = 'Delete'
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user