Reading Lists & More (#564)

* Added continous reading to the book reader. Clicking on the max pages to right of progress bar will now go to last page.

* Forgot a file for continous book reading

* Fixed up some code regarding transitioning between chapters. Arrows now show to represent a chapter transition.

* Laid the foundation for reading lists

* All foundation is laid out. Actions are wired in the UI. Backend repository is setup. Redid the migration to have ReadingList track modification so we can order them for the user.

* Updated add modal to have basic skeleton

* Hooked up ability to fetch reading lists from backend

* Made a huge performance improvement to GetChapterIdsForSeriesAsync() by reducing a JOIN and an iteration loop. Improvement went from 2 seconds -> 200 ms.

* Implemented the ability to add all chapters in a series to a reading list.

* Fixed issue with adding new items to reading list not being in a logical order. Lots of work on getting all the information around the reading list view. Added some foreign keys back to chapter so delete should clean up after itself.

* Added ability to open directly the series

* Reading List Items now have progress attached

* Hooked up list deletion and added a case where if doesn't exist on load, then redirect to library.

* Lots of changes. Introduced a dashboard component for the main app. This will sit on libraries route for now and will have 3 tabs to show different sections.

Moved libraries reel down to bottom as people are more likely to access recently added or in progress than explore their whole library.

Note: Bundles are messed up, they need to be reoptimized and routes need to be updated.

* Added pagination to the reading lists api and implemented a page to show all lists

* Cleaned up old code from all-collections component so now it only handles all collections and doesn't have the old code for an individual collection

* Hooked in actions and navigation on reading lists

* When the user re-arranges items, they are now persisted

* Implemented remove read, but performance is pretty poor. Needs to be optimized.

* Lots of API fixes for adding items to a series, returning items, etc. Committing before fixing incorrect fetches of items for a readingListId.

* Rewrote the joins for GetReadingListItemDtosByIdAsync() to not return extra records.

* Remove bug marker now that it is fixed

* Refactor update-by-series to move more of the code to a re-usable function for update-by-volume/chapter APIs

* Implemented the ability to add via series, volume or chapter.

* Added OPDS support for reading lists. This included adding VolumeId to the ReadingListDto.

* Fixed a bug with deleting items

* After we create a library inform user that a scan has started

* Added some extra help information for users on directory picker, since linux users were getting confused.

* Setup for the reading functionality

* Fixed an issue where opening the edit series modal and pressing save without doing anything would empty collection tags. Would happen often when editing cover images.

* Fixed get-next-chapter for reading list. Refactored all methods to use the new GetUserIdByUsernameAsync(), which is much faster and uses less memory.

* Hooked in prev chapter for continuous reading with reading list

* Hooked up the read code for manga reader and book reader to have list id passed

* Manga reader now functions completely with reading lists

* Implemented reading list and incognito mode into book reader

* Refactored some common reading code into reader service

* Added support for "Series -  - Vol. 03 Ch. 023.5 - Volume 3 Extras.cbz" format that can occur with FMD2.

* Implemented continuous reading with a reading list between different readers. This incurs a 3x performance hit on the book info api.

* style changes. Don't emit an event if position of draggable item hasn't changed

* Styling and added the edit reading list flow.

* Cleaned up some extra spaces when actionables isn't shown. Lots of cleanup for promoted lists.

* Refactored some filter code to a common service

* Added an RBS check in getting Items for a given user.

* Code smells

* More smells
This commit is contained in:
Joseph Milazzo 2021-09-08 10:03:27 -07:00 committed by GitHub
parent d65e49926a
commit cf7a9aa71e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
117 changed files with 7050 additions and 305 deletions

View File

@ -35,6 +35,5 @@ namespace API.Benchmark
var parsedSeries = _parseScannedFiles.ScanLibrariesForSeries(LibraryType.Manga, new string[] {libraryPath},
out var totalFiles, out var scanElapsedTime);
}
}
}

View File

@ -66,6 +66,7 @@ namespace API.Tests.Parser
[InlineData("Noblesse - Episode 406 (52 Pages).7z", "0")]
[InlineData("X-Men v1 #201 (September 2007).cbz", "1")]
[InlineData("Hentai Ouji to Warawanai Neko. - Vol. 06 Ch. 034.5", "6")]
[InlineData("The 100 Girlfriends Who Really, Really, Really, Really, Really Love You - Vol. 03 Ch. 023.5 - Volume 3 Extras.cbz", "3")]
public void ParseVolumeTest(string filename, string expected)
{
Assert.Equal(expected, API.Parser.Parser.ParseVolume(filename));
@ -128,7 +129,6 @@ namespace API.Tests.Parser
[InlineData("Fullmetal Alchemist chapters 101-108.cbz", "Fullmetal Alchemist")]
[InlineData("To Love Ru v09 Uncensored (Ch.071-079).cbz", "To Love Ru")]
[InlineData("[dmntsf.net] One Piece - Digital Colored Comics Vol. 20 Ch. 177 - 30 Million vs 81 Million.cbz", "One Piece - Digital Colored Comics")]
//[InlineData("Corpse Party -The Anthology- Sachikos game of love Hysteric Birthday 2U Extra Chapter", "Corpse Party -The Anthology- Sachikos game of love Hysteric Birthday 2U")]
[InlineData("Corpse Party -The Anthology- Sachikos game of love Hysteric Birthday 2U Chapter 01", "Corpse Party -The Anthology- Sachikos game of love Hysteric Birthday 2U")]
[InlineData("Vol03_ch15-22.rar", "")]
[InlineData("Love Hina - Special.cbz", "")] // This has to be a fallback case
@ -157,6 +157,7 @@ namespace API.Tests.Parser
[InlineData("Killing Bites - Vol 11 Chapter 050 Save Me, Nunupi!.cbz", "Killing Bites")]
[InlineData("Mad Chimera World - Volume 005 - Chapter 026.cbz", "Mad Chimera World")]
[InlineData("Hentai Ouji to Warawanai Neko. - Vol. 06 Ch. 034.5", "Hentai Ouji to Warawanai Neko.")]
[InlineData("The 100 Girlfriends Who Really, Really, Really, Really, Really Love You - Vol. 03 Ch. 023.5 - Volume 3 Extras.cbz", "The 100 Girlfriends Who Really, Really, Really, Really, Really Love You")]
public void ParseSeriesTest(string filename, string expected)
{
Assert.Equal(expected, API.Parser.Parser.ParseSeries(filename));

View File

@ -2,6 +2,8 @@
using System.Linq;
using System.Threading.Tasks;
using API.DTOs;
using API.DTOs.Reader;
using API.Entities.Enums;
using API.Extensions;
using API.Interfaces;
using API.Interfaces.Services;
@ -31,12 +33,31 @@ namespace API.Controllers
}
[HttpGet("{chapterId}/book-info")]
public async Task<ActionResult<string>> GetBookInfo(int chapterId)
public async Task<ActionResult<BookInfoDto>> GetBookInfo(int chapterId)
{
// PERF: Write this in one DB call - This does not meet NFR
var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(chapterId);
using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath);
var volume = await _unitOfWork.SeriesRepository.GetVolumeDtoAsync(chapter.VolumeId);
if (volume == null) return BadRequest("Could not find Volume");
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId);
if (series == null) return BadRequest("Series could not be found");
return book.Title;
var bookTitle = string.Empty;
if (series.Format == MangaFormat.Epub)
{
using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath);
bookTitle = book.Title;
}
return new BookInfoDto()
{
BookTitle = bookTitle,
VolumeId = chapter.VolumeId,
SeriesFormat = series.Format,
SeriesId = series.Id,
LibraryId = series.LibraryId,
};
}
[HttpGet("{chapterId}/book-resources")]

View File

@ -225,11 +225,11 @@ namespace API.Controllers
[HttpGet("search")]
public async Task<ActionResult<IEnumerable<SearchResultDto>>> Search(string queryString)
{
queryString = queryString.Replace(@"%", "");
queryString = queryString.Trim().Replace(@"%", "");
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
// 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");

View File

@ -93,6 +93,19 @@ namespace API.Controllers
}
});
feed.Entries.Add(new FeedEntry()
{
Id = "readingList",
Title = "Reading Lists",
Content = new FeedEntryContent()
{
Text = "Browse by Reading Lists"
},
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/reading-list"),
}
});
feed.Entries.Add(new FeedEntry()
{
Id = "allLibraries",
Title = "All Libraries",
@ -190,6 +203,7 @@ namespace API.Controllers
return CreateXmlResult(SerializeXml(feed));
}
[HttpGet("{apiKey}/collections/{collectionId}")]
[Produces("application/xml")]
public async Task<IActionResult> GetCollection(int collectionId, string apiKey, [FromQuery] int pageNumber = 0)
@ -230,6 +244,76 @@ namespace API.Controllers
}
return CreateXmlResult(SerializeXml(feed));
}
[HttpGet("{apiKey}/reading-list")]
[Produces("application/xml")]
public async Task<IActionResult> GetReadingLists(string apiKey, [FromQuery] int pageNumber = 0)
{
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest("OPDS is not enabled on this server");
var user = await GetUser(apiKey);
var readingLists = await _unitOfWork.ReadingListRepository.GetReadingListDtosForUserAsync(user.Id, true, new UserParams()
{
PageNumber = pageNumber
});
var feed = CreateFeed("All Reading Lists", $"{apiKey}/reading-list", apiKey);
foreach (var readingListDto in readingLists)
{
feed.Entries.Add(new FeedEntry()
{
Id = readingListDto.Id.ToString(),
Title = readingListDto.Title,
Summary = readingListDto.Summary,
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/reading-list/{readingListDto.Id}"),
}
});
}
return CreateXmlResult(SerializeXml(feed));
}
[HttpGet("{apiKey}/reading-list/{readingListId}")]
[Produces("application/xml")]
public async Task<IActionResult> GetReadingListItems(int readingListId, string apiKey)
{
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest("OPDS is not enabled on this server");
var user = await GetUser(apiKey);
var userWithLists = await _unitOfWork.UserRepository.GetUserWithReadingListsByUsernameAsync(user.UserName);
var readingList = userWithLists.ReadingLists.SingleOrDefault(t => t.Id == readingListId);
if (readingList == null)
{
return BadRequest("Reading list does not exist or you don't have access");
}
var feed = CreateFeed(readingList.Title + " Reading List", $"{apiKey}/reading-list/{readingListId}", apiKey);
var items = await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, user.Id);
foreach (var item in items)
{
feed.Entries.Add(new FeedEntry()
{
Id = item.ChapterId.ToString(),
Title = "Chapter " + item.ChapterNumber,
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/series/{item.SeriesId}/volume/{item.VolumeId}/chapter/{item.ChapterId}"),
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/chapter-cover?chapterId={item.ChapterId}")
}
});
}
return CreateXmlResult(SerializeXml(feed));
}

View File

@ -49,7 +49,7 @@ namespace API.Controllers
[HttpGet("image")]
public async Task<ActionResult> GetImage(int chapterId, int page)
{
if (page < 0) return BadRequest("Page cannot be less than 0");
if (page < 0) page = 0;
var chapter = await _cacheService.Ensure(chapterId);
if (chapter == null) return BadRequest("There was an issue finding image file for reading");
@ -76,20 +76,21 @@ namespace API.Controllers
/// <summary>
/// Returns various information about a Chapter. Side effect: This will cache the chapter images for reading.
/// </summary>
/// <param name="seriesId"></param>
/// <param name="seriesId">Not used</param>
/// <param name="chapterId"></param>
/// <returns></returns>
[HttpGet("chapter-info")]
public async Task<ActionResult<ChapterInfoDto>> GetChapterInfo(int seriesId, int chapterId)
{
// PERF: Write this in one DB call
// PERF: Write this in one DB call - This does not meet NFR
var chapter = await _cacheService.Ensure(chapterId);
if (chapter == null) return BadRequest("Could not find Chapter");
var volume = await _unitOfWork.SeriesRepository.GetVolumeDtoAsync(chapter.VolumeId);
if (volume == null) return BadRequest("Could not find Volume");
var mangaFile = (await _unitOfWork.VolumeRepository.GetFilesForChapterAsync(chapterId)).First();
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId);
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId);
if (series == null) return BadRequest("Series could not be found");
return Ok(new ChapterInfoDto()
{
@ -97,7 +98,10 @@ namespace API.Controllers
VolumeNumber = volume.Number + string.Empty,
VolumeId = volume.Id,
FileName = Path.GetFileName(mangaFile.FilePath),
SeriesName = series?.Name,
SeriesName = series.Name,
SeriesFormat = series.Format,
SeriesId = series.Id,
LibraryId = series.LibraryId,
IsSpecial = chapter.IsSpecial,
Pages = chapter.Pages,
});
@ -526,8 +530,8 @@ namespace API.Controllers
[HttpGet("next-chapter")]
public async Task<ActionResult<int>> GetNextChapter(int seriesId, int volumeId, int currentChapterId)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
var volumes = await _unitOfWork.SeriesRepository.GetVolumesDtoAsync(seriesId, user.Id);
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)
@ -592,8 +596,8 @@ namespace API.Controllers
[HttpGet("prev-chapter")]
public async Task<ActionResult<int>> GetPreviousChapter(int seriesId, int volumeId, int currentChapterId)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
var volumes = await _unitOfWork.SeriesRepository.GetVolumesDtoAsync(seriesId, user.Id);
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);

View File

@ -0,0 +1,404 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Comparators;
using API.DTOs.ReadingLists;
using API.Entities;
using API.Extensions;
using API.Helpers;
using API.Interfaces;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace API.Controllers
{
public class ReadingListController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst();
public ReadingListController(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}
[HttpGet]
public async Task<ActionResult<IEnumerable<ReadingListDto>>> GetList(int readingListId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.ReadingListRepository.GetReadingListDtoByIdAsync(readingListId, userId));
}
/// <summary>
/// Returns reading lists (paginated) for a given user.
/// </summary>
/// <param name="includePromoted">Defaults to true</param>
/// <returns></returns>
[HttpPost("lists")]
public async Task<ActionResult<IEnumerable<ReadingListDto>>> GetListsForUser([FromQuery] UserParams userParams, [FromQuery] bool includePromoted = true)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var items = await _unitOfWork.ReadingListRepository.GetReadingListDtosForUserAsync(userId, includePromoted,
userParams);
Response.AddPaginationHeader(items.CurrentPage, items.PageSize, items.TotalCount, items.TotalPages);
return Ok(items);
}
/// <summary>
/// Fetches all reading list items for a given list including rich metadata around series, volume, chapters, and progress
/// </summary>
/// <remarks>This call is expensive</remarks>
/// <param name="readingListId"></param>
/// <returns></returns>
[HttpGet("items")]
public async Task<ActionResult<IEnumerable<ReadingListItemDto>>> GetListForUser(int readingListId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var items = await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId);
return Ok(await _unitOfWork.ReadingListRepository.AddReadingProgressModifiers(userId, items.ToList()));
}
/// <summary>
/// Updates an items position
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("update-position")]
public async Task<ActionResult> UpdateListItemPosition(UpdateReadingListPosition dto)
{
// Make sure UI buffers events
var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemsByIdAsync(dto.ReadingListId)).ToList();
var item = items.Find(r => r.Id == dto.ReadingListItemId);
items.Remove(item);
items.Insert(dto.ToPosition, item);
for (var i = 0; i < items.Count; i++)
{
items[i].Order = i;
}
if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync())
{
return Ok("Updated");
}
return BadRequest("Couldn't update position");
}
[HttpPost("delete-item")]
public async Task<ActionResult> DeleteListItem(UpdateReadingListPosition dto)
{
var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemsByIdAsync(dto.ReadingListId)).ToList();
var item = items.Find(r => r.Id == dto.ReadingListItemId);
items.Remove(item);
for (var i = 0; i < items.Count; i++)
{
items[i].Order = i;
}
if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync())
{
return Ok("Updated");
}
return BadRequest("Couldn't delete item");
}
/// <summary>
/// Removes all entries that are fully read from the reading list
/// </summary>
/// <param name="readingListId"></param>
/// <returns></returns>
[HttpPost("remove-read")]
public async Task<ActionResult> DeleteReadFromList([FromQuery] int readingListId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var items = await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId);
items = await _unitOfWork.ReadingListRepository.AddReadingProgressModifiers(userId, items.ToList());
// Collect all Ids to remove
var itemIdsToRemove = items.Where(item => item.PagesRead == item.PagesTotal).Select(item => item.Id);
try
{
var listItems =
(await _unitOfWork.ReadingListRepository.GetReadingListItemsByIdAsync(readingListId)).Where(r =>
itemIdsToRemove.Contains(r.Id));
_unitOfWork.ReadingListRepository.BulkRemove(listItems);
if (_unitOfWork.HasChanges())
{
await _unitOfWork.CommitAsync();
return Ok("Updated");
}
else
{
return Ok("Nothing to remove");
}
}
catch
{
await _unitOfWork.RollbackAsync();
}
return BadRequest("Could not remove read items");
}
/// <summary>
/// Deletes a reading list
/// </summary>
/// <param name="readingListId"></param>
/// <returns></returns>
[HttpDelete]
public async Task<ActionResult> DeleteList([FromQuery] int readingListId)
{
var user = await _unitOfWork.UserRepository.GetUserWithReadingListsByUsernameAsync(User.GetUsername());
var readingList = user.ReadingLists.SingleOrDefault(r => r.Id == readingListId);
if (readingList == null)
{
return BadRequest("User is not associated with this reading list");
}
user.ReadingLists.Remove(readingList);
if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync())
{
return Ok("Deleted");
}
return BadRequest("There was an issue deleting reading list");
}
/// <summary>
/// Creates a new List with a unique title. Returns the new ReadingList back
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("create")]
public async Task<ActionResult<ReadingListDto>> CreateList(CreateReadingListDto dto)
{
var user = await _unitOfWork.UserRepository.GetUserWithReadingListsByUsernameAsync(User.GetUsername());
// When creating, we need to make sure Title is unique
var hasExisting = user.ReadingLists.Any(l => l.Title.Equals(dto.Title));
if (hasExisting)
{
return BadRequest("A list of this name already exists");
}
user.ReadingLists.Add(new ReadingList()
{
Promoted = false,
Title = dto.Title,
Summary = string.Empty
});
if (!_unitOfWork.HasChanges()) return BadRequest("There was a problem creating list");
await _unitOfWork.CommitAsync();
return Ok(await _unitOfWork.ReadingListRepository.GetReadingListDtoByTitleAsync(dto.Title));
}
[HttpPost("update")]
public async Task<ActionResult> UpdateList(UpdateReadingListDto dto)
{
var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(dto.ReadingListId);
if (readingList == null) return BadRequest("List does not exist");
if (!string.IsNullOrEmpty(dto.Title))
{
readingList.Title = dto.Title; // Should I check if this is unique?
}
if (!string.IsNullOrEmpty(dto.Title))
{
readingList.Summary = dto.Summary;
}
readingList.Promoted = dto.Promoted;
_unitOfWork.ReadingListRepository.Update(readingList);
if (await _unitOfWork.CommitAsync())
{
return Ok("Updated");
}
return BadRequest("Could not update reading list");
}
[HttpPost("update-by-series")]
public async Task<ActionResult> UpdateListBySeries(UpdateReadingListBySeriesDto dto)
{
var user = await _unitOfWork.UserRepository.GetUserWithReadingListsByUsernameAsync(User.GetUsername());
var readingList = user.ReadingLists.SingleOrDefault(l => l.Id == dto.ReadingListId);
if (readingList == null) return BadRequest("Reading List does not exist");
var chapterIdsForSeries =
await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(new [] {dto.SeriesId});
// If there are adds, tell tracking this has been modified
if (await AddChaptersToReadingList(dto.SeriesId, chapterIdsForSeries, readingList))
{
_unitOfWork.ReadingListRepository.Update(readingList);
}
try
{
if (_unitOfWork.HasChanges())
{
await _unitOfWork.CommitAsync();
return Ok("Updated");
}
}
catch
{
await _unitOfWork.RollbackAsync();
}
return Ok("Nothing to do");
}
[HttpPost("update-by-volume")]
public async Task<ActionResult> UpdateListByVolume(UpdateReadingListByVolumeDto dto)
{
var user = await _unitOfWork.UserRepository.GetUserWithReadingListsByUsernameAsync(User.GetUsername());
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();
// If there are adds, tell tracking this has been modified
if (await AddChaptersToReadingList(dto.SeriesId, chapterIdsForVolume, readingList))
{
_unitOfWork.ReadingListRepository.Update(readingList);
}
try
{
if (_unitOfWork.HasChanges())
{
await _unitOfWork.CommitAsync();
return Ok("Updated");
}
}
catch
{
await _unitOfWork.RollbackAsync();
}
return Ok("Nothing to do");
}
[HttpPost("update-by-chapter")]
public async Task<ActionResult> UpdateListByChapter(UpdateReadingListByChapterDto dto)
{
var user = await _unitOfWork.UserRepository.GetUserWithReadingListsByUsernameAsync(User.GetUsername());
var readingList = user.ReadingLists.SingleOrDefault(l => l.Id == dto.ReadingListId);
if (readingList == null) return BadRequest("Reading List does not exist");
// If there are adds, tell tracking this has been modified
if (await AddChaptersToReadingList(dto.SeriesId, new List<int>() { dto.ChapterId }, readingList))
{
_unitOfWork.ReadingListRepository.Update(readingList);
}
try
{
if (_unitOfWork.HasChanges())
{
await _unitOfWork.CommitAsync();
return Ok("Updated");
}
}
catch
{
await _unitOfWork.RollbackAsync();
}
return Ok("Nothing to do");
}
/// <summary>
/// Adds a list of Chapters as reading list items to the passed reading list.
/// </summary>
/// <param name="seriesId"></param>
/// <param name="chapterIds"></param>
/// <param name="readingList"></param>
/// <returns>True if new chapters were added</returns>
private async Task<bool> AddChaptersToReadingList(int seriesId, IList<int> chapterIds,
ReadingList readingList)
{
readingList.Items ??= new List<ReadingListItem>();
var lastOrder = 0;
if (readingList.Items.Any())
{
lastOrder = readingList.Items.DefaultIfEmpty().Max(rli => rli.Order);
}
var existingChapterExists = readingList.Items.Select(rli => rli.ChapterId).ToHashSet();
var chaptersForSeries = (await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(chapterIds))
.OrderBy(c => int.Parse(c.Volume.Name))
.ThenBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting);
var index = lastOrder + 1;
foreach (var chapter in chaptersForSeries)
{
if (existingChapterExists.Contains(chapter.Id)) continue;
readingList.Items.Add(new ReadingListItem()
{
Order = index,
ChapterId = chapter.Id,
SeriesId = seriesId,
VolumeId = chapter.VolumeId
});
index += 1;
}
return index > lastOrder + 1;
}
/// <summary>
/// Returns the next chapter within the reading list
/// </summary>
/// <param name="currentChapterId"></param>
/// <param name="readingListId"></param>
/// <returns>Chapter Id for next item, -1 if nothing exists</returns>
[HttpGet("next-chapter")]
public async Task<ActionResult<int>> GetNextChapter(int currentChapterId, int readingListId)
{
var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemsByIdAsync(readingListId)).ToList();
var readingListItem = items.SingleOrDefault(rl => rl.ChapterId == currentChapterId);
if (readingListItem == null) return BadRequest("Id does not exist");
var index = items.IndexOf(readingListItem) + 1;
if (items.Count > index)
{
return items[index].ChapterId;
}
return Ok(-1);
}
/// <summary>
/// Returns the prev chapter within the reading list
/// </summary>
/// <param name="currentChapterId"></param>
/// <param name="readingListId"></param>
/// <returns>Chapter Id for next item, -1 if nothing exists</returns>
[HttpGet("prev-chapter")]
public async Task<ActionResult<int>> GetPrevChapter(int currentChapterId, int readingListId)
{
var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemsByIdAsync(readingListId)).ToList();
var readingListItem = items.SingleOrDefault(rl => rl.ChapterId == currentChapterId);
if (readingListItem == null) return BadRequest("Id does not exist");
var index = items.IndexOf(readingListItem) - 1;
if (0 <= index)
{
return items[index].ChapterId;
}
return Ok(-1);
}
}
}

View File

@ -32,14 +32,14 @@ namespace API.Controllers
[HttpPost]
public async Task<ActionResult<IEnumerable<Series>>> GetSeriesForLibrary(int libraryId, [FromQuery] UserParams userParams, [FromBody] FilterDto filterDto)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var series =
await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, user.Id, userParams, filterDto);
await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, userId, userParams, filterDto);
// Apply progress/rating information (I can't work out how to do this in initial query)
if (series == null) return BadRequest("Could not get series for library");
await _unitOfWork.SeriesRepository.AddSeriesModifiers(user.Id, series);
await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series);
Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages);
@ -55,10 +55,10 @@ namespace API.Controllers
[HttpGet("{seriesId}")]
public async Task<ActionResult<SeriesDto>> GetSeries(int seriesId)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
try
{
return Ok(await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, user.Id));
return Ok(await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId));
}
catch (Exception e)
{
@ -95,15 +95,15 @@ namespace API.Controllers
[HttpGet("volumes")]
public async Task<ActionResult<IEnumerable<VolumeDto>>> GetVolumes(int seriesId)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.SeriesRepository.GetVolumesDtoAsync(seriesId, user.Id));
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.SeriesRepository.GetVolumesDtoAsync(seriesId, userId));
}
[HttpGet("volume")]
public async Task<ActionResult<VolumeDto>> GetVolume(int volumeId)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.SeriesRepository.GetVolumeDtoAsync(volumeId, user.Id));
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.SeriesRepository.GetVolumeDtoAsync(volumeId, userId));
}
[HttpGet("chapter")]
@ -182,14 +182,14 @@ namespace API.Controllers
[HttpPost("recently-added")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetRecentlyAdded(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var series =
await _unitOfWork.SeriesRepository.GetRecentlyAdded(libraryId, user.Id, userParams, filterDto);
await _unitOfWork.SeriesRepository.GetRecentlyAdded(libraryId, userId, userParams, filterDto);
// Apply progress/rating information (I can't work out how to do this in initial query)
if (series == null) return BadRequest("Could not get series");
await _unitOfWork.SeriesRepository.AddSeriesModifiers(user.Id, series);
await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series);
Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages);
@ -200,8 +200,8 @@ namespace API.Controllers
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetInProgress(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0)
{
// NOTE: This has to be done manually like this due to the DistinctBy requirement
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
var results = await _unitOfWork.SeriesRepository.GetInProgress(user.Id, libraryId, userParams, filterDto);
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var results = await _unitOfWork.SeriesRepository.GetInProgress(userId, libraryId, userParams, filterDto);
var listResults = results.DistinctBy(s => s.Name).Skip((userParams.PageNumber - 1) * userParams.PageSize)
.Take(userParams.PageSize).ToList();
@ -316,14 +316,14 @@ namespace API.Controllers
[HttpGet("series-by-collection")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetSeriesByCollectionTag(int collectionId, [FromQuery] UserParams userParams)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var series =
await _unitOfWork.SeriesRepository.GetSeriesDtoForCollectionAsync(collectionId, user.Id, userParams);
await _unitOfWork.SeriesRepository.GetSeriesDtoForCollectionAsync(collectionId, userId, userParams);
// Apply progress/rating information (I can't work out how to do this in initial query)
if (series == null) return BadRequest("Could not get series for collection");
await _unitOfWork.SeriesRepository.AddSeriesModifiers(user.Id, series);
await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series);
Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages);
@ -339,8 +339,8 @@ namespace API.Controllers
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetAllSeriesById(SeriesByIdsDto dto)
{
if (dto.SeriesIds == null) return BadRequest("Must pass seriesIds");
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.SeriesRepository.GetSeriesDtoForIdsAsync(dto.SeriesIds, user.Id));
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.SeriesRepository.GetSeriesDtoForIdsAsync(dto.SeriesIds, userId));
}

View File

@ -103,7 +103,7 @@ namespace API.Controllers
}
else
{
_taskScheduler.ScheduleStatsTasks();
await _taskScheduler.ScheduleStatsTasks();
}
}
}

View File

@ -18,7 +18,7 @@ namespace API.Controllers
{
_unitOfWork = unitOfWork;
}
[Authorize(Policy = "RequireAdminRole")]
[HttpDelete("delete-user")]
public async Task<ActionResult> DeleteUser(string username)
@ -30,7 +30,7 @@ namespace API.Controllers
return BadRequest("Could not delete the user.");
}
[Authorize(Policy = "RequireAdminRole")]
[HttpGet]
public async Task<ActionResult<IEnumerable<MemberDto>>> GetUsers()
@ -42,8 +42,8 @@ namespace API.Controllers
public async Task<ActionResult<bool>> HasReadingProgress(int libraryId)
{
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId);
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.AppUserProgressRepository.UserHasProgress(library.Type, user.Id));
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.AppUserProgressRepository.UserHasProgress(library.Type, userId));
}
[HttpGet("has-library-access")]
@ -77,8 +77,8 @@ namespace API.Controllers
{
return Ok(preferencesDto);
}
return BadRequest("There was an issue saving preferences.");
}
}
}
}

View File

@ -0,0 +1,13 @@
using API.Entities.Enums;
namespace API.DTOs.Reader
{
public class BookInfoDto
{
public string BookTitle { get; set; }
public int SeriesId { get; set; }
public int VolumeId { get; set; }
public MangaFormat SeriesFormat { get; set; }
public int LibraryId { get; set; }
}
}

View File

@ -1,16 +1,21 @@
namespace API.DTOs.Reader
using API.Entities.Enums;
namespace API.DTOs.Reader
{
public class ChapterInfoDto
{
public string ChapterNumber { get; set; }
public string VolumeNumber { get; set; }
public int VolumeId { get; set; }
public string SeriesName { get; set; }
public MangaFormat SeriesFormat { get; set; }
public int SeriesId { get; set; }
public int LibraryId { get; set; }
public string ChapterTitle { get; set; } = "";
public int Pages { get; set; }
public string FileName { get; set; }
public bool IsSpecial { get; set; }
}
}
}

View File

@ -0,0 +1,7 @@
namespace API.DTOs.ReadingLists
{
public class CreateReadingListDto
{
public string Title { get; init; }
}
}

View File

@ -0,0 +1,13 @@
namespace API.DTOs.ReadingLists
{
public class ReadingListDto
{
public int Id { get; init; }
public string Title { get; set; }
public string Summary { get; set; }
/// <summary>
/// Reading lists that are promoted are only done by admins
/// </summary>
public bool Promoted { get; set; }
}
}

View File

@ -0,0 +1,25 @@
using API.Entities.Enums;
namespace API.DTOs.ReadingLists
{
public class ReadingListItemDto
{
public int Id { get; init; }
public int Order { get; init; }
public int ChapterId { get; init; }
public int SeriesId { get; init; }
public string SeriesName { get; set; }
public MangaFormat SeriesFormat { get; set; }
public int PagesRead { get; set; }
public int PagesTotal { get; set; }
public string ChapterNumber { get; set; }
public string VolumeNumber { get; set; }
public int VolumeId { get; set; }
public int LibraryId { get; set; }
public string Title { get; set; }
/// <summary>
/// Used internally only
/// </summary>
public int ReadingListId { get; set; }
}
}

View File

@ -0,0 +1,9 @@
namespace API.DTOs.ReadingLists
{
public class UpdateReadingListByChapterDto
{
public int ChapterId { get; init; }
public int SeriesId { get; init; }
public int ReadingListId { get; init; }
}
}

View File

@ -0,0 +1,8 @@
namespace API.DTOs.ReadingLists
{
public class UpdateReadingListBySeriesDto
{
public int SeriesId { get; init; }
public int ReadingListId { get; init; }
}
}

View File

@ -0,0 +1,9 @@
namespace API.DTOs.ReadingLists
{
public class UpdateReadingListByVolumeDto
{
public int VolumeId { get; init; }
public int SeriesId { get; init; }
public int ReadingListId { get; init; }
}
}

View File

@ -0,0 +1,10 @@
namespace API.DTOs.ReadingLists
{
public class UpdateReadingListDto
{
public int ReadingListId { get; set; }
public string Title { get; set; }
public string Summary { get; set; }
public bool Promoted { get; set; }
}
}

View File

@ -0,0 +1,10 @@
namespace API.DTOs.ReadingLists
{
public class UpdateReadingListPosition
{
public int ReadingListId { get; set; }
public int ReadingListItemId { get; set; }
public int FromPosition { get; set; }
public int ToPosition { get; set; }
}
}

View File

@ -14,6 +14,7 @@ namespace API.DTOs
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; }
}
}
}

View File

@ -1,7 +0,0 @@
namespace API.Data
{
public class BookmarkRepository
{
}
}

View File

@ -35,6 +35,9 @@ namespace API.Data
public DbSet<SeriesMetadata> SeriesMetadata { get; set; }
public DbSet<CollectionTag> CollectionTag { get; set; }
public DbSet<AppUserBookmark> AppUserBookmark { get; set; }
public DbSet<ReadingList> ReadingList { get; set; }
public DbSet<ReadingListItem> ReadingListItem { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
{

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,84 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
namespace API.Data.Migrations
{
public partial class ReadingLists : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ReadingList",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Title = table.Column<string>(type: "TEXT", nullable: true),
Summary = table.Column<string>(type: "TEXT", nullable: true),
Promoted = table.Column<bool>(type: "INTEGER", nullable: false),
Created = table.Column<DateTime>(type: "TEXT", nullable: false),
LastModified = table.Column<DateTime>(type: "TEXT", nullable: false),
AppUserId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ReadingList", x => x.Id);
table.ForeignKey(
name: "FK_ReadingList_AspNetUsers_AppUserId",
column: x => x.AppUserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ReadingListItem",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
LibraryId = table.Column<int>(type: "INTEGER", nullable: false),
SeriesId = table.Column<int>(type: "INTEGER", nullable: false),
VolumeId = table.Column<int>(type: "INTEGER", nullable: false),
ChapterId = table.Column<int>(type: "INTEGER", nullable: false),
Order = table.Column<int>(type: "INTEGER", nullable: false),
ReadingListId = table.Column<int>(type: "INTEGER", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ReadingListItem", x => x.Id);
table.ForeignKey(
name: "FK_ReadingListItem_ReadingList_ReadingListId",
column: x => x.ReadingListId,
principalTable: "ReadingList",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_ReadingList_AppUserId",
table: "ReadingList",
column: "AppUserId");
migrationBuilder.CreateIndex(
name: "IX_ReadingListItem_ReadingListId",
table: "ReadingListItem",
column: "ReadingListId");
migrationBuilder.CreateIndex(
name: "IX_ReadingListItem_SeriesId_VolumeId_ChapterId_LibraryId",
table: "ReadingListItem",
columns: new[] { "SeriesId", "VolumeId", "ChapterId", "LibraryId" },
unique: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ReadingListItem");
migrationBuilder.DropTable(
name: "ReadingList");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,55 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace API.Data.Migrations
{
public partial class ReadingListsAdditions : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_ReadingListItem_ReadingList_ReadingListId",
table: "ReadingListItem");
migrationBuilder.AlterColumn<int>(
name: "ReadingListId",
table: "ReadingListItem",
type: "INTEGER",
nullable: false,
defaultValue: 0,
oldClrType: typeof(int),
oldType: "INTEGER",
oldNullable: true);
migrationBuilder.AddForeignKey(
name: "FK_ReadingListItem_ReadingList_ReadingListId",
table: "ReadingListItem",
column: "ReadingListId",
principalTable: "ReadingList",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_ReadingListItem_ReadingList_ReadingListId",
table: "ReadingListItem");
migrationBuilder.AlterColumn<int>(
name: "ReadingListId",
table: "ReadingListItem",
type: "INTEGER",
nullable: true,
oldClrType: typeof(int),
oldType: "INTEGER");
migrationBuilder.AddForeignKey(
name: "FK_ReadingListItem_ReadingList_ReadingListId",
table: "ReadingListItem",
column: "ReadingListId",
principalTable: "ReadingList",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,67 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace API.Data.Migrations
{
public partial class ReadingListsExtraRealationships : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_ReadingListItem_ChapterId",
table: "ReadingListItem",
column: "ChapterId");
migrationBuilder.CreateIndex(
name: "IX_ReadingListItem_VolumeId",
table: "ReadingListItem",
column: "VolumeId");
migrationBuilder.AddForeignKey(
name: "FK_ReadingListItem_Chapter_ChapterId",
table: "ReadingListItem",
column: "ChapterId",
principalTable: "Chapter",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_ReadingListItem_Series_SeriesId",
table: "ReadingListItem",
column: "SeriesId",
principalTable: "Series",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_ReadingListItem_Volume_VolumeId",
table: "ReadingListItem",
column: "VolumeId",
principalTable: "Volume",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_ReadingListItem_Chapter_ChapterId",
table: "ReadingListItem");
migrationBuilder.DropForeignKey(
name: "FK_ReadingListItem_Series_SeriesId",
table: "ReadingListItem");
migrationBuilder.DropForeignKey(
name: "FK_ReadingListItem_Volume_VolumeId",
table: "ReadingListItem");
migrationBuilder.DropIndex(
name: "IX_ReadingListItem_ChapterId",
table: "ReadingListItem");
migrationBuilder.DropIndex(
name: "IX_ReadingListItem_VolumeId",
table: "ReadingListItem");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,43 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace API.Data.Migrations
{
public partial class ReadingListsChanges : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_ReadingListItem_SeriesId_VolumeId_ChapterId_LibraryId",
table: "ReadingListItem");
migrationBuilder.DropColumn(
name: "LibraryId",
table: "ReadingListItem");
migrationBuilder.CreateIndex(
name: "IX_ReadingListItem_SeriesId",
table: "ReadingListItem",
column: "SeriesId");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_ReadingListItem_SeriesId",
table: "ReadingListItem");
migrationBuilder.AddColumn<int>(
name: "LibraryId",
table: "ReadingListItem",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateIndex(
name: "IX_ReadingListItem_SeriesId_VolumeId_ChapterId_LibraryId",
table: "ReadingListItem",
columns: new[] { "SeriesId", "VolumeId", "ChapterId", "LibraryId" },
unique: true);
}
}
}

View File

@ -440,6 +440,71 @@ namespace API.Data.Migrations
b.ToTable("MangaFile");
});
modelBuilder.Entity("API.Entities.ReadingList", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<bool>("Promoted")
.HasColumnType("INTEGER");
b.Property<string>("Summary")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.ToTable("ReadingList");
});
modelBuilder.Entity("API.Entities.ReadingListItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<int>("Order")
.HasColumnType("INTEGER");
b.Property<int>("ReadingListId")
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int>("VolumeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ChapterId");
b.HasIndex("ReadingListId");
b.HasIndex("SeriesId");
b.HasIndex("VolumeId");
b.ToTable("ReadingListItem");
});
modelBuilder.Entity("API.Entities.Series", b =>
{
b.Property<int>("Id")
@ -780,6 +845,52 @@ namespace API.Data.Migrations
b.Navigation("Chapter");
});
modelBuilder.Entity("API.Entities.ReadingList", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithMany("ReadingLists")
.HasForeignKey("AppUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AppUser");
});
modelBuilder.Entity("API.Entities.ReadingListItem", b =>
{
b.HasOne("API.Entities.Chapter", "Chapter")
.WithMany()
.HasForeignKey("ChapterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.ReadingList", "ReadingList")
.WithMany("Items")
.HasForeignKey("ReadingListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.Series", "Series")
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.Volume", "Volume")
.WithMany()
.HasForeignKey("VolumeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Chapter");
b.Navigation("ReadingList");
b.Navigation("Series");
b.Navigation("Volume");
});
modelBuilder.Entity("API.Entities.Series", b =>
{
b.HasOne("API.Entities.Library", "Library")
@ -892,6 +1003,8 @@ namespace API.Data.Migrations
b.Navigation("Ratings");
b.Navigation("ReadingLists");
b.Navigation("UserPreferences");
b.Navigation("UserRoles");
@ -909,6 +1022,11 @@ namespace API.Data.Migrations
b.Navigation("Series");
});
modelBuilder.Entity("API.Entities.ReadingList", b =>
{
b.Navigation("Items");
});
modelBuilder.Entity("API.Entities.Series", b =>
{
b.Navigation("Metadata");

View File

@ -2,9 +2,10 @@
using System.Threading.Tasks;
using API.Entities.Enums;
using API.Interfaces;
using API.Interfaces.Repositories;
using Microsoft.EntityFrameworkCore;
namespace API.Data
namespace API.Data.Repositories
{
public class AppUserProgressRepository : IAppUserProgressRepository
{
@ -25,7 +26,7 @@ namespace API.Data
var rowsToRemove = await _context.AppUserProgresses
.Where(progress => !chapterIds.Contains(progress.ChapterId))
.ToListAsync();
_context.RemoveRange(rowsToRemove);
return await _context.SaveChangesAsync() > 0 ? rowsToRemove.Count : 0;
}
@ -45,7 +46,7 @@ namespace API.Data
.ToListAsync();
if (seriesIds.Count == 0) return false;
return await _context.Series
.Include(s => s.Library)
.Where(s => seriesIds.Contains(s.Id) && s.Library.Type == libraryType)
@ -53,4 +54,4 @@ namespace API.Data
.AnyAsync();
}
}
}
}

View File

@ -1,8 +1,11 @@
using API.Entities;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Entities;
using API.Interfaces.Repositories;
using Microsoft.EntityFrameworkCore;
namespace API.Data
namespace API.Data.Repositories
{
public class ChapterRepository : IChapterRepository
{
@ -18,6 +21,14 @@ namespace API.Data
_context.Entry(chapter).State = EntityState.Modified;
}
public async Task<IEnumerable<Chapter>> GetChaptersByIdsAsync(IList<int> chapterIds)
{
return await _context.Chapter
.Where(c => chapterIds.Contains(c.Id))
.Include(c => c.Volume)
.ToListAsync();
}
// TODO: Move over Chapter based queries here
}
}

View File

@ -4,11 +4,12 @@ using System.Threading.Tasks;
using API.DTOs;
using API.Entities;
using API.Interfaces;
using API.Interfaces.Repositories;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.EntityFrameworkCore;
namespace API.Data
namespace API.Data.Repositories
{
public class CollectionTagRepository : ICollectionTagRepository
{

View File

@ -3,9 +3,10 @@ using System.IO;
using System.Linq;
using System.Threading.Tasks;
using API.Interfaces;
using API.Interfaces.Repositories;
using Microsoft.EntityFrameworkCore;
namespace API.Data
namespace API.Data.Repositories
{
public class FileRepository : IFileRepository
{

View File

@ -5,11 +5,12 @@ using API.DTOs;
using API.Entities;
using API.Entities.Enums;
using API.Interfaces;
using API.Interfaces.Repositories;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.EntityFrameworkCore;
namespace API.Data
namespace API.Data.Repositories
{
public class LibraryRepository : ILibraryRepository
{

View File

@ -0,0 +1,178 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.DTOs.ReadingLists;
using API.Entities;
using API.Helpers;
using API.Interfaces.Repositories;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.EntityFrameworkCore;
namespace API.Data.Repositories
{
public class ReadingListRepository : IReadingListRepository
{
private readonly DataContext _context;
private readonly IMapper _mapper;
public ReadingListRepository(DataContext context, IMapper mapper)
{
_context = context;
_mapper = mapper;
}
public void Update(ReadingList list)
{
_context.Entry(list).State = EntityState.Modified;
}
public void Remove(ReadingListItem item)
{
_context.ReadingListItem.Remove(item);
}
public void BulkRemove(IEnumerable<ReadingListItem> items)
{
_context.ReadingListItem.RemoveRange(items);
}
public async Task<PagedList<ReadingListDto>> GetReadingListDtosForUserAsync(int userId, bool includePromoted, UserParams userParams)
{
var query = _context.ReadingList
.Where(l => l.AppUserId == userId || (includePromoted && l.Promoted ))
.OrderBy(l => l.LastModified)
.ProjectTo<ReadingListDto>(_mapper.ConfigurationProvider)
.AsNoTracking();
return await PagedList<ReadingListDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
}
public async Task<ReadingList> GetReadingListByIdAsync(int readingListId)
{
return await _context.ReadingList
.Where(r => r.Id == readingListId)
.Include(r => r.Items)
.SingleOrDefaultAsync();
}
public async Task<IEnumerable<ReadingListItemDto>> GetReadingListItemDtosByIdAsync(int readingListId, int userId)
{
var userLibraries = _context.Library
.Include(l => l.AppUsers)
.Where(library => library.AppUsers.Any(user => user.Id == userId))
.AsNoTracking()
.Select(library => library.Id)
.ToList();
var items = await _context.ReadingListItem
.Where(s => s.ReadingListId == readingListId)
.Join(_context.Chapter, s => s.ChapterId, chapter => chapter.Id, (data, chapter) => new
{
TotalPages = chapter.Pages,
ChapterNumber = chapter.Range,
readingListItem = data
})
.Join(_context.Volume, s => s.readingListItem.VolumeId, volume => volume.Id, (data, volume) => new
{
data.readingListItem,
data.TotalPages,
data.ChapterNumber,
VolumeId = volume.Id,
VolumeNumber = volume.Name,
})
.Join(_context.Series, s => s.readingListItem.SeriesId, series => series.Id,
(data, s) => new
{
SeriesName = s.Name,
SeriesFormat = s.Format,
s.LibraryId,
data.readingListItem,
data.TotalPages,
data.ChapterNumber,
data.VolumeNumber,
data.VolumeId
})
.Select(data => new ReadingListItemDto()
{
Id = data.readingListItem.Id,
ChapterId = data.readingListItem.ChapterId,
Order = data.readingListItem.Order,
SeriesId = data.readingListItem.SeriesId,
SeriesName = data.SeriesName,
SeriesFormat = data.SeriesFormat,
PagesTotal = data.TotalPages,
ChapterNumber = data.ChapterNumber,
VolumeNumber = data.VolumeNumber,
LibraryId = data.LibraryId,
VolumeId = data.VolumeId,
ReadingListId = data.readingListItem.ReadingListId
})
.Where(o => userLibraries.Contains(o.LibraryId))
.OrderBy(rli => rli.Order)
.AsNoTracking()
.ToListAsync();
// Attach progress information
var fetchedChapterIds = items.Select(i => i.ChapterId);
var progresses = await _context.AppUserProgresses
.Where(p => fetchedChapterIds.Contains(p.ChapterId))
.AsNoTracking()
.ToListAsync();
foreach (var progress in progresses)
{
var progressItem = items.SingleOrDefault(i => i.ChapterId == progress.ChapterId && i.ReadingListId == readingListId);
if (progressItem == null) continue;
progressItem.PagesRead = progress.PagesRead;
}
return items;
}
public async Task<ReadingListDto> GetReadingListDtoByIdAsync(int readingListId, int userId)
{
return await _context.ReadingList
.Where(r => r.Id == readingListId && (r.AppUserId == userId || r.Promoted))
.ProjectTo<ReadingListDto>(_mapper.ConfigurationProvider)
.SingleOrDefaultAsync();
}
public async Task<IEnumerable<ReadingListItemDto>> AddReadingProgressModifiers(int userId, IList<ReadingListItemDto> items)
{
var chapterIds = items.Select(i => i.ChapterId).Distinct().ToList();
var userProgress = await _context.AppUserProgresses
.Where(p => p.AppUserId == userId && chapterIds.Contains(p.ChapterId))
.AsNoTracking()
.ToListAsync();
foreach (var item in items)
{
var progress = userProgress.Where(p => p.ChapterId == item.ChapterId);
item.PagesRead = progress.Sum(p => p.PagesRead);
}
return items;
}
public async Task<ReadingListDto> GetReadingListDtoByTitleAsync(string title)
{
return await _context.ReadingList
.Where(r => r.Title.Equals(title))
.ProjectTo<ReadingListDto>(_mapper.ConfigurationProvider)
.SingleOrDefaultAsync();
}
public async Task<IEnumerable<ReadingListItem>> GetReadingListItemsByIdAsync(int readingListId)
{
return await _context.ReadingListItem
.Where(r => r.ReadingListId == readingListId)
.OrderBy(r => r.Order)
.ToListAsync();
}
}
}

View File

@ -8,11 +8,12 @@ using API.Entities;
using API.Extensions;
using API.Helpers;
using API.Interfaces;
using API.Interfaces.Repositories;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.EntityFrameworkCore;
namespace API.Data
namespace API.Data.Repositories
{
public class SeriesRepository : ISeriesRepository
{
@ -221,21 +222,17 @@ namespace API.Data
public async Task<int[]> GetChapterIdsForSeriesAsync(int[] seriesIds)
{
var series = await _context.Series
.Where(s => seriesIds.Contains(s.Id))
.Include(s => s.Volumes)
.ThenInclude(v => v.Chapters)
var volumes = await _context.Volume
.Where(v => seriesIds.Contains(v.SeriesId))
.Include(v => v.Chapters)
.ToListAsync();
IList<int> chapterIds = new List<int>();
foreach (var s in series)
foreach (var v in volumes)
{
foreach (var v in s.Volumes)
foreach (var c in v.Chapters)
{
foreach (var c in v.Chapters)
{
chapterIds.Add(c.Id);
}
chapterIds.Add(c.Id);
}
}

View File

@ -5,10 +5,11 @@ using API.DTOs;
using API.Entities;
using API.Entities.Enums;
using API.Interfaces;
using API.Interfaces.Repositories;
using AutoMapper;
using Microsoft.EntityFrameworkCore;
namespace API.Data
namespace API.Data.Repositories
{
public class SettingsRepository : ISettingsRepository
{
@ -45,4 +46,4 @@ namespace API.Data
return await _context.ServerSetting.ToListAsync();
}
}
}
}

View File

@ -5,12 +5,13 @@ using API.Constants;
using API.DTOs;
using API.Entities;
using API.Interfaces;
using API.Interfaces.Repositories;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
namespace API.Data
namespace API.Data.Repositories
{
public class UserRepository : IUserRepository
{
@ -54,10 +55,36 @@ namespace API.Data
}
/// <summary>
/// Gets an AppUser by id. Returns back Progress information.
/// This fetches the Id for a user. Use whenever you just need an ID.
/// </summary>
/// <param name="username"></param>
/// <returns></returns>
public async Task<int> GetUserIdByUsernameAsync(string username)
{
return await _context.Users
.Where(x => x.UserName == username)
.Select(u => u.Id)
.SingleOrDefaultAsync();
}
/// <summary>
/// Gets an AppUser by username. Returns back Reading List and their Items.
/// </summary>
/// <param name="username"></param>
/// <returns></returns>
public async Task<AppUser> GetUserWithReadingListsByUsernameAsync(string username)
{
return await _context.Users
.Include(u => u.ReadingLists)
.ThenInclude(l => l.Items)
.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

View File

@ -4,11 +4,12 @@ using System.Threading.Tasks;
using API.DTOs;
using API.Entities;
using API.Interfaces;
using API.Interfaces.Repositories;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.EntityFrameworkCore;
namespace API.Data
namespace API.Data.Repositories
{
public class VolumeRepository : IVolumeRepository
{

View File

@ -1,4 +1,5 @@
using System.Threading.Tasks;
using API.Data.Repositories;
using API.Entities;
using API.Interfaces;
using API.Interfaces.Repositories;
@ -32,6 +33,7 @@ namespace API.Data
public ICollectionTagRepository CollectionTagRepository => new CollectionTagRepository(_context, _mapper);
public IFileRepository FileRepository => new FileRepository(_context);
public IChapterRepository ChapterRepository => new ChapterRepository(_context);
public IReadingListRepository ReadingListRepository => new ReadingListRepository(_context, _mapper);
/// <summary>
/// Commits changes to the DB. Completes the open transaction.
@ -39,7 +41,6 @@ namespace API.Data
/// <returns></returns>
public bool Commit()
{
return _context.SaveChanges() > 0;
}
/// <summary>

View File

@ -18,6 +18,10 @@ namespace API.Entities
public AppUserPreferences UserPreferences { get; set; }
public ICollection<AppUserBookmark> Bookmarks { get; set; }
/// <summary>
/// Reading lists associated with this user
/// </summary>
public ICollection<ReadingList> ReadingLists { get; set; }
/// <summary>
/// An API Key to interact with external services, like OPDS
/// </summary>
public string ApiKey { get; set; }

View File

@ -0,0 +1,29 @@
using System;
using System.Collections.Generic;
using API.Entities.Interfaces;
namespace API.Entities
{
/// <summary>
/// This is a collection of <see cref="ReadingListItem"/> which represent individual chapters and an order.
/// </summary>
public class ReadingList : IEntityDate
{
public int Id { get; init; }
public string Title { get; set; }
public string Summary { get; set; }
/// <summary>
/// Reading lists that are promoted are only done by admins
/// </summary>
public bool Promoted { get; set; }
public ICollection<ReadingListItem> Items { get; set; }
public DateTime Created { get; set; }
public DateTime LastModified { get; set; }
// Relationships
public int AppUserId { get; set; }
public AppUser AppUser { get; set; }
}
}

View File

@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore;
namespace API.Entities
{
//[Index(nameof(SeriesId), nameof(VolumeId), nameof(ChapterId), IsUnique = true)]
public class ReadingListItem
{
public int Id { get; init; }
public int SeriesId { get; set; }
public int VolumeId { get; set; }
public int ChapterId { get; set; }
/// <summary>
/// Order of the chapter within a Reading List
/// </summary>
public int Order { get; set; }
// Relationship
public ReadingList ReadingList { get; set; }
public int ReadingListId { get; set; }
// Idea, keep these for easy join statements
public Series Series { get; set; }
public Volume Volume { get; set; }
public Chapter Chapter { get; set; }
}
}

View File

@ -14,7 +14,6 @@ namespace API.Extensions
public static bool HasFileBeenModifiedSince(this FileInfo fileInfo, DateTime comparison)
{
return DateTime.Compare(fileInfo.LastWriteTime, comparison) > 0;
//return fileInfo?.LastWriteTime > comparison;
}
}
}

View File

@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.Linq;
using API.DTOs;
using API.DTOs.ReadingLists;
using API.Entities;
using API.Helpers.Converters;
using AutoMapper;
@ -31,6 +32,9 @@ namespace API.Helpers
CreateMap<AppUserBookmark, BookmarkDto>();
CreateMap<ReadingList, ReadingListDto>();
CreateMap<ReadingListItem, ReadingListItemDto>();
CreateMap<Series, SearchResultDto>()
.ForMember(dest => dest.SeriesId,
opt => opt.MapFrom(src => src.Id))

View File

@ -14,6 +14,7 @@ namespace API.Interfaces
ICollectionTagRepository CollectionTagRepository { get; }
IFileRepository FileRepository { get; }
IChapterRepository ChapterRepository { get; }
IReadingListRepository ReadingListRepository { get; }
bool Commit();
Task<bool> CommitAsync();
bool HasChanges();

View File

@ -1,11 +1,11 @@
using System.Threading.Tasks;
using API.Entities.Enums;
namespace API.Interfaces
namespace API.Interfaces.Repositories
{
public interface IAppUserProgressRepository
{
Task<int> CleanupAbandonedChapters();
Task<bool> UserHasProgress(LibraryType libraryType, int userId);
}
}
}

View File

@ -1,9 +1,13 @@
using API.Entities;
using System.Collections.Generic;
using System.Threading.Tasks;
using API.DTOs;
using API.Entities;
namespace API.Interfaces.Repositories
{
public interface IChapterRepository
{
void Update(Chapter chapter);
Task<IEnumerable<Chapter>> GetChaptersByIdsAsync(IList<int> chapterIds);
}
}

View File

@ -3,7 +3,7 @@ using System.Threading.Tasks;
using API.DTOs;
using API.Entities;
namespace API.Interfaces
namespace API.Interfaces.Repositories
{
public interface ICollectionTagRepository
{

View File

@ -1,10 +1,10 @@
using System.Collections.Generic;
using System.Threading.Tasks;
namespace API.Interfaces
namespace API.Interfaces.Repositories
{
public interface IFileRepository
{
Task<IEnumerable<string>> GetFileExtensions();
}
}
}

View File

@ -4,7 +4,7 @@ using API.DTOs;
using API.Entities;
using API.Entities.Enums;
namespace API.Interfaces
namespace API.Interfaces.Repositories
{
public interface ILibraryRepository
{

View File

@ -0,0 +1,22 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using API.DTOs.ReadingLists;
using API.Entities;
using API.Helpers;
namespace API.Interfaces.Repositories
{
public interface IReadingListRepository
{
Task<PagedList<ReadingListDto>> GetReadingListDtosForUserAsync(int userId, bool includePromoted, UserParams userParams);
Task<ReadingList> GetReadingListByIdAsync(int readingListId);
Task<IEnumerable<ReadingListItemDto>> GetReadingListItemDtosByIdAsync(int readingListId, int userId);
Task<ReadingListDto> GetReadingListDtoByIdAsync(int readingListId, int userId);
Task<IEnumerable<ReadingListItemDto>> AddReadingProgressModifiers(int userId, IList<ReadingListItemDto> items);
Task<ReadingListDto> GetReadingListDtoByTitleAsync(string title);
Task<IEnumerable<ReadingListItem>> GetReadingListItemsByIdAsync(int readingListId);
void Remove(ReadingListItem item);
void BulkRemove(IEnumerable<ReadingListItem> items);
void Update(ReadingList list);
}
}

View File

@ -5,7 +5,7 @@ using API.DTOs.Filtering;
using API.Entities;
using API.Helpers;
namespace API.Interfaces
namespace API.Interfaces.Repositories
{
public interface ISeriesRepository
{

View File

@ -4,7 +4,7 @@ using API.DTOs;
using API.Entities;
using API.Entities.Enums;
namespace API.Interfaces
namespace API.Interfaces.Repositories
{
public interface ISettingsRepository
{
@ -12,6 +12,6 @@ namespace API.Interfaces
Task<ServerSettingDto> GetSettingsDtoAsync();
Task<ServerSetting> GetSettingAsync(ServerSettingKey key);
Task<IEnumerable<ServerSetting>> GetSettingsAsync();
}
}
}

View File

@ -3,7 +3,7 @@ using System.Threading.Tasks;
using API.DTOs;
using API.Entities;
namespace API.Interfaces
namespace API.Interfaces.Repositories
{
public interface IUserRepository
{
@ -11,6 +11,8 @@ namespace API.Interfaces
void Update(AppUserPreferences preferences);
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();

View File

@ -3,7 +3,7 @@ using System.Threading.Tasks;
using API.DTOs;
using API.Entities;
namespace API.Interfaces
namespace API.Interfaces.Repositories
{
public interface IVolumeRepository
{

View File

@ -129,9 +129,14 @@ namespace API.Parser
RegexTimeout),
// [dmntsf.net] One Piece - Digital Colored Comics Vol. 20 Ch. 177 - 30 Million vs 81 Million.cbz
new Regex(
@"(?<Series>.*) (\b|_|-)(vol)\.?",
@"(?<Series>.*) (\b|_|-)(vol)\.?(\s|-|_)?\d+",
RegexOptions.IgnoreCase | RegexOptions.Compiled,
RegexTimeout),
// [xPearse] Kyochuu Rettou Volume 1 [English] [Manga] [Volume Scans]
new Regex(
@"(?<Series>.*) (\b|_|-)(vol)(ume)",
RegexOptions.IgnoreCase | RegexOptions.Compiled,
RegexTimeout),
//Knights of Sidonia c000 (S2 LE BD Omake - BLAME!) [Habanero Scans]
new Regex(
@"(?<Series>.*)(\bc\d+\b)",

View File

@ -161,6 +161,7 @@ namespace API.Services
/// <summary>
/// Not an external call. Only public so that we can call this for a Task
/// </summary>
// ReSharper disable once MemberCanBePrivate.Global
public async Task CheckForUpdate()
{
var update = await _versionUpdaterService.CheckForUpdate();

View File

@ -245,6 +245,28 @@
"tslib": "^2.0.0"
}
},
"@angular/cdk": {
"version": "12.2.3",
"resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-12.2.3.tgz",
"integrity": "sha512-ahY3k5X3eoQlsCX/fYiwbe1z7nfmwY15EiLpcJ8YrnUoB+ZshPm8qFIZi6gwY4tsMmUN8OfsIGcUO701bdxFpg==",
"requires": {
"parse5": "^5.0.0",
"tslib": "^2.2.0"
},
"dependencies": {
"parse5": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz",
"integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==",
"optional": true
},
"tslib": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
"integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
}
}
},
"@angular/cli": {
"version": "11.2.11",
"resolved": "https://registry.npmjs.org/@angular/cli/-/cli-11.2.11.tgz",

View File

@ -17,6 +17,7 @@
"dependencies": {
"@angular-slider/ngx-slider": "^2.0.3",
"@angular/animations": "~11.0.0",
"@angular/cdk": "^12.2.3",
"@angular/common": "~11.0.0",
"@angular/compiler": "~11.0.0",
"@angular/core": "~11.0.0",

View File

@ -0,0 +1,23 @@
import { MangaFormat } from "./manga-format";
export interface ReadingListItem {
pagesRead: number;
pagesTotal: number;
seriesName: string;
seriesFormat: MangaFormat;
seriesId: number;
chapterId: number;
order: number;
chapterNumber: string;
volumeNumber: string;
libraryId: number;
id: number;
}
export interface ReadingList {
id: number;
title: string;
summary: string;
promoted: boolean;
items: Array<ReadingListItem>;
}

View File

@ -3,6 +3,7 @@ import { Chapter } from '../_models/chapter';
import { CollectionTag } from '../_models/collection-tag';
import { Library } from '../_models/library';
import { MangaFormat } from '../_models/manga-format';
import { ReadingList } from '../_models/reading-list';
import { Series } from '../_models/series';
import { Volume } from '../_models/volume';
import { AccountService } from './account.service';
@ -17,7 +18,8 @@ export enum Action {
RefreshMetadata = 6,
Download = 7,
Bookmarks = 8,
IncognitoRead = 9
IncognitoRead = 9,
AddToReadingList = 10
}
export interface ActionItem<T> {
@ -42,6 +44,8 @@ export class ActionFactoryService {
collectionTagActions: Array<ActionItem<CollectionTag>> = [];
readingListActions: Array<ActionItem<ReadingList>> = [];
isAdmin = false;
hasDownloadRole = false;
@ -113,6 +117,13 @@ export class ActionFactoryService {
callback: this.dummyCallback,
requiresAdmin: false
});
// this.readingListActions.push({
// action: Action.Promote, // Should I just use CollectionTag modal-like instead?
// title: 'Delete',
// callback: this.dummyCallback,
// requiresAdmin: true
// });
}
if (this.hasDownloadRole || this.isAdmin) {
@ -158,6 +169,11 @@ export class ActionFactoryService {
return this.collectionTagActions;
}
getReadingListActions(callback: (action: Action, readingList: ReadingList) => void) {
this.readingListActions.forEach(action => action.callback = callback);
return this.readingListActions;
}
filterBookmarksForFormat(action: ActionItem<Series>, series: Series) {
if (action.action === Action.Bookmarks && series?.format === MangaFormat.EPUB) return false;
return true;
@ -188,7 +204,13 @@ export class ActionFactoryService {
title: 'Bookmarks',
callback: this.dummyCallback,
requiresAdmin: false
}
},
{
action: Action.AddToReadingList,
title: 'Add to Reading List',
callback: this.dummyCallback,
requiresAdmin: false
},
];
this.volumeActions = [
@ -204,6 +226,12 @@ export class ActionFactoryService {
callback: this.dummyCallback,
requiresAdmin: false
},
{
action: Action.AddToReadingList,
title: 'Add to Reading List',
callback: this.dummyCallback,
requiresAdmin: false
},
{
action: Action.IncognitoRead,
title: 'Read in Incognito',
@ -232,8 +260,23 @@ export class ActionFactoryService {
requiresAdmin: false
},
{
action: Action.IncognitoRead,
title: 'Read in Incognito',
action: Action.AddToReadingList,
title: 'Add to Reading List',
callback: this.dummyCallback,
requiresAdmin: false
},
];
this.readingListActions = [
{
action: Action.Edit,
title: 'Edit',
callback: this.dummyCallback,
requiresAdmin: false
},
{
action: Action.Delete,
title: 'Delete',
callback: this.dummyCallback,
requiresAdmin: false
},

View File

@ -4,8 +4,11 @@ import { ToastrService } from 'ngx-toastr';
import { forkJoin, Subject } from 'rxjs';
import { take, takeUntil } from 'rxjs/operators';
import { BookmarksModalComponent } from '../cards/_modals/bookmarks-modal/bookmarks-modal.component';
import { AddToListModalComponent, ADD_FLOW } from '../reading-list/_modals/add-to-list-modal/add-to-list-modal.component';
import { EditReadingListModalComponent } from '../reading-list/_modals/edit-reading-list-modal/edit-reading-list-modal.component';
import { Chapter } from '../_models/chapter';
import { Library } from '../_models/library';
import { ReadingList } from '../_models/reading-list';
import { Series } from '../_models/series';
import { Volume } from '../_models/volume';
import { LibraryService } from './library.service';
@ -16,6 +19,7 @@ export type LibraryActionCallback = (library: Partial<Library>) => void;
export type SeriesActionCallback = (series: Series) => void;
export type VolumeActionCallback = (volume: Volume) => void;
export type ChapterActionCallback = (chapter: Chapter) => void;
export type ReadingListActionCallback = (readingList: ReadingList) => void;
/**
* Responsible for executing actions
@ -27,6 +31,7 @@ export class ActionService implements OnDestroy {
private readonly onDestroy = new Subject<void>();
private bookmarkModalRef: NgbModalRef | null = null;
private readingListModalRef: NgbModalRef | null = null;
constructor(private libraryService: LibraryService, private seriesService: SeriesService,
private readerService: ReaderService, private toastr: ToastrService, private modalService: NgbModal) { }
@ -217,4 +222,85 @@ export class ActionService implements OnDestroy {
});
}
addSeriesToReadingList(series: Series, callback?: SeriesActionCallback) {
if (this.readingListModalRef != null) { return; }
this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md' });
this.readingListModalRef.componentInstance.seriesId = series.id;
this.readingListModalRef.componentInstance.title = series.name;
this.readingListModalRef.componentInstance.type = ADD_FLOW.Series;
this.readingListModalRef.closed.pipe(take(1)).subscribe(() => {
this.readingListModalRef = null;
if (callback) {
callback(series);
}
});
this.readingListModalRef.dismissed.pipe(take(1)).subscribe(() => {
this.readingListModalRef = null;
if (callback) {
callback(series);
}
});
}
addVolumeToReadingList(volume: Volume, seriesId: number, callback?: VolumeActionCallback) {
if (this.readingListModalRef != null) { return; }
this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md' });
this.readingListModalRef.componentInstance.seriesId = seriesId;
this.readingListModalRef.componentInstance.volumeId = volume.id;
this.readingListModalRef.componentInstance.type = ADD_FLOW.Volume;
this.readingListModalRef.closed.pipe(take(1)).subscribe(() => {
this.readingListModalRef = null;
if (callback) {
callback(volume);
}
});
this.readingListModalRef.dismissed.pipe(take(1)).subscribe(() => {
this.readingListModalRef = null;
if (callback) {
callback(volume);
}
});
}
addChapterToReadingList(chapter: Chapter, seriesId: number, callback?: ChapterActionCallback) {
if (this.readingListModalRef != null) { return; }
this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md' });
this.readingListModalRef.componentInstance.seriesId = seriesId;
this.readingListModalRef.componentInstance.chapterId = chapter.id;
this.readingListModalRef.componentInstance.type = ADD_FLOW.Chapter;
this.readingListModalRef.closed.pipe(take(1)).subscribe(() => {
this.readingListModalRef = null;
if (callback) {
callback(chapter);
}
});
this.readingListModalRef.dismissed.pipe(take(1)).subscribe(() => {
this.readingListModalRef = null;
if (callback) {
callback(chapter);
}
});
}
editReadingList(readingList: ReadingList, callback?: ReadingListActionCallback) {
const readingListModalRef = this.modalService.open(EditReadingListModalComponent, { scrollable: true, size: 'md' });
readingListModalRef.componentInstance.readingList = readingList;
readingListModalRef.closed.pipe(take(1)).subscribe((list) => {
if (callback && list !== undefined) {
callback(readingList);
}
});
readingListModalRef.dismissed.pipe(take(1)).subscribe((list) => {
if (callback && list !== undefined) {
callback(readingList);
}
});
}
}

View File

@ -1,6 +1,5 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from 'src/environments/environment';
import { ChapterInfo } from '../manga-reader/_models/chapter-info';
import { UtilityService } from '../shared/_services/utility.service';
@ -73,17 +72,23 @@ export class ReaderService {
return this.httpClient.post(this.baseUrl + 'reader/mark-volume-unread', {seriesId, volumeId});
}
getNextChapter(seriesId: number, volumeId: number, currentChapterId: number) {
getNextChapter(seriesId: number, volumeId: number, currentChapterId: number, readingListId: number = -1) {
if (readingListId > 0) {
return this.httpClient.get<number>(this.baseUrl + 'readinglist/next-chapter?seriesId=' + seriesId + '&currentChapterId=' + currentChapterId + '&readingListId=' + readingListId);
}
return this.httpClient.get<number>(this.baseUrl + 'reader/next-chapter?seriesId=' + seriesId + '&volumeId=' + volumeId + '&currentChapterId=' + currentChapterId);
}
getPrevChapter(seriesId: number, volumeId: number, currentChapterId: number) {
getPrevChapter(seriesId: number, volumeId: number, currentChapterId: number, readingListId: number = -1) {
if (readingListId > 0) {
return this.httpClient.get<number>(this.baseUrl + 'readinglist/prev-chapter?seriesId=' + seriesId + '&currentChapterId=' + currentChapterId + '&readingListId=' + readingListId);
}
return this.httpClient.get<number>(this.baseUrl + 'reader/prev-chapter?seriesId=' + seriesId + '&volumeId=' + volumeId + '&currentChapterId=' + currentChapterId);
}
getCurrentChapter(volumes: Array<Volume>): Chapter {
let currentlyReadingChapter: Chapter | undefined = undefined;
const chapters = volumes.filter(v => v.number !== 0).map(v => v.chapters || []).flat().sort(this.utilityService.sortChapters); // changed from === 0 to != 0
const chapters = volumes.filter(v => v.number !== 0).map(v => v.chapters || []).flat().sort(this.utilityService.sortChapters);
for (const c of chapters) {
if (c.pagesRead < c.pages) {
@ -137,4 +142,37 @@ export class ReaderService {
if (imageSrc === undefined || imageSrc === '') { return -1; }
return parseInt(imageSrc.split('&page=')[1], 10);
}
getNextChapterUrl(url: string, nextChapterId: number, incognitoMode: boolean = false, readingListMode: boolean = false, readingListId: number = -1) {
const lastSlashIndex = url.lastIndexOf('/');
let newRoute = url.substring(0, lastSlashIndex + 1) + nextChapterId + '';
newRoute += this.getQueryParams(incognitoMode, readingListMode, readingListId);
return newRoute;
}
getQueryParamsObject(incognitoMode: boolean = false, readingListMode: boolean = false, readingListId: number = -1) {
let params: {[key: string]: any} = {};
if (incognitoMode) {
params['incognitoMode'] = true;
}
if (readingListMode) {
params['readingListId'] = readingListId;
}
return params;
}
getQueryParams(incognitoMode: boolean = false, readingListMode: boolean = false, readingListId: number = -1) {
let params = '';
if (incognitoMode) {
params += '?incognitoMode=true';
}
if (readingListMode) {
if (params.indexOf('?') > 0) {
params += '&readingListId=' + readingListId;
} else {
params += '?readingListId=' + readingListId;
}
}
return params;
}
}

View File

@ -0,0 +1,102 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { map } from 'rxjs/operators';
import { environment } from 'src/environments/environment';
import { PaginatedResult } from '../_models/pagination';
import { ReadingList, ReadingListItem } from '../_models/reading-list';
import { ActionItem } from './action-factory.service';
@Injectable({
providedIn: 'root'
})
export class ReadingListService {
baseUrl = environment.apiUrl;
constructor(private httpClient: HttpClient) { }
getReadingList(readingListId: number) {
return this.httpClient.get<ReadingList>(this.baseUrl + 'readinglist?readingListId=' + readingListId);
}
getReadingLists(includePromoted: boolean = true, pageNum?: number, itemsPerPage?: number) {
let params = new HttpParams();
params = this._addPaginationIfExists(params, pageNum, itemsPerPage);
return this.httpClient.post<PaginatedResult<ReadingList[]>>(this.baseUrl + 'readinglist/lists?includePromoted=' + includePromoted, {}, {observe: 'response', params}).pipe(
map((response: any) => {
return this._cachePaginatedResults(response, new PaginatedResult<ReadingList[]>());
})
);
}
getListItems(readingListId: number) {
return this.httpClient.get<ReadingListItem[]>(this.baseUrl + 'readinglist/items?readingListId=' + readingListId);
}
createList(title: string) {
return this.httpClient.post<ReadingList>(this.baseUrl + 'readinglist/create', {title});
}
update(model: {readingListId: number, title?: string, summary?: string, promoted: boolean}) {
return this.httpClient.post(this.baseUrl + 'readinglist/update', model, { responseType: 'text' as 'json' });
}
updateBySeries(readingListId: number, seriesId: number) {
return this.httpClient.post(this.baseUrl + 'readinglist/update-by-series', {readingListId, seriesId}, { responseType: 'text' as 'json' });
}
updateByVolume(readingListId: number, seriesId: number, volumeId: number) {
return this.httpClient.post(this.baseUrl + 'readinglist/update-by-volume', {readingListId, seriesId, volumeId}, { responseType: 'text' as 'json' });
}
updateByChapter(readingListId: number, seriesId: number, chapterId: number) {
return this.httpClient.post(this.baseUrl + 'readinglist/update-by-chapter', {readingListId, seriesId, chapterId}, { responseType: 'text' as 'json' });
}
delete(readingListId: number) {
return this.httpClient.delete(this.baseUrl + 'readinglist?readingListId=' + readingListId, { responseType: 'text' as 'json' });
}
updatePosition(readingListId: number, readingListItemId: number, fromPosition: number, toPosition: number) {
return this.httpClient.post(this.baseUrl + 'readinglist/update-position', {readingListId, readingListItemId, fromPosition, toPosition}, { responseType: 'text' as 'json' });
}
deleteItem(readingListId: number, readingListItemId: number) {
return this.httpClient.post(this.baseUrl + 'readinglist/delete-item', {readingListId, readingListItemId}, { responseType: 'text' as 'json' });
}
removeRead(readingListId: number) {
return this.httpClient.post(this.baseUrl + 'readinglist/remove-read?readingListId=' + readingListId, { responseType: 'text' as 'json' });
}
actionListFilter(action: ActionItem<ReadingList>, readingList: ReadingList, isAdmin: boolean) {
if (readingList?.promoted && !isAdmin) return false;
return true;
}
_addPaginationIfExists(params: HttpParams, pageNum?: number, itemsPerPage?: number) {
// TODO: Move to utility service
if (pageNum !== null && pageNum !== undefined && itemsPerPage !== null && itemsPerPage !== undefined) {
params = params.append('pageNumber', pageNum + '');
params = params.append('pageSize', itemsPerPage + '');
}
return params;
}
_cachePaginatedResults(response: any, paginatedVariable: PaginatedResult<any[]>) {
// TODO: Move to utility service
if (response.body === null) {
paginatedVariable.result = [];
} else {
paginatedVariable.result = response.body;
}
const pageHeader = response.headers.get('Pagination');
if (pageHeader !== null) {
paginatedVariable.pagination = JSON.parse(pageHeader);
}
return paginatedVariable;
}
}

View File

@ -27,7 +27,7 @@
</li>
</ol>
<ng-template #noBreadcrumb>
<div class="breadcrumb">Select a folder to view breadcrumb</div>
<div class="breadcrumb">Select a folder to view breadcrumb. Don't see your directory, try checking / first.</div>
</ng-template>
</nav>
<ul class="list-group">
@ -50,5 +50,6 @@
</ul>
</div>
<div class="modal-footer">
<a class="btn btn-info" href="https://wiki.kavitareader.com/en/guides/adding-a-library" target="_blank">Help</a>
<button type="button" class="btn btn-secondary" (click)="close()">Cancel</button>
</div>

View File

@ -1,6 +1,7 @@
import { Component, Input, OnInit } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { Library } from 'src/app/_models/library';
import { LibraryService } from 'src/app/_services/library.service';
import { SettingsService } from '../../settings.service';
@ -26,7 +27,8 @@ export class LibraryEditorModalComponent implements OnInit {
libraryTypes: string[] = []
constructor(private modalService: NgbModal, private libraryService: LibraryService, public modal: NgbActiveModal, private settingService: SettingsService) { }
constructor(private modalService: NgbModal, private libraryService: LibraryService, public modal: NgbActiveModal, private settingService: SettingsService,
private toastr: ToastrService) { }
ngOnInit(): void {
@ -64,6 +66,7 @@ export class LibraryEditorModalComponent implements OnInit {
model.folders = model.folders.map((item: string) => item.startsWith('\\') ? item.substr(1, item.length) : item);
model.type = parseInt(model.type, 10);
this.libraryService.create(model).subscribe(() => {
this.toastr.success('Library created, a scan has been started');
this.close(true);
}, err => {
this.errorMessage = err;

View File

@ -1,7 +1,7 @@
<div class="container">
<h2>Admin Dashboard</h2>
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-tabs">
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-tabs nav-pills">
<li *ngFor="let tab of tabs" [ngbNavItem]="tab">
<a ngbNavLink routerLink="." [fragment]="tab.fragment">{{ tab.title | titlecase }}</a>
<ng-template ngbNavContent>

View File

@ -10,6 +10,8 @@ import { UserLoginComponent } from './user-login/user-login.component';
import { AuthGuard } from './_guards/auth.guard';
import { LibraryAccessGuard } from './_guards/library-access.guard';
import { InProgressComponent } from './in-progress/in-progress.component';
import { DashboardComponent as AdminDashboardComponent } from './admin/dashboard/dashboard.component';
import { DashboardComponent } from './dashboard/dashboard.component';
// TODO: Once we modularize the components, use this and measure performance impact: https://angular.io/guide/lazy-loading-ngmodules#preloading-modules
@ -27,6 +29,10 @@ const routes: Routes = [
path: 'preferences',
loadChildren: () => import('./user-settings/user-settings.module').then(m => m.UserSettingsModule)
},
{
path: 'lists',
loadChildren: () => import('./reading-list/reading-list.module').then(m => m.ReadingListModule)
},
{
path: '',
runGuardsAndResolvers: 'always',
@ -49,7 +55,7 @@ const routes: Routes = [
runGuardsAndResolvers: 'always',
canActivate: [AuthGuard],
children: [
{path: 'library', component: LibraryComponent},
{path: 'library', component: DashboardComponent},
{path: 'recently-added', component: RecentlyAddedComponent},
{path: 'in-progress', component: InProgressComponent},
]

View File

@ -7,7 +7,7 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { HomeComponent } from './home/home.component';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { NgbAccordionModule, NgbDropdownModule, NgbNavModule, NgbPaginationModule, NgbRatingModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { NgbDropdownModule, NgbNavModule, NgbPaginationModule, NgbRatingModule } from '@ng-bootstrap/ng-bootstrap';
import { NavHeaderComponent } from './nav-header/nav-header.component';
import { JwtInterceptor } from './_interceptors/jwt.interceptor';
import { UserLoginComponent } from './user-login/user-login.component';
@ -21,7 +21,6 @@ import { NotConnectedComponent } from './not-connected/not-connected.component';
import { AutocompleteLibModule } from 'angular-ng-autocomplete';
import { ReviewSeriesModalComponent } from './_modals/review-series-modal/review-series-modal.component';
import { CarouselModule } from './carousel/carousel.module';
import { NgxSliderModule } from '@angular-slider/ngx-slider';
import * as Sentry from '@sentry/angular';
@ -33,11 +32,12 @@ import { Dedupe as DedupeIntegration } from '@sentry/integrations';
import { PersonBadgeComponent } from './person-badge/person-badge.component';
import { TypeaheadModule } from './typeahead/typeahead.module';
import { RecentlyAddedComponent } from './recently-added/recently-added.component';
import { InProgressComponent } from './in-progress/in-progress.component';
import { CardsModule } from './cards/cards.module';
import { CollectionsModule } from './collections/collections.module';
import { CommonModule } from '@angular/common';
import { InProgressComponent } from './in-progress/in-progress.component';
import { SAVER, getSaver } from './shared/_providers/saver.provider';
import { ReadingListModule } from './reading-list/reading-list.module';
import { DashboardComponent } from './dashboard/dashboard.component';
let sentryProviders: any[] = [];
@ -98,6 +98,7 @@ if (environment.production) {
PersonBadgeComponent,
RecentlyAddedComponent,
InProgressComponent,
DashboardComponent,
],
imports: [
HttpClientModule,
@ -109,19 +110,16 @@ if (environment.production) {
NgbDropdownModule, // Nav
AutocompleteLibModule, // Nav
//NgbTooltipModule, // Shared & SettingsModule
NgbRatingModule, // Series Detail
NgbNavModule,
//NgbAccordionModule, // User Preferences
//NgxSliderModule, // User Preference
NgbPaginationModule,
SharedModule,
CarouselModule,
TypeaheadModule,
CardsModule,
CollectionsModule,
ReadingListModule,
ToastrModule.forRoot({
positionClass: 'toast-bottom-right',

View File

@ -0,0 +1,9 @@
import { MangaFormat } from "src/app/_models/manga-format";
export interface BookInfo {
bookTitle: string;
seriesFormat: MangaFormat;
seriesId: number;
libraryId: number;
volumeId: number;
}

View File

@ -67,7 +67,7 @@
<div class="col-10" style="margin-top: 9px">
<ngb-progressbar style="cursor: pointer" title="Go to page" (click)="goToPage()" type="primary" height="5px" [value]="pageNum" [max]="maxPages - 1"></ngb-progressbar>
</div>
<div class="col-1">{{maxPages - 1}}</div>
<div class="col-1 btn-icon" (click)="goToPage(maxPages - 1)" title="Go to last page">{{maxPages - 1}}</div>
</div>
<div class="table-of-contents">
<h3>Table of Contents</h3>
@ -77,18 +77,18 @@
<div *ngIf="chapters.length === 1; else nestedChildren">
<ul>
<li *ngFor="let chapter of chapters[0].children">
<a href="javascript:void(0);" (click)="loadChapter(chapter.page, chapter.part)">{{chapter.title}}</a>
<a href="javascript:void(0);" (click)="loadChapterPage(chapter.page, chapter.part)">{{chapter.title}}</a>
</li>
</ul>
</div>
<ng-template #nestedChildren>
<ul *ngFor="let chapterGroup of chapters" style="padding-inline-start: 0px">
<li class="{{chapterGroup.page == pageNum ? 'active': ''}}" (click)="loadChapter(chapterGroup.page, '')">
<li class="{{chapterGroup.page == pageNum ? 'active': ''}}" (click)="loadChapterPage(chapterGroup.page, '')">
{{chapterGroup.title}}
</li>
<ul *ngFor="let chapter of chapterGroup.children">
<li class="{{cleanIdSelector(chapter.part) === currentPageAnchor ? 'active' : ''}}">
<a href="javascript:void(0);" (click)="loadChapter(chapter.page, chapter.part)">{{chapter.title}}</a>
<a href="javascript:void(0);" (click)="loadChapterPage(chapter.page, chapter.part)">{{chapter.title}}</a>
</li>
</ul>
</ul>
@ -122,12 +122,22 @@
<ng-template #actionBar>
<div class="reading-bar row no-gutters justify-content-between">
<button class="btn btn-outline-secondary btn-icon col-2 col-xs-1" (click)="prevPage()" [disabled]="readingDirection === 0 ? pageNum === 0 : pageNum + 1 >= maxPages - 1" title="{{readingDirection === 0 ? 'Previous' : 'Next'}} Page"><i class="fa fa-arrow-left" aria-hidden="true"></i><span class="phone-hidden">&nbsp;{{readingDirection === 0 ? 'Previous' : 'Next'}}</span></button>
<button class="btn btn-outline-secondary btn-icon col-2 col-xs-1" (click)="prevPage()"
[disabled]="IsPrevDisabled"
title="{{readingDirection === ReadingDirection.LeftToRight ? 'Previous' : 'Next'}} Page">
<i class="fa {{(readingDirection === ReadingDirection.LeftToRight ? pageNum === 0 : pageNum + 1 >= maxPages - 1) ? 'fa-angle-double-left' : 'fa-angle-left'}}" aria-hidden="true"></i>
<span class="phone-hidden">&nbsp;{{readingDirection === ReadingDirection.LeftToRight ? 'Previous' : 'Next'}}</span>
</button>
<button *ngIf="!this.adhocPageHistory.isEmpty()" class="btn btn-outline-secondary btn-icon col-2 col-xs-1" (click)="goBack()" title="Go Back"><i class="fa fa-reply" aria-hidden="true"></i><span class="phone-hidden">&nbsp;Go Back</span></button>
<button class="btn btn-secondary col-2 col-xs-1" (click)="toggleDrawer()"><i class="fa fa-bars" aria-hidden="true"></i><span class="phone-hidden">&nbsp;Settings</span></button>
<div class="book-title col-2 phone-hidden">{{bookTitle}} </div>
<div class="book-title col-2 phone-hidden">{{bookTitle}} <span *ngIf="incognitoMode">(<i class="fa fa-glasses" aria-hidden="true"></i><span class="sr-only">Incognito Mode</span>)</span></div>
<button class="btn btn-secondary col-2 col-xs-1" (click)="closeReader()"><i class="fa fa-times-circle" aria-hidden="true"></i><span class="phone-hidden">&nbsp;Close</span></button>
<button class="btn btn-outline-secondary btn-icon col-2 col-xs-1" [disabled]="readingDirection === 0 ? pageNum + 1 >= maxPages - 1 : pageNum === 0" (click)="nextPage()" title="{{readingDirection === 0 ? 'Next' : 'Previous'}} Page"><span class="phone-hidden">{{readingDirection === 0 ? 'Next' : 'Previous'}}&nbsp;</span><i class="fa fa-arrow-right" aria-hidden="true"></i></button>
<button class="btn btn-outline-secondary btn-icon col-2 col-xs-1"
[disabled]="IsNextDisabled"
(click)="nextPage()" title="{{readingDirection === ReadingDirection.LeftToRight ? 'Next' : 'Previous'}} Page">
<span class="phone-hidden">{{readingDirection === ReadingDirection.LeftToRight ? 'Next' : 'Previous'}}&nbsp;</span>
<i class="fa {{(readingDirection === ReadingDirection.LeftToRight ? pageNum + 1 >= maxPages - 1 : pageNum === 0) ? 'fa-angle-double-right' : 'fa-angle-right'}}" aria-hidden="true"></i>
</button>
</div>
</ng-template>

View File

@ -22,6 +22,7 @@ import { Preferences } from 'src/app/_models/preferences/preferences';
import { MemberService } from 'src/app/_services/member.service';
import { ReadingDirection } from 'src/app/_models/preferences/reading-direction';
import { ScrollService } from 'src/app/scroll.service';
import { MangaFormat } from 'src/app/_models/manga-format';
interface PageStyle {
@ -38,7 +39,8 @@ interface HistoryPoint {
}
const TOP_OFFSET = -50 * 1.5; // px the sticky header takes up
const SCROLL_PART_TIMEOUT = 5000;
const CHAPTER_ID_NOT_FETCHED = -2;
const CHAPTER_ID_DOESNT_EXIST = -1;
@Component({
selector: 'app-book-reader',
@ -65,15 +67,30 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
chapterId!: number;
chapter!: Chapter;
/**
* If we should save progress or not
* Reading List id. Defaults to -1.
*/
readingListId: number = CHAPTER_ID_DOESNT_EXIST;
/**
* If this is true, no progress will be saved.
*/
incognitoMode: boolean = false;
/**
* If this is true, chapters will be fetched in the order of a reading list, rather than natural series order.
*/
readingListMode: boolean = false;
chapters: Array<BookChapterItem> = [];
pageNum = 0;
maxPages = 1;
adhocPageHistory: Stack<HistoryPoint> = new Stack<HistoryPoint>();
/**
* A stack of the chapter ids we come across during continuous reading mode. When we traverse a boundary, we use this to avoid extra API calls.
* @see Stack
*/
continuousChaptersStack: Stack<number> = new Stack();
user!: User;
@ -94,6 +111,38 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
@ViewChild('readingSection', {static: false}) readingSectionElemRef!: ElementRef<HTMLDivElement>;
@ViewChild('stickyTop', {static: false}) stickyTopElemRef!: ElementRef<HTMLDivElement>;
/**
* Next Chapter Id. This is not garunteed to be a valid ChapterId. Prefetched on page load (non-blocking).
*/
nextChapterId: number = CHAPTER_ID_NOT_FETCHED;
/**
* Previous Chapter Id. This is not garunteed to be a valid ChapterId. Prefetched on page load (non-blocking).
*/
prevChapterId: number = CHAPTER_ID_NOT_FETCHED;
/**
* Is there a next chapter. If not, this will disable UI controls.
*/
nextChapterDisabled: boolean = false;
/**
* Is there a previous chapter. If not, this will disable UI controls.
*/
prevChapterDisabled: boolean = false;
/**
* Has the next chapter been prefetched. Prefetched means the backend will cache the files.
*/
nextChapterPrefetched: boolean = false;
/**
* Has the previous chapter been prefetched. Prefetched means the backend will cache the files.
*/
prevChapterPrefetched: boolean = false;
/**
* If the prev page allows a page change to occur.
*/
prevPageDisabled = false;
/**
* If the next page allows a page change to occur.
*/
nextPageDisabled = false;
/**
* Internal property used to capture all the different css properties to render on all elements
@ -122,6 +171,9 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
* Last seen progress part path
*/
lastSeenScrollPartPath: string = '';
/**
* Hack: Override background color for reader and restore it onDestroy
*/
@ -151,6 +203,24 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}
`;
get ReadingDirection(): typeof ReadingDirection {
return ReadingDirection;
}
get IsPrevDisabled() {
if (this.readingDirection === ReadingDirection.LeftToRight) {
return this.prevPageDisabled && this.pageNum === 0;
}
return this.nextPageDisabled && this.pageNum + 1 >= this.maxPages - 1;
}
get IsNextDisabled() {
if (this.readingDirection === ReadingDirection.LeftToRight) {
this.nextPageDisabled && this.pageNum + 1 >= this.maxPages - 1;
}
return this.prevPageDisabled && this.pageNum === 0;
}
constructor(private route: ActivatedRoute, private router: Router, private accountService: AccountService,
private seriesService: SeriesService, private readerService: ReaderService, private location: Location,
private renderer: Renderer2, private navService: NavService, private toastr: ToastrService,
@ -274,6 +344,13 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
this.chapterId = parseInt(chapterId, 10);
this.incognitoMode = this.route.snapshot.queryParamMap.get('incognitoMode') === 'true';
const readingListId = this.route.snapshot.queryParamMap.get('readingListId');
if (readingListId != null) {
this.readingListMode = true;
this.readingListId = parseInt(readingListId, 10);
}
this.memberService.hasReadingProgress(this.libraryId).pipe(take(1)).subscribe(hasProgress => {
if (!hasProgress) {
this.toggleDrawer();
@ -281,34 +358,71 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}
});
forkJoin({
chapter: this.seriesService.getChapter(this.chapterId),
progress: this.readerService.getProgress(this.chapterId),
chapters: this.bookService.getBookChapters(this.chapterId),
info: this.bookService.getBookInfo(this.chapterId)
}).pipe(take(1)).subscribe(results => {
this.chapter = results.chapter;
this.volumeId = results.chapter.volumeId;
this.maxPages = results.chapter.pages;
this.chapters = results.chapters;
this.pageNum = results.progress.pageNum;
this.bookTitle = results.info;
this.init();
}
init() {
this.nextChapterId = CHAPTER_ID_NOT_FETCHED;
this.prevChapterId = CHAPTER_ID_NOT_FETCHED;
this.nextChapterDisabled = false;
this.prevChapterDisabled = false;
this.nextChapterPrefetched = false;
if (this.pageNum >= this.maxPages) {
this.pageNum = this.maxPages - 1;
if (!this.incognitoMode) {
this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, this.pageNum).pipe(take(1)).subscribe(() => {/* No operation */});
}
this.bookService.getBookInfo(this.chapterId).subscribe(info => {
this.bookTitle = info.bookTitle;
if (this.readingListMode && info.seriesFormat !== MangaFormat.EPUB) {
// Redirect to the manga reader.
const params = this.readerService.getQueryParamsObject(this.incognitoMode, this.readingListMode, this.readingListId);
this.router.navigate(['library', info.libraryId, 'series', info.seriesId, 'manga', this.chapterId], {queryParams: params});
return;
}
// Check if user progress has part, if so load it so we scroll to it
this.loadPage(results.progress.bookScrollId || undefined);
}, () => {
setTimeout(() => {
this.closeReader();
}, 200);
forkJoin({
chapter: this.seriesService.getChapter(this.chapterId),
progress: this.readerService.getProgress(this.chapterId),
chapters: this.bookService.getBookChapters(this.chapterId),
}).pipe(take(1)).subscribe(results => {
this.chapter = results.chapter;
this.volumeId = results.chapter.volumeId;
this.maxPages = results.chapter.pages;
this.chapters = results.chapters;
this.pageNum = results.progress.pageNum;
this.continuousChaptersStack.push(this.chapterId);
if (this.pageNum >= this.maxPages) {
this.pageNum = this.maxPages - 1;
if (!this.incognitoMode) {
this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, this.pageNum).pipe(take(1)).subscribe(() => {/* No operation */});
}
}
this.readerService.getNextChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).pipe(take(1)).subscribe(chapterId => {
this.nextChapterId = chapterId;
if (chapterId === CHAPTER_ID_DOESNT_EXIST || chapterId === this.chapterId) {
this.nextChapterDisabled = true;
}
});
this.readerService.getPrevChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).pipe(take(1)).subscribe(chapterId => {
this.prevChapterId = chapterId;
if (chapterId === CHAPTER_ID_DOESNT_EXIST || chapterId === this.chapterId) {
this.prevChapterDisabled = true;
}
});
// Check if user progress has part, if so load it so we scroll to it
this.loadPage(results.progress.bookScrollId || undefined);
}, () => {
setTimeout(() => {
this.closeReader();
}, 200);
});
});
}
@HostListener('window:keydown', ['$event'])
@ -353,7 +467,63 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}
}
loadChapter(pageNum: number, part: string) {
loadNextChapter() {
if (this.nextPageDisabled) { return; }
this.isLoading = true;
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');
});
} else {
this.loadChapter(this.nextChapterId, 'next');
}
}
loadPrevChapter() {
if (this.prevPageDisabled) { return; }
this.isLoading = true;
this.continuousChaptersStack.pop();
const prevChapter = this.continuousChaptersStack.peek();
if (prevChapter != this.chapterId) {
if (prevChapter !== undefined) {
this.chapterId = prevChapter;
this.init();
return;
}
}
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');
});
} else {
this.loadChapter(this.prevChapterId, 'prev');
}
}
loadChapter(chapterId: number, direction: 'next' | 'prev') {
if (chapterId >= 0) {
this.chapterId = chapterId;
this.continuousChaptersStack.push(chapterId);
// Load chapter Id onto route but don't reload
const newRoute = this.readerService.getNextChapterUrl(this.router.url, this.chapterId, this.incognitoMode, this.readingListMode, this.readingListId);
window.history.replaceState({}, '', newRoute);
this.init();
} else {
// This will only happen if no actual chapter can be found
this.toastr.warning('Could not find ' + direction + ' chapter');
this.isLoading = false;
if (direction === 'prev') {
this.prevPageDisabled = true;
} else {
this.nextPageDisabled = true;
}
}
}
loadChapterPage(pageNum: number, part: string) {
this.setPageNum(pageNum);
this.loadPage('id("' + part + '")');
}
@ -572,7 +742,14 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
this.setPageNum(this.pageNum + 1);
}
if (oldPageNum === 0) {
// Move to next volume/chapter automatically
this.loadPrevChapter();
return;
}
if (oldPageNum === this.pageNum) { return; }
this.loadPage();
}
@ -588,7 +765,12 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
} else {
this.setPageNum(this.pageNum - 1);
}
if (this.pageNum >= this.maxPages - 1) {
// Move to next volume/chapter automatically
this.loadNextChapter();
}
if (oldPageNum === this.pageNum) { return; }
this.loadPage();

View File

@ -2,6 +2,7 @@ import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { environment } from 'src/environments/environment';
import { BookChapterItem } from './_models/book-chapter-item';
import { BookInfo } from './_models/book-info';
export interface BookPage {
bookTitle: string;
@ -32,7 +33,7 @@ export class BookService {
}
getBookInfo(chapterId: number) {
return this.http.get<string>(this.baseUrl + 'book/' + chapterId + '/book-info', {responseType: 'text' as 'json'});
return this.http.get<BookInfo>(this.baseUrl + 'book/' + chapterId + '/book-info');
}
getBookPageUrl(chapterId: number, page: number) {

View File

@ -37,8 +37,8 @@
<h5 class="mt-0 mb-1">
<span *ngIf="chapter.number !== '0'; else specialHeader">
<span class="">
<app-card-actionables (actionHandler)="performAction($event, chapter)" [actions]="chapterActions" [labelBy]="'Chapter' + formatChapterNumber(chapter)"></app-card-actionables>
</span>&nbsp;Chapter {{formatChapterNumber(chapter)}}
<app-card-actionables (actionHandler)="performAction($event, chapter)" [actions]="chapterActions" [labelBy]="'Chapter' + formatChapterNumber(chapter)"></app-card-actionables>&nbsp;
</span>Chapter {{formatChapterNumber(chapter)}}
<span class="badge badge-primary badge-pill">
<span *ngIf="chapter.pagesRead > 0 && chapter.pagesRead < chapter.pages">{{chapter.pagesRead}} / {{chapter.pages}}</span>
<span *ngIf="chapter.pagesRead === 0">UNREAD</span>

View File

@ -11,7 +11,7 @@
Promotion means that the tag can be seen server-wide, not just for admin users. All series that have this tag will still have user-access restrictions placed on them.
</p>
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-tabs">
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-tabs nav-pills">
<li [ngbNavItem]="tabs[0]">
<a ngbNavLink>{{tabs[0]}}</a>
<ng-template ngbNavContent>

View File

@ -83,6 +83,7 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
if (metadata) {
this.metadata = metadata;
this.settings.savedData = metadata.tags;
this.tags = metadata.tags;
}
});
@ -147,6 +148,7 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
this.seriesService.updateMetadata(this.metadata, this.tags)
];
if (selectedIndex > 0) {
apis.push(this.uploadService.updateSeriesCoverImage(model.id, this.selectedCover));
}

View File

@ -3,8 +3,8 @@
<div class="col mr-auto">
<h2 style="display: inline-block">
<span *ngIf="actions.length > 0" class="">
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="header"></app-card-actionables>
</span>&nbsp;{{header}}&nbsp;
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="header"></app-card-actionables>&nbsp;
</span>{{header}}&nbsp;
<!-- NOTE: On mobile the pill can eat up a lot of space, we can hide it and move to the filter section if user is interested -->
<span class="badge badge-primary badge-pill" attr.aria-label="{{pagination.totalItems}} total items" *ngIf="pagination != undefined">{{pagination.totalItems}}</span>
</h2>

View File

@ -146,6 +146,7 @@ export class CardItemComponent implements OnInit, OnDestroy {
isPromoted() {
const tag = this.entity as CollectionTag;
// TODO: Validate if this works with reading lists
return tag.hasOwnProperty('promoted') && tag.promoted;
}
}

View File

@ -78,6 +78,9 @@ export class SeriesCardComponent implements OnInit, OnChanges {
case(Action.Bookmarks):
this.actionService.openBookmarkModal(series, (series) => {/* No Operation */ });
break;
case(Action.AddToReadingList):
this.actionService.addSeriesToReadingList(series, (series) => {/* No Operation */ });
break;
default:
break;
}

View File

@ -1,23 +1,8 @@
<ng-container *ngIf="collectionTagId === 0; else collectionTagDetail;">
<app-card-detail-layout header="Collections"
<app-card-detail-layout header="Collections"
[isLoading]="isLoading"
[items]="collections"
(pageChange)="onPageChange($event)"
>
<ng-template #cardItem let-item let-position="idx">
<app-card-item [title]="item.title" [entity]="item" [actions]="collectionTagActions" [imageUrl]="item.coverImage" (clicked)="loadCollection(item)"></app-card-item>
</ng-template>
</app-card-detail-layout>
</ng-container>
<ng-template #collectionTagDetail>
<app-card-detail-layout header="{{collectionTagName}} Collection"
[isLoading]="isLoading"
[items]="series"
[pagination]="seriesPagination"
(pageChange)="onPageChange($event)"
>
<ng-template #cardItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="item.libraryId" (reload)="loadPage()"></app-series-card>
</ng-template>
</app-card-detail-layout>
</ng-template>
</app-card-detail-layout>

View File

@ -1,21 +1,14 @@
import { Component, OnInit } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';
import { Router } from '@angular/router';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { EditCollectionTagsComponent } from 'src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component';
import { CollectionTag } from 'src/app/_models/collection-tag';
import { Pagination } from 'src/app/_models/pagination';
import { Series } from 'src/app/_models/series';
import { ActionItem, ActionFactoryService, Action } from 'src/app/_services/action-factory.service';
import { CollectionTagService } from 'src/app/_services/collection-tag.service';
import { ImageService } from 'src/app/_services/image.service';
import { SeriesService } from 'src/app/_services/series.service';
/**
* This component is used as a standard layout for any card detail. ie) series, in-progress, collections, etc.
*/
@Component({
selector: 'app-all-collections',
templateUrl: './all-collections.component.html',
@ -25,32 +18,13 @@ export class AllCollectionsComponent implements OnInit {
isLoading: boolean = true;
collections: CollectionTag[] = [];
collectionTagId: number = 0; // 0 is not a valid id, if 0, we will load all tags
collectionTagName: string = '';
series: Array<Series> = [];
seriesPagination!: Pagination;
collectionTagActions: ActionItem<CollectionTag>[] = [];
constructor(private collectionService: CollectionTagService, private router: Router, private route: ActivatedRoute,
private seriesService: SeriesService, private toastr: ToastrService, private actionFactoryService: ActionFactoryService,
private modalService: NgbModal, private titleService: Title, private imageService: ImageService) {
constructor(private collectionService: CollectionTagService, private router: Router,
private actionFactoryService: ActionFactoryService, private modalService: NgbModal,
private titleService: Title, private imageService: ImageService) {
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
const routeId = this.route.snapshot.paramMap.get('id');
if (routeId != null) {
this.collectionTagId = parseInt(routeId, 10);
this.collectionService.allTags().subscribe(tags => {
this.collections = tags;
const matchingTags = this.collections.filter(t => t.id === this.collectionTagId);
if (matchingTags.length === 0) {
this.toastr.error('You don\'t have access to any libraries this tag belongs to or this tag is invalid');
this.router.navigate(['collections']);
return;
}
this.collectionTagName = tags.filter(item => item.id === this.collectionTagId)[0].title;
this.titleService.setTitle('Kavita - ' + this.collectionTagName + ' Collection');
});
}
this.titleService.setTitle('Kavita - Collections');
}
ngOnInit() {
@ -60,38 +34,15 @@ export class AllCollectionsComponent implements OnInit {
loadCollection(item: CollectionTag) {
this.collectionTagId = item.id;
this.collectionTagName = item.title;
this.router.navigate(['collections', this.collectionTagId]);
this.router.navigate(['collections', item.id]);
this.loadPage();
}
onPageChange(pagination: Pagination) {
this.router.navigate(['collections', this.collectionTagId], {replaceUrl: true, queryParamsHandling: 'merge', queryParams: {page: this.seriesPagination.currentPage} });
}
loadPage() {
const page = this.route.snapshot.queryParamMap.get('page');
if (page != null) {
if (this.seriesPagination === undefined || this.seriesPagination === null) {
this.seriesPagination = {currentPage: 0, itemsPerPage: 30, totalItems: 0, totalPages: 1};
}
this.seriesPagination.currentPage = parseInt(page, 10);
}
// Reload page after a series is updated or first load
if (this.collectionTagId === 0) {
this.collectionService.allTags().subscribe(tags => {
this.collections = tags;
this.isLoading = false;
});
} else {
this.seriesService.getSeriesForTag(this.collectionTagId, this.seriesPagination?.currentPage, this.seriesPagination?.itemsPerPage).subscribe(tags => {
this.series = tags.result;
this.seriesPagination = tags.pagination;
this.isLoading = false;
window.scrollTo(0, 0);
});
}
this.collectionService.allTags().subscribe(tags => {
this.collections = tags;
this.isLoading = false;
});
}
handleCollectionActionCallback(action: Action, collectionTag: CollectionTag) {

View File

@ -18,6 +18,9 @@ import { AllCollectionsComponent } from './all-collections/all-collections.compo
SharedModule,
CardsModule,
CollectionsRoutingModule,
],
exports: [
AllCollectionsComponent
]
})
export class CollectionsModule { }

View File

@ -0,0 +1,21 @@
<div class="container-fluid">
<nav role="navigation">
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav nav-pills justify-content-center mt-3" role="tab">
<li *ngFor="let tab of tabs" [ngbNavItem]="tab" class="nav-item">
<a ngbNavLink routerLink="." [fragment]="tab.fragment">{{ tab.title | titlecase }}</a>
<ng-template ngbNavContent>
<ng-container *ngIf="tab.fragment === ''">
<app-library></app-library>
</ng-container>
<ng-container *ngIf="tab.fragment === 'lists'">
<app-reading-lists></app-reading-lists>
</ng-container>
<ng-container *ngIf="tab.fragment === 'collections'">
<app-all-collections></app-all-collections>
</ng-container>
</ng-template>
</li>
</ul>
</nav>
<div [ngbNavOutlet]="nav" class="mt-3"></div>
</div>

View File

@ -0,0 +1,37 @@
import { Component, OnInit } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { ActivatedRoute } from '@angular/router';
import { ToastrService } from 'ngx-toastr';
import { ServerService } from '../_services/server.service';
@Component({
selector: 'app-dashboard',
templateUrl: './dashboard.component.html',
styleUrls: ['./dashboard.component.scss']
})
export class DashboardComponent implements OnInit {
tabs: Array<{title: string, fragment: string}> = [
{title: 'Libraries', fragment: ''},
{title: 'Lists', fragment: 'lists'},
{title: 'Collections', fragment: 'collections'},
];
active = this.tabs[0];
constructor(public route: ActivatedRoute, private serverService: ServerService,
private toastr: ToastrService, private titleService: Title) {
this.route.fragment.subscribe(frag => {
const tab = this.tabs.filter(item => item.fragment === frag);
if (tab.length > 0) {
this.active = tab[0];
} else {
this.active = this.tabs[0]; // Default to first tab
}
});
this.titleService.setTitle('Kavita - Dashboard');
}
ngOnInit(): void {
}
}

View File

@ -1,33 +1,24 @@
<div *ngIf="libraries.length === 0 && !isLoading && isAdmin" class="d-flex justify-content-center">
<p>There are no libraries setup yet. Configure some in <a href="/admin/dashboard#libraries">Server settings</a>.</p>
</div>
<div *ngIf="libraries.length === 0 && !isLoading && !isAdmin" class="d-flex justify-content-center">
<p>You haven't been granted access to any libraries.</p>
</div>
<div class="container-fluid">
<div *ngIf="libraries.length === 0 && !isLoading && isAdmin" class="d-flex justify-content-center">
<p>There are no libraries setup yet. Configure some in <a href="/admin/dashboard#libraries">Server settings</a>.</p>
</div>
<div *ngIf="libraries.length === 0 && !isLoading && !isAdmin" class="d-flex justify-content-center">
<p>You haven't been granted access to any libraries.</p>
</div>
<app-carousel-reel [items]="libraries" title="Libraries">
<ng-template #carouselItem let-item let-position="idx">
<app-library-card [data]="item"></app-library-card>
</ng-template>
</app-carousel-reel>
<app-carousel-reel [items]="inProgress" title="In Progress" (sectionClick)="handleSectionClick($event)">
<ng-template #carouselItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="item.libraryId" (reload)="reloadInProgress($event)" (dataChanged)="reloadInProgress($event)"></app-series-card>
</ng-template>
</app-carousel-reel>
<app-carousel-reel [items]="inProgress" title="In Progress" (sectionClick)="handleSectionClick($event)">
<ng-template #carouselItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="item.libraryId" (reload)="reloadInProgress($event)" (dataChanged)="reloadInProgress($event)"></app-series-card>
</ng-template>
</app-carousel-reel>
<app-carousel-reel [items]="recentlyAdded" title="Recently Added" (sectionClick)="handleSectionClick($event)">
<ng-template #carouselItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="item.libraryId" (reload)="reloadTags()" (dataChanged)="loadRecentlyAdded()"></app-series-card>
</ng-template>
</app-carousel-reel>
<app-carousel-reel [items]="recentlyAdded" title="Recently Added" (sectionClick)="handleSectionClick($event)">
<ng-template #carouselItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="item.libraryId" (reload)="reloadTags()" (dataChanged)="loadRecentlyAdded()"></app-series-card>
</ng-template>
</app-carousel-reel>
<app-carousel-reel [items]="collectionTags" title="Collections" (sectionClick)="handleSectionClick($event)">
<ng-template #carouselItem let-item let-position="idx">
<app-card-item [title]="item.title" [entity]="item" [actions]="collectionTagActions" [imageUrl]="item.coverImage" (clicked)="loadCollection(item)"></app-card-item>
</ng-template>
</app-carousel-reel>
</div>
<app-carousel-reel [items]="libraries" title="Libraries">
<ng-template #carouselItem let-item let-position="idx">
<app-library-card [data]="item"></app-library-card>
</ng-template>
</app-carousel-reel>

View File

@ -5,7 +5,6 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { Subject } from 'rxjs';
import { take, takeUntil } from 'rxjs/operators';
import { EditCollectionTagsComponent } from '../cards/_modals/edit-collection-tags/edit-collection-tags.component';
import { ScrollService } from '../scroll.service';
import { CollectionTag } from '../_models/collection-tag';
import { InProgressChapter } from '../_models/in-progress-chapter';
import { Library } from '../_models/library';
@ -33,8 +32,8 @@ export class LibraryComponent implements OnInit, OnDestroy {
recentlyAdded: Series[] = [];
inProgress: Series[] = [];
continueReading: InProgressChapter[] = [];
collectionTags: CollectionTag[] = [];
collectionTagActions: ActionItem<CollectionTag>[] = [];
// collectionTags: CollectionTag[] = [];
// collectionTagActions: ActionItem<CollectionTag>[] = [];
private readonly onDestroy = new Subject<void>();
@ -43,8 +42,7 @@ export class LibraryComponent implements OnInit, OnDestroy {
constructor(public accountService: AccountService, private libraryService: LibraryService,
private seriesService: SeriesService, private actionFactoryService: ActionFactoryService,
private collectionService: CollectionTagService, private router: Router,
private modalService: NgbModal, private titleService: Title, public imageService: ImageService,
private scrollService: ScrollService) { }
private modalService: NgbModal, private titleService: Title, public imageService: ImageService) { }
ngOnInit(): void {
this.titleService.setTitle('Kavita - Dashboard');
@ -58,7 +56,7 @@ export class LibraryComponent implements OnInit, OnDestroy {
});
});
this.collectionTagActions = this.actionFactoryService.getCollectionTagActions(this.handleCollectionActionCallback.bind(this));
//this.collectionTagActions = this.actionFactoryService.getCollectionTagActions(this.handleCollectionActionCallback.bind(this));
this.reloadSeries();
}
@ -103,9 +101,9 @@ export class LibraryComponent implements OnInit, OnDestroy {
}
reloadTags() {
this.collectionService.allTags().pipe(takeUntil(this.onDestroy)).subscribe(tags => {
this.collectionTags = tags;
});
// this.collectionService.allTags().pipe(takeUntil(this.onDestroy)).subscribe(tags => {
// this.collectionTags = tags;
// });
}
handleSectionClick(sectionTitle: string) {
@ -119,24 +117,24 @@ export class LibraryComponent implements OnInit, OnDestroy {
}
loadCollection(item: CollectionTag) {
this.router.navigate(['collections', item.id]);
//this.router.navigate(['collections', item.id]);
}
handleCollectionActionCallback(action: Action, collectionTag: CollectionTag) {
switch (action) {
case(Action.Edit):
const modalRef = this.modalService.open(EditCollectionTagsComponent, { size: 'lg', scrollable: true });
modalRef.componentInstance.tag = collectionTag;
modalRef.closed.subscribe((results: {success: boolean, coverImageUpdated: boolean}) => {
this.reloadTags();
if (results.coverImageUpdated) {
collectionTag.coverImage = this.imageService.randomize(collectionTag.coverImage);
}
});
break;
default:
break;
}
}
// handleCollectionActionCallback(action: Action, collectionTag: CollectionTag) {
// switch (action) {
// case(Action.Edit):
// const modalRef = this.modalService.open(EditCollectionTagsComponent, { size: 'lg', scrollable: true });
// modalRef.componentInstance.tag = collectionTag;
// modalRef.closed.subscribe((results: {success: boolean, coverImageUpdated: boolean}) => {
// this.reloadTags();
// if (results.coverImageUpdated) {
// collectionTag.coverImage = this.imageService.randomize(collectionTag.coverImage);
// }
// });
// break;
// default:
// break;
// }
// }
}

View File

@ -1,8 +1,13 @@
import { MangaFormat } from "src/app/_models/manga-format";
export interface ChapterInfo {
chapterNumber: string;
volumeNumber: string;
chapterTitle: string;
seriesName: string;
seriesFormat: MangaFormat;
seriesId: number;
libraryId: number;
fileName: string;
isSpecial: boolean;
volumeId: number;

View File

@ -22,6 +22,7 @@ import { ChapterInfo } from './_models/chapter-info';
import { COLOR_FILTER, FITTING_OPTION, PAGING_DIRECTION, SPLIT_PAGE_PART } from './_models/reader-enums';
import { Preferences, scalingOptions } from '../_models/preferences/preferences';
import { READER_MODE } from '../_models/preferences/reader-mode';
import { MangaFormat } from '../_models/manga-format';
const PREFETCH_PAGES = 5;
@ -65,12 +66,20 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
seriesId!: number;
volumeId!: number;
chapterId!: number;
/**
* Reading List id. Defaults to -1.
*/
readingListId: number = CHAPTER_ID_DOESNT_EXIST;
/**
* If this is true, no progress will be saved.
*/
incognitoMode: boolean = false;
/**
* If this is true, chapters will be fetched in the order of a reading list, rather than natural series order.
*/
readingListMode: boolean = false;
/**
* The current page. UI will show this number + 1.
*/
@ -265,6 +274,13 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
this.seriesId = parseInt(seriesId, 10);
this.chapterId = parseInt(chapterId, 10);
this.incognitoMode = this.route.snapshot.queryParamMap.get('incognitoMode') === 'true';
const readingListId = this.route.snapshot.queryParamMap.get('readingListId');
if (readingListId != null) {
this.readingListMode = true;
this.readingListId = parseInt(readingListId, 10);
}
this.continuousChaptersStack.push(this.chapterId);
@ -365,6 +381,14 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
chapterInfo: this.readerService.getChapterInfo(this.seriesId, this.chapterId),
bookmarks: this.readerService.getBookmarks(this.chapterId)
}).pipe(take(1)).subscribe(results => {
if (this.readingListMode && results.chapterInfo.seriesFormat === MangaFormat.EPUB) {
// Redirect to the book reader.
const params = this.readerService.getQueryParamsObject(this.incognitoMode, this.readingListMode, this.readingListId);
this.router.navigate(['library', results.chapterInfo.libraryId, 'series', results.chapterInfo.seriesId, 'book', this.chapterId], {queryParams: params});
return;
}
this.volumeId = results.chapterInfo.volumeId;
this.maxPages = results.chapterInfo.pages;
@ -387,13 +411,13 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
this.bookmarks[bookmark.page] = 1;
});
this.readerService.getNextChapter(this.seriesId, this.volumeId, this.chapterId).pipe(take(1)).subscribe(chapterId => {
this.readerService.getNextChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).pipe(take(1)).subscribe(chapterId => {
this.nextChapterId = chapterId;
if (chapterId === CHAPTER_ID_DOESNT_EXIST || chapterId === this.chapterId) {
this.nextChapterDisabled = true;
}
});
this.readerService.getPrevChapter(this.seriesId, this.volumeId, this.chapterId).pipe(take(1)).subscribe(chapterId => {
this.readerService.getPrevChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).pipe(take(1)).subscribe(chapterId => {
this.prevChapterId = chapterId;
if (chapterId === CHAPTER_ID_DOESNT_EXIST || chapterId === this.chapterId) {
this.prevChapterDisabled = true;
@ -674,7 +698,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
if (this.nextPageDisabled) { return; }
this.isLoading = true;
if (this.nextChapterId === CHAPTER_ID_NOT_FETCHED || this.nextChapterId === this.chapterId) {
this.readerService.getNextChapter(this.seriesId, this.volumeId, this.chapterId).pipe(take(1)).subscribe(chapterId => {
this.readerService.getNextChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).pipe(take(1)).subscribe(chapterId => {
this.nextChapterId = chapterId;
this.loadChapter(chapterId, 'next');
});
@ -697,7 +721,7 @@ 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).pipe(take(1)).subscribe(chapterId => {
this.readerService.getPrevChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).pipe(take(1)).subscribe(chapterId => {
this.prevChapterId = chapterId;
this.loadChapter(chapterId, 'prev');
});
@ -711,11 +735,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
this.chapterId = chapterId;
this.continuousChaptersStack.push(chapterId);
// Load chapter Id onto route but don't reload
const lastSlashIndex = this.router.url.lastIndexOf('/');
let newRoute = this.router.url.substring(0, lastSlashIndex + 1) + this.chapterId + '';
if (this.incognitoMode) {
newRoute += '?incognitoMode=true';
}
const newRoute = this.readerService.getNextChapterUrl(this.router.url, this.chapterId, this.incognitoMode, this.readingListMode, this.readingListId);
window.history.replaceState({}, '', newRoute);
this.init();
} else {
@ -734,7 +754,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
renderPage() {
if (this.ctx && this.canvas) {
this.canvasImage.onload = null;
//this.ctx.imageSmoothingEnabled = true;
this.canvas.nativeElement.width = this.canvasImage.width;
this.canvas.nativeElement.height = this.canvasImage.height;
const needsSplitting = this.canvasImage.width > this.canvasImage.height;

View File

@ -6,7 +6,12 @@ const routes: Routes = [
{
path: ':chapterId',
component: MangaReaderComponent
}
},
{
// This will allow the MangaReader to have a list to use for next/prev chapters rather than natural sort order
path: ':chapterId/list/:listId',
component: MangaReaderComponent
}
];

View File

@ -72,7 +72,7 @@ export class NavHeaderComponent implements OnInit, OnDestroy {
onChangeSearch(val: string) {
this.isLoading = true;
this.searchTerm = val;
this.searchTerm = val.trim();
this.libraryService.search(val).pipe(takeUntil(this.onDestroy)).subscribe(results => {
this.searchResults = results;
this.isLoading = false;

View File

@ -0,0 +1,37 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Add to Reading List</h4>
<button type="button" class="close" aria-label="Close" (click)="close()">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<!-- TODO: Put filter here -->
<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-->
{{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>
<li class="list-group-item" *ngIf="loading">
<div class="spinner-border text-secondary" role="status">
<span class="sr-only">Loading...</span>
</div>
</li>
</ul>
</div>
<div class="modal-footer" style="justify-content: normal">
<form style="width: 100%" [formGroup]="listForm">
<div class="form-row">
<div class="col-md-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">
<button type="submit" class="btn btn-primary" (click)="create()">Create</button>
</div>
</div>
</form>
</div>

View File

@ -0,0 +1,7 @@
.clickable {
cursor: pointer;
}
.clickable:hover, .clickable:focus {
background-color: lightgreen;
}

View File

@ -0,0 +1,90 @@
import { AfterViewInit, Component, ElementRef, Input, OnInit, ViewChild } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { ReadingList } from 'src/app/_models/reading-list';
import { ReadingListService } from 'src/app/_services/reading-list.service';
export enum ADD_FLOW {
Series = 0,
Volume = 1,
Chapter = 2
}
@Component({
selector: 'app-add-to-list-modal',
templateUrl: './add-to-list-modal.component.html',
styleUrls: ['./add-to-list-modal.component.scss']
})
export class AddToListModalComponent implements OnInit, AfterViewInit {
@Input() title!: string;
@Input() seriesId?: number;
@Input() volumeId?: number;
@Input() chapterId?: number;
/**
* Determines which Input is required and which API is used to associate to the Reading List
*/
@Input() type!: ADD_FLOW;
/**
* All existing reading lists sorted by recent use date
*/
lists: Array<any> = [];
loading: boolean = false;
listForm: FormGroup = new FormGroup({});
@ViewChild('title') inputElem!: ElementRef<HTMLInputElement>;
constructor(private modal: NgbActiveModal, private readingListService: ReadingListService) { }
ngOnInit(): void {
this.listForm.addControl('title', new FormControl(this.title, []));
this.loading = true;
this.readingListService.getReadingLists(false).subscribe(lists => {
this.lists = lists.result;
this.loading = false;
});
}
ngAfterViewInit() {
// Shift focus to input
if (this.inputElem) {
this.inputElem.nativeElement.select();
}
}
close() {
this.modal.close();
}
create() {
this.readingListService.createList(this.listForm.value.title).subscribe(list => {
this.addToList(list);
});
}
addToList(readingList: ReadingList) {
if (this.seriesId === undefined) return;
if (this.type === ADD_FLOW.Series) {
this.readingListService.updateBySeries(readingList.id, this.seriesId).subscribe(() => {
this.modal.close();
});
} else if (this.type === ADD_FLOW.Volume && this.volumeId !== undefined) {
this.readingListService.updateByVolume(readingList.id, this.seriesId, this.volumeId).subscribe(() => {
this.modal.close();
});
} else if (this.type === ADD_FLOW.Chapter && this.chapterId !== undefined) {
this.readingListService.updateByChapter(readingList.id, this.seriesId, this.chapterId).subscribe(() => {
this.modal.close();
});
}
}
}

View File

@ -0,0 +1,31 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Edit {{readingList.title}} Reading List</h4>
<button type="button" class="close" aria-label="Close" (click)="close()">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p>
This list is currently {{readingList?.promoted ? 'promoted' : 'not promoted'}} (<i class="fa fa-angle-double-up" aria-hidden="true"></i>).
Promotion means that the list can be seen server-wide, not just for admin users. All series that are within this list will still have user-access restrictions placed on them.
</p>
<form [formGroup]="reviewGroup">
<div class="form-group">
<label for="title">Name</label>
<input id="title" class="form-control" formControlName="title" type="text">
</div>
<div class="form-group">
<label for="summary">Summary</label>
<textarea id="summary" class="form-control" formControlName="summary" rows="3"></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="close()">Close</button>
<button type="button" class="btn btn-info" (click)="togglePromotion()">{{readingList.promoted ? 'Demote' : 'Promote'}}</button>
<button type="submit" class="btn btn-primary" [disabled]="reviewGroup.get('title')?.value.trim().length === 0" (click)="save()">Save</button>
</div>

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