Collection Support (#234)

* Readme refactored to be more clean and clear, taking inspiration from wiki.js's readme.

* Initial backend for Collections and basic metadata implemented.

* More build flavors for Raspberry Pi users and updated Install since we don't need users to set their own JWT Token Key. Update a typo in appsettings.json file for prod.

* Fixed #224. Sort before getting a First?Last() chatper

* The rough ability to add and get series metadata and tags.

* Fix a bug on getting metadata for when it doesn't exist.

* Fixed a bug where flattening directories with some unique filenames could cause reading order of images to be out of order.

* Added a seed code to ensure all series have SeriesMetdata

* Ensure all instances of opening an epub is using "using" so we don't lock the file. When we have a malformed html file, log the issues and inform the user we can't open the file.

* Book reader now handles @Import "" statements in CSS and inlines the css into css file that references them. This allows for them to be scoped. In addition, if the html or body tag had classes, we now send back a single div with those classes.

* Fixed GetSeriesDtoForCollectionAsync which was not properly returning series

* Implemented cover image for collection tag. Fixed an issue in metadata update call.

* Add check for user access when resolving series for a collection tag. When asking for all tags, if the user is not an admin, only give promotoed tags back.

* Implemented updateTag api

* Implemented the ability to update series the tags have access to.

* Cleanup, sorting, and null check

* More sorting changes

* Ensure we can delete tags when editing a series tags

* Fix order of update to make sure a tag is properly deleted

* Code smells
This commit is contained in:
Joseph Milazzo 2021-05-30 17:24:23 -05:00 committed by GitHub
parent 3c375da10c
commit d7d7f9b529
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 3577 additions and 38 deletions

View file

@ -31,7 +31,7 @@ namespace API.Controllers
public async Task<ActionResult<string>> GetBookInfo(int chapterId)
{
var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(chapterId);
var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath);
using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath);
return book.Title;
}
@ -47,6 +47,7 @@ namespace API.Controllers
var bookFile = book.Content.AllFiles[key];
var content = await bookFile.ReadContentAsBytesAsync();
Response.AddCacheHeader(content);
var contentType = BookService.GetContentType(bookFile.ContentType);
return File(content, contentType, $"{chapterId}-{file}");
@ -58,7 +59,7 @@ namespace API.Controllers
// This will return a list of mappings from ID -> pagenum. ID will be the xhtml key and pagenum will be the reading order
// this is used to rewrite anchors in the book text so that we always load properly in FE
var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(chapterId);
var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath);
using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath);
var mappings = await _bookService.CreateKeyToPageMappingAsync(book);
var navItems = await book.GetNavigationAsync();
@ -170,11 +171,11 @@ namespace API.Controllers
{
var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(chapterId);
var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath);
using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath);
var mappings = await _bookService.CreateKeyToPageMappingAsync(book);
var counter = 0;
var doc = new HtmlDocument();
var doc = new HtmlDocument {OptionFixNestedTags = true};
var baseUrl = Request.Scheme + "://" + Request.Host + Request.PathBase + "/api/";
var apiBase = baseUrl + "book/" + chapterId + "/" + BookApiUrl;
var bookPages = await book.GetReadingOrderAsync();
@ -186,14 +187,31 @@ namespace API.Controllers
if (contentFileRef.ContentType != EpubContentType.XHTML_1_1) return Ok(content);
doc.LoadHtml(content);
var body = doc.DocumentNode.SelectSingleNode("/html/body");
var body = doc.DocumentNode.SelectSingleNode("//body");
if (body == null)
{
if (doc.ParseErrors.Any())
{
_logger.LogError("{FilePath} has an invalid html file (Page {PageName})", book.FilePath, contentFileRef.FileName);
foreach (var error in doc.ParseErrors)
{
_logger.LogError("Line {LineNumber}, Reason: {Reason}", error.Line, error.Reason);
}
return BadRequest("The file is malformed! Cannot read.");
}
_logger.LogError("{FilePath} has no body tag! Generating one for support. Book may be skewed", book.FilePath);
doc.DocumentNode.SelectSingleNode("/html").AppendChild(HtmlNode.CreateNode("<body></body>"));
body = doc.DocumentNode.SelectSingleNode("/html/body");
}
var inlineStyles = doc.DocumentNode.SelectNodes("//style");
if (inlineStyles != null)
{
foreach (var inlineStyle in inlineStyles)
{
var styleContent = await _bookService.ScopeStyles(inlineStyle.InnerHtml, apiBase);
var styleContent = await _bookService.ScopeStyles(inlineStyle.InnerHtml, apiBase, "", book);
body.PrependChild(HtmlNode.CreateNode($"<style>{styleContent}</style>"));
}
}
@ -217,7 +235,8 @@ namespace API.Controllers
key = correctedKey;
}
var styleContent = await _bookService.ScopeStyles(await book.Content.Css[key].ReadContentAsync(), apiBase);
var styleContent = await _bookService.ScopeStyles(await book.Content.Css[key].ReadContentAsync(), apiBase, book.Content.Css[key].FileName, book);
body.PrependChild(HtmlNode.CreateNode($"<style>{styleContent}</style>"));
}
}
@ -280,10 +299,19 @@ namespace API.Controllers
}
}
// Check if any classes on the html node (some r2l books do this) and move them to body tag for scoping
var htmlNode = doc.DocumentNode.SelectSingleNode("//html");
if (htmlNode != null && htmlNode.Attributes.Contains("class"))
{
var bodyClasses = body.Attributes.Contains("class") ? body.Attributes["class"].Value : string.Empty;
var classes = htmlNode.Attributes["class"].Value + " " + bodyClasses;
body.Attributes.Add("class", $"{classes}");
// I actually need the body tag itself for the classes, so i will create a div and put the body stuff there.
return Ok($"<div class=\"{body.Attributes["class"].Value}\">{body.InnerHtml}</div>");
}
return Ok(body.InnerHtml);
return Ok(body.InnerHtml);
}
counter++;

View file

@ -0,0 +1,118 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Constants;
using API.DTOs;
using API.Entities;
using API.Extensions;
using API.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace API.Controllers
{
public class CollectionController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly UserManager<AppUser> _userManager;
public CollectionController(IUnitOfWork unitOfWork, UserManager<AppUser> userManager)
{
_unitOfWork = unitOfWork;
_userManager = userManager;
}
[HttpGet]
public async Task<IEnumerable<CollectionTagDto>> GetAllTags()
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
var isAdmin = await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole);
if (isAdmin)
{
return await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync();
}
else
{
return await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync();
}
}
[Authorize(Policy = "RequireAdminRole")]
[HttpGet("search")]
public async Task<IEnumerable<CollectionTagDto>> SearchTags(string queryString)
{
queryString ??= "";
queryString = queryString.Replace(@"%", "");
if (queryString.Length == 0) return await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync();
return await _unitOfWork.CollectionTagRepository.SearchTagDtosAsync(queryString);
}
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("update")]
public async Task<ActionResult> UpdateTag(CollectionTagDto updatedTag)
{
var existingTag = await _unitOfWork.CollectionTagRepository.GetTagAsync(updatedTag.Id);
if (existingTag == null) return BadRequest("This tag does not exist");
existingTag.Promoted = updatedTag.Promoted;
existingTag.Title = updatedTag.Title;
existingTag.NormalizedTitle = Parser.Parser.Normalize(updatedTag.Title).ToUpper();
if (_unitOfWork.HasChanges())
{
if (await _unitOfWork.Complete())
{
return Ok("Tag updated successfully");
}
}
else
{
return Ok("Tag updated successfully");
}
return BadRequest("Something went wrong, please try again");
}
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("update-series")]
public async Task<ActionResult> UpdateSeriesForTag(UpdateSeriesForTagDto updateSeriesForTagDto)
{
var tag = await _unitOfWork.CollectionTagRepository.GetFullTagAsync(updateSeriesForTagDto.Tag.Id);
if (tag == null) return BadRequest("Not a valid Tag");
tag.SeriesMetadatas ??= new List<SeriesMetadata>();
// Check if Tag has updated (Summary)
if (tag.Summary == null || !tag.Summary.Equals(updateSeriesForTagDto.Tag.Summary))
{
tag.Summary = updateSeriesForTagDto.Tag.Summary;
_unitOfWork.CollectionTagRepository.Update(tag);
}
foreach (var seriesIdToRemove in updateSeriesForTagDto.SeriesIdsToRemove)
{
tag.SeriesMetadatas.Remove(tag.SeriesMetadatas.Single(sm => sm.SeriesId == seriesIdToRemove));
}
if (tag.SeriesMetadatas.Count == 0)
{
_unitOfWork.CollectionTagRepository.Remove(tag);
}
if (_unitOfWork.HasChanges() && await _unitOfWork.Complete())
{
return Ok("Tag updated");
}
return BadRequest("Something went wrong. Please try again.");
}
}
}

View file

@ -46,5 +46,16 @@ namespace API.Controllers
Response.AddCacheHeader(content);
return File(content, "image/" + format, $"seriesId");
}
[HttpGet("collection-cover")]
public async Task<ActionResult> GetCollectionCoverImage(int collectionTagId)
{
var content = await _unitOfWork.CollectionTagRepository.GetCoverImageAsync(collectionTagId);
if (content == null) return BadRequest("No cover image");
const string format = "jpeg";
Response.AddCacheHeader(content);
return File(content, "image/" + format, $"collectionTagId");
}
}
}

View file

@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.DTOs;
using API.Entities;
using API.Extensions;
@ -168,5 +170,94 @@ namespace API.Controllers
_taskScheduler.RefreshSeriesMetadata(refreshSeriesDto.LibraryId, refreshSeriesDto.SeriesId);
return Ok();
}
[HttpGet("metadata")]
public async Task<ActionResult<SeriesMetadataDto>> GetSeriesMetadata(int seriesId)
{
var metadata = await _unitOfWork.SeriesRepository.GetSeriesMetadata(seriesId);
return Ok(metadata);
}
[HttpPost("metadata")]
public async Task<ActionResult> UpdateSeriesMetadata(UpdateSeriesMetadataDto updateSeriesMetadataDto)
{
var seriesId = updateSeriesMetadataDto.SeriesMetadata.SeriesId;
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId);
if (series.Metadata == null)
{
series.Metadata = DbFactory.SeriesMetadata(updateSeriesMetadataDto.Tags
.Select(dto => DbFactory.CollectionTag(dto.Id, dto.Title, dto.Summary, dto.Promoted)).ToList());
}
else
{
series.Metadata.CollectionTags ??= new List<CollectionTag>();
var newTags = new List<CollectionTag>();
// I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different
var existingTags = series.Metadata.CollectionTags.ToList();
foreach (var existing in existingTags)
{
if (updateSeriesMetadataDto.Tags.SingleOrDefault(t => t.Id == existing.Id) == null)
{
// Remove tag
series.Metadata.CollectionTags.Remove(existing);
}
}
// At this point, all tags that aren't in dto have been removed.
foreach (var tag in updateSeriesMetadataDto.Tags)
{
var existingTag = series.Metadata.CollectionTags.SingleOrDefault(t => t.Title == tag.Title);
if (existingTag != null)
{
// Update existingTag
existingTag.Promoted = tag.Promoted;
existingTag.Title = tag.Title;
existingTag.NormalizedTitle = Parser.Parser.Normalize(tag.Title).ToUpper();
}
else
{
// Add new tag
newTags.Add(DbFactory.CollectionTag(tag.Id, tag.Title, tag.Summary, tag.Promoted));
}
}
foreach (var tag in newTags)
{
series.Metadata.CollectionTags.Add(tag);
}
}
if (!_unitOfWork.HasChanges())
{
return Ok("No changes to save");
}
if (await _unitOfWork.Complete())
{
return Ok("Successfully updated");
}
return BadRequest("Could not update metadata");
}
[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 series =
await _unitOfWork.SeriesRepository.GetSeriesDtoForCollectionAsync(collectionId, user.Id, 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);
Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages);
return Ok(series);
}
}
}