From d7d7f9b5299606c18b4cfb403aceabcd944860fa Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Sun, 30 May 2021 17:24:23 -0500 Subject: [PATCH] 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 --- API.Tests/Extensions/SeriesExtensionsTests.cs | 3 +- API.Tests/Helpers/EntityFactory.cs | 23 +- API.Tests/Services/ScannerServiceTests.cs | 6 +- API/Controllers/BookController.cs | 48 +- API/Controllers/CollectionController.cs | 118 +++ API/Controllers/ImageController.cs | 11 + API/Controllers/SeriesController.cs | 91 ++ API/DTOs/CollectionTagDto.cs | 12 + API/DTOs/PersonDto.cs | 10 + API/DTOs/SeriesMetadataDto.cs | 15 + API/DTOs/UpdateSeriesForTagDto.cs | 10 + API/DTOs/UpdateSeriesMetadataDto.cs | 11 + API/Data/CollectionTagRepository.cs | 90 ++ API/Data/DataContext.cs | 2 + API/Data/DbFactory.cs | 20 + .../20210519215934_CollectionTag.Designer.cs | 851 +++++++++++++++++ .../20210519215934_CollectionTag.cs | 107 +++ ...528150353_CollectionCoverImage.Designer.cs | 854 +++++++++++++++++ .../20210528150353_CollectionCoverImage.cs | 24 + ...210530201541_CollectionSummary.Designer.cs | 857 ++++++++++++++++++ .../20210530201541_CollectionSummary.cs | 23 + .../Migrations/DataContextModelSnapshot.cs | 100 ++ API/Data/Seed.cs | 17 + API/Data/SeriesRepository.cs | 45 + API/Data/UnitOfWork.cs | 1 + API/Entities/CollectionTag.cs | 50 + API/Entities/Enums/PersonRole.cs | 19 + API/Entities/Genre.cs | 20 + API/Entities/Person.cs | 21 + API/Entities/Series.cs | 4 +- API/Entities/SeriesMetadata.cs | 31 + API/Helpers/AutoMapperProfiles.cs | 6 + API/Interfaces/IBookService.cs | 5 +- API/Interfaces/ICollectionTagRepository.cs | 19 + API/Interfaces/ISeriesRepository.cs | 2 + API/Interfaces/IUnitOfWork.cs | 1 + API/Parser/Parser.cs | 1 + API/Program.cs | 3 + API/Services/BookService.cs | 37 +- API/Services/Tasks/ScannerService.cs | 1 + Kavita.Common/Kavita.Common.csproj | 2 +- README.md | 44 +- 42 files changed, 3577 insertions(+), 38 deletions(-) create mode 100644 API/Controllers/CollectionController.cs create mode 100644 API/DTOs/CollectionTagDto.cs create mode 100644 API/DTOs/PersonDto.cs create mode 100644 API/DTOs/SeriesMetadataDto.cs create mode 100644 API/DTOs/UpdateSeriesForTagDto.cs create mode 100644 API/DTOs/UpdateSeriesMetadataDto.cs create mode 100644 API/Data/CollectionTagRepository.cs create mode 100644 API/Data/Migrations/20210519215934_CollectionTag.Designer.cs create mode 100644 API/Data/Migrations/20210519215934_CollectionTag.cs create mode 100644 API/Data/Migrations/20210528150353_CollectionCoverImage.Designer.cs create mode 100644 API/Data/Migrations/20210528150353_CollectionCoverImage.cs create mode 100644 API/Data/Migrations/20210530201541_CollectionSummary.Designer.cs create mode 100644 API/Data/Migrations/20210530201541_CollectionSummary.cs create mode 100644 API/Entities/CollectionTag.cs create mode 100644 API/Entities/Enums/PersonRole.cs create mode 100644 API/Entities/Genre.cs create mode 100644 API/Entities/Person.cs create mode 100644 API/Entities/SeriesMetadata.cs create mode 100644 API/Interfaces/ICollectionTagRepository.cs diff --git a/API.Tests/Extensions/SeriesExtensionsTests.cs b/API.Tests/Extensions/SeriesExtensionsTests.cs index 86d788036..6631757d6 100644 --- a/API.Tests/Extensions/SeriesExtensionsTests.cs +++ b/API.Tests/Extensions/SeriesExtensionsTests.cs @@ -22,7 +22,8 @@ namespace API.Tests.Extensions Name = seriesInput[0], LocalizedName = seriesInput[1], OriginalName = seriesInput[2], - NormalizedName = seriesInput.Length == 4 ? seriesInput[3] : API.Parser.Parser.Normalize(seriesInput[0]) + NormalizedName = seriesInput.Length == 4 ? seriesInput[3] : API.Parser.Parser.Normalize(seriesInput[0]), + Metadata = new SeriesMetadata() }; Assert.Equal(expected, series.NameInList(list)); diff --git a/API.Tests/Helpers/EntityFactory.cs b/API.Tests/Helpers/EntityFactory.cs index b3b09d486..456cd1b52 100644 --- a/API.Tests/Helpers/EntityFactory.cs +++ b/API.Tests/Helpers/EntityFactory.cs @@ -17,7 +17,8 @@ namespace API.Tests.Helpers SortName = name, LocalizedName = name, NormalizedName = API.Parser.Parser.Normalize(name), - Volumes = new List() + Volumes = new List(), + Metadata = new SeriesMetadata() }; } @@ -53,5 +54,25 @@ namespace API.Tests.Helpers Pages = pages }; } + + public static SeriesMetadata CreateSeriesMetadata(ICollection collectionTags) + { + return new SeriesMetadata() + { + CollectionTags = collectionTags + }; + } + + public static CollectionTag CreateCollectionTag(int id, string title, string summary, bool promoted) + { + return new CollectionTag() + { + Id = id, + NormalizedTitle = API.Parser.Parser.Normalize(title).ToUpper(), + Title = title, + Summary = summary, + Promoted = promoted + }; + } } } \ No newline at end of file diff --git a/API.Tests/Services/ScannerServiceTests.cs b/API.Tests/Services/ScannerServiceTests.cs index 7b7e6bc2f..7c3c47355 100644 --- a/API.Tests/Services/ScannerServiceTests.cs +++ b/API.Tests/Services/ScannerServiceTests.cs @@ -106,14 +106,16 @@ namespace API.Tests.Services Name = "Cage of Eden", LocalizedName = "Cage of Eden", OriginalName = "Cage of Eden", - NormalizedName = API.Parser.Parser.Normalize("Cage of Eden") + NormalizedName = API.Parser.Parser.Normalize("Cage of Eden"), + Metadata = new SeriesMetadata() }); existingSeries.Add(new Series() { Name = "Darker Than Black", LocalizedName = "Darker Than Black", OriginalName = "Darker Than Black", - NormalizedName = API.Parser.Parser.Normalize("Darker Than Black") + NormalizedName = API.Parser.Parser.Normalize("Darker Than Black"), + Metadata = new SeriesMetadata() }); diff --git a/API/Controllers/BookController.cs b/API/Controllers/BookController.cs index 01588f3f4..a2af28ab6 100644 --- a/API/Controllers/BookController.cs +++ b/API/Controllers/BookController.cs @@ -31,7 +31,7 @@ namespace API.Controllers public async Task> 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 = 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($"")); } } @@ -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($"")); } } @@ -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($"
{body.InnerHtml}
"); + } - - return Ok(body.InnerHtml); + return Ok(body.InnerHtml); } counter++; diff --git a/API/Controllers/CollectionController.cs b/API/Controllers/CollectionController.cs new file mode 100644 index 000000000..27455a283 --- /dev/null +++ b/API/Controllers/CollectionController.cs @@ -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 _userManager; + + public CollectionController(IUnitOfWork unitOfWork, UserManager userManager) + { + _unitOfWork = unitOfWork; + _userManager = userManager; + } + + [HttpGet] + public async Task> 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> 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 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 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(); + + // 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."); + } + + + + } +} \ No newline at end of file diff --git a/API/Controllers/ImageController.cs b/API/Controllers/ImageController.cs index b05f99409..234ef2ae6 100644 --- a/API/Controllers/ImageController.cs +++ b/API/Controllers/ImageController.cs @@ -46,5 +46,16 @@ namespace API.Controllers Response.AddCacheHeader(content); return File(content, "image/" + format, $"seriesId"); } + + [HttpGet("collection-cover")] + public async Task 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"); + } } } \ No newline at end of file diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index cde3d9c0f..ac355abcb 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -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> GetSeriesMetadata(int seriesId) + { + var metadata = await _unitOfWork.SeriesRepository.GetSeriesMetadata(seriesId); + return Ok(metadata); + } + + [HttpPost("metadata")] + public async Task 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(); + var newTags = new List(); + + // 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>> 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); + } + + } } \ No newline at end of file diff --git a/API/DTOs/CollectionTagDto.cs b/API/DTOs/CollectionTagDto.cs new file mode 100644 index 000000000..72027e84a --- /dev/null +++ b/API/DTOs/CollectionTagDto.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; + +namespace API.DTOs +{ + public class CollectionTagDto + { + public int Id { get; set; } + public string Title { get; set; } + public string Summary { get; set; } + public bool Promoted { get; set; } + } +} \ No newline at end of file diff --git a/API/DTOs/PersonDto.cs b/API/DTOs/PersonDto.cs new file mode 100644 index 000000000..646817c1d --- /dev/null +++ b/API/DTOs/PersonDto.cs @@ -0,0 +1,10 @@ +using API.Entities.Enums; + +namespace API.DTOs +{ + public class PersonDto + { + public string Name { get; set; } + public PersonRole Role { get; set; } + } +} \ No newline at end of file diff --git a/API/DTOs/SeriesMetadataDto.cs b/API/DTOs/SeriesMetadataDto.cs new file mode 100644 index 000000000..47d5cbee2 --- /dev/null +++ b/API/DTOs/SeriesMetadataDto.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using API.Entities; + +namespace API.DTOs +{ + public class SeriesMetadataDto + { + public int Id { get; set; } + public ICollection Genres { get; set; } + public ICollection Tags { get; set; } + public ICollection Persons { get; set; } + public string Publisher { get; set; } + public int SeriesId { get; set; } + } +} \ No newline at end of file diff --git a/API/DTOs/UpdateSeriesForTagDto.cs b/API/DTOs/UpdateSeriesForTagDto.cs new file mode 100644 index 000000000..743981165 --- /dev/null +++ b/API/DTOs/UpdateSeriesForTagDto.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; + +namespace API.DTOs +{ + public class UpdateSeriesForTagDto + { + public CollectionTagDto Tag { get; init; } + public ICollection SeriesIdsToRemove { get; init; } + } +} \ No newline at end of file diff --git a/API/DTOs/UpdateSeriesMetadataDto.cs b/API/DTOs/UpdateSeriesMetadataDto.cs new file mode 100644 index 000000000..fd71526b7 --- /dev/null +++ b/API/DTOs/UpdateSeriesMetadataDto.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using API.Entities; + +namespace API.DTOs +{ + public class UpdateSeriesMetadataDto + { + public SeriesMetadataDto SeriesMetadata { get; set; } + public ICollection Tags { get; set; } + } +} \ No newline at end of file diff --git a/API/Data/CollectionTagRepository.cs b/API/Data/CollectionTagRepository.cs new file mode 100644 index 000000000..77cfe70f2 --- /dev/null +++ b/API/Data/CollectionTagRepository.cs @@ -0,0 +1,90 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs; +using API.Entities; +using API.Interfaces; +using AutoMapper; +using AutoMapper.QueryableExtensions; +using Microsoft.EntityFrameworkCore; + +namespace API.Data +{ + public class CollectionTagRepository : ICollectionTagRepository + { + private readonly DataContext _context; + private readonly IMapper _mapper; + + public CollectionTagRepository(DataContext context, IMapper mapper) + { + _context = context; + _mapper = mapper; + } + + public void Remove(CollectionTag tag) + { + _context.CollectionTag.Remove(tag); + } + + public void Update(CollectionTag tag) + { + _context.Entry(tag).State = EntityState.Modified; + } + + public async Task> GetAllTagDtosAsync() + { + return await _context.CollectionTag + .Select(c => c) + .OrderBy(c => c.NormalizedTitle) + .AsNoTracking() + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } + + public async Task> GetAllPromotedTagDtosAsync() + { + return await _context.CollectionTag + .Where(c => c.Promoted) + .OrderBy(c => c.NormalizedTitle) + .AsNoTracking() + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } + + public async Task GetTagAsync(int tagId) + { + return await _context.CollectionTag + .Where(c => c.Id == tagId) + .SingleOrDefaultAsync(); + } + + public async Task GetFullTagAsync(int tagId) + { + return await _context.CollectionTag + .Where(c => c.Id == tagId) + .Include(c => c.SeriesMetadatas) + .SingleOrDefaultAsync(); + } + + public async Task> SearchTagDtosAsync(string searchQuery) + { + return await _context.CollectionTag + .Where(s => EF.Functions.Like(s.Title, $"%{searchQuery}%") + || EF.Functions.Like(s.NormalizedTitle, $"%{searchQuery}%")) + .OrderBy(s => s.Title) + .AsNoTracking() + .OrderBy(c => c.NormalizedTitle) + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } + + public Task GetCoverImageAsync(int collectionTagId) + { + return _context.CollectionTag + .Where(c => c.Id == collectionTagId) + .Select(c => c.CoverImage) + .AsNoTracking() + .SingleOrDefaultAsync(); + } + } +} \ No newline at end of file diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index 9f7437cc3..008d96ed2 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -29,6 +29,8 @@ namespace API.Data public DbSet AppUserRating { get; set; } public DbSet ServerSetting { get; set; } public DbSet AppUserPreferences { get; set; } + public DbSet SeriesMetadata { get; set; } + public DbSet CollectionTag { get; set; } protected override void OnModelCreating(ModelBuilder builder) { diff --git a/API/Data/DbFactory.cs b/API/Data/DbFactory.cs index 3589fc30e..e55ed0cd8 100644 --- a/API/Data/DbFactory.cs +++ b/API/Data/DbFactory.cs @@ -50,5 +50,25 @@ namespace API.Data IsSpecial = specialTreatment, }; } + + public static SeriesMetadata SeriesMetadata(ICollection collectionTags) + { + return new SeriesMetadata() + { + CollectionTags = collectionTags + }; + } + + public static CollectionTag CollectionTag(int id, string title, string summary, bool promoted) + { + return new CollectionTag() + { + Id = id, + NormalizedTitle = API.Parser.Parser.Normalize(title).ToUpper(), + Title = title, + Summary = summary, + Promoted = promoted + }; + } } } \ No newline at end of file diff --git a/API/Data/Migrations/20210519215934_CollectionTag.Designer.cs b/API/Data/Migrations/20210519215934_CollectionTag.Designer.cs new file mode 100644 index 000000000..17c4ec353 --- /dev/null +++ b/API/Data/Migrations/20210519215934_CollectionTag.Designer.cs @@ -0,0 +1,851 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20210519215934_CollectionTag")] + partial class CollectionTag + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "5.0.4"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookReaderDarkMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SiteDarkMode") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId") + .IsUnique(); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20210519215934_CollectionTag.cs b/API/Data/Migrations/20210519215934_CollectionTag.cs new file mode 100644 index 000000000..b6afc209e --- /dev/null +++ b/API/Data/Migrations/20210519215934_CollectionTag.cs @@ -0,0 +1,107 @@ +using API.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace API.Data.Migrations +{ + public partial class CollectionTag : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "CollectionTag", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Title = table.Column(type: "TEXT", nullable: true), + NormalizedTitle = table.Column(type: "TEXT", nullable: true), + Promoted = table.Column(type: "INTEGER", nullable: false), + RowVersion = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CollectionTag", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "SeriesMetadata", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + SeriesId = table.Column(type: "INTEGER", nullable: false), + RowVersion = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SeriesMetadata", x => x.Id); + table.ForeignKey( + name: "FK_SeriesMetadata_Series_SeriesId", + column: x => x.SeriesId, + principalTable: "Series", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "CollectionTagSeriesMetadata", + columns: table => new + { + CollectionTagsId = table.Column(type: "INTEGER", nullable: false), + SeriesMetadatasId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CollectionTagSeriesMetadata", x => new { x.CollectionTagsId, x.SeriesMetadatasId }); + table.ForeignKey( + name: "FK_CollectionTagSeriesMetadata_CollectionTag_CollectionTagsId", + column: x => x.CollectionTagsId, + principalTable: "CollectionTag", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_CollectionTagSeriesMetadata_SeriesMetadata_SeriesMetadatasId", + column: x => x.SeriesMetadatasId, + principalTable: "SeriesMetadata", + principalColumn: "Id", + onDelete: ReferentialAction.NoAction); + }); + + migrationBuilder.CreateIndex( + name: "IX_CollectionTag_Id_Promoted", + table: "CollectionTag", + columns: new[] { "Id", "Promoted" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_CollectionTagSeriesMetadata_SeriesMetadatasId", + table: "CollectionTagSeriesMetadata", + column: "SeriesMetadatasId"); + + migrationBuilder.CreateIndex( + name: "IX_SeriesMetadata_Id_SeriesId", + table: "SeriesMetadata", + columns: new[] { "Id", "SeriesId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_SeriesMetadata_SeriesId", + table: "SeriesMetadata", + column: "SeriesId", + unique: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "CollectionTagSeriesMetadata"); + + migrationBuilder.DropTable( + name: "CollectionTag"); + + migrationBuilder.DropTable( + name: "SeriesMetadata"); + } + } +} diff --git a/API/Data/Migrations/20210528150353_CollectionCoverImage.Designer.cs b/API/Data/Migrations/20210528150353_CollectionCoverImage.Designer.cs new file mode 100644 index 000000000..b3d4c3d4a --- /dev/null +++ b/API/Data/Migrations/20210528150353_CollectionCoverImage.Designer.cs @@ -0,0 +1,854 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20210528150353_CollectionCoverImage")] + partial class CollectionCoverImage + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "5.0.4"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookReaderDarkMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SiteDarkMode") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId") + .IsUnique(); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20210528150353_CollectionCoverImage.cs b/API/Data/Migrations/20210528150353_CollectionCoverImage.cs new file mode 100644 index 000000000..a38f8cf93 --- /dev/null +++ b/API/Data/Migrations/20210528150353_CollectionCoverImage.cs @@ -0,0 +1,24 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace API.Data.Migrations +{ + public partial class CollectionCoverImage : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CoverImage", + table: "CollectionTag", + type: "BLOB", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CoverImage", + table: "CollectionTag"); + } + } +} diff --git a/API/Data/Migrations/20210530201541_CollectionSummary.Designer.cs b/API/Data/Migrations/20210530201541_CollectionSummary.Designer.cs new file mode 100644 index 000000000..9d5507b38 --- /dev/null +++ b/API/Data/Migrations/20210530201541_CollectionSummary.Designer.cs @@ -0,0 +1,857 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20210530201541_CollectionSummary")] + partial class CollectionSummary + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "5.0.4"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookReaderDarkMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SiteDarkMode") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId") + .IsUnique(); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20210530201541_CollectionSummary.cs b/API/Data/Migrations/20210530201541_CollectionSummary.cs new file mode 100644 index 000000000..255ad78f3 --- /dev/null +++ b/API/Data/Migrations/20210530201541_CollectionSummary.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace API.Data.Migrations +{ + public partial class CollectionSummary : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Summary", + table: "CollectionTag", + type: "TEXT", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Summary", + table: "CollectionTag"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 6b8950110..357a6fcd4 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -279,6 +279,39 @@ namespace API.Data.Migrations b.ToTable("Chapter"); }); + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + modelBuilder.Entity("API.Entities.FolderPath", b => { b.Property("Id") @@ -404,6 +437,30 @@ namespace API.Data.Migrations b.ToTable("Series"); }); + modelBuilder.Entity("API.Entities.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + modelBuilder.Entity("API.Entities.ServerSetting", b => { b.Property("Key") @@ -470,6 +527,21 @@ namespace API.Data.Migrations b.ToTable("AppUserLibrary"); }); + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.Property("Id") @@ -650,6 +722,17 @@ namespace API.Data.Migrations b.Navigation("Library"); }); + modelBuilder.Entity("API.Entities.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + modelBuilder.Entity("API.Entities.Volume", b => { b.HasOne("API.Entities.Series", "Series") @@ -676,6 +759,21 @@ namespace API.Data.Migrations .IsRequired(); }); + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.HasOne("API.Entities.AppRole", null) @@ -742,6 +840,8 @@ namespace API.Data.Migrations modelBuilder.Entity("API.Entities.Series", b => { + b.Navigation("Metadata"); + b.Navigation("Volumes"); }); diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index ad0c09236..2dfeb1c0a 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -7,6 +7,7 @@ using API.Entities; using API.Entities.Enums; using API.Services; using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; namespace API.Data { @@ -55,5 +56,21 @@ namespace API.Data await context.SaveChangesAsync(); } + + public static async Task SeedSeriesMetadata(DataContext context) + { + await context.Database.EnsureCreatedAsync(); + + context.Database.EnsureCreated(); + var series = await context.Series + .Include(s => s.Metadata).ToListAsync(); + + foreach (var s in series) + { + s.Metadata ??= new SeriesMetadata(); + } + + await context.SaveChangesAsync(); + } } } \ No newline at end of file diff --git a/API/Data/SeriesRepository.cs b/API/Data/SeriesRepository.cs index e4a715f11..b77064898 100644 --- a/API/Data/SeriesRepository.cs +++ b/API/Data/SeriesRepository.cs @@ -199,6 +199,8 @@ namespace API.Data { return await _context.Series .Include(s => s.Volumes) + .Include(s => s.Metadata) + .ThenInclude(m => m.CollectionTags) .Where(s => s.Id == seriesId) .SingleOrDefaultAsync(); } @@ -366,5 +368,48 @@ namespace API.Data return retSeries.DistinctBy(s => s.Name).Take(limit); } + + public async Task GetSeriesMetadata(int seriesId) + { + var metadataDto = await _context.SeriesMetadata + .Where(metadata => metadata.SeriesId == seriesId) + .AsNoTracking() + .ProjectTo(_mapper.ConfigurationProvider) + .SingleOrDefaultAsync(); + + if (metadataDto != null) + { + metadataDto.Tags = await _context.CollectionTag + .Include(t => t.SeriesMetadatas) + .Where(t => t.SeriesMetadatas.Select(s => s.SeriesId).Contains(seriesId)) + .ProjectTo(_mapper.ConfigurationProvider) + .AsNoTracking() + .ToListAsync(); + } + + return metadataDto; + } + + public async Task> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams) + { + var userLibraries = _context.Library + .Include(l => l.AppUsers) + .Where(library => library.AppUsers.Any(user => user.Id == userId)) + .AsNoTracking() + .Select(library => library.Id) + .ToList(); + + var query = _context.CollectionTag + .Where(s => s.Id == collectionId) + .Include(c => c.SeriesMetadatas) + .ThenInclude(m => m.Series) + .SelectMany(c => c.SeriesMetadatas.Select(sm => sm.Series).Where(s => userLibraries.Contains(s.LibraryId))) + .OrderBy(s => s.LibraryId) + .ThenBy(s => s.SortName) + .ProjectTo(_mapper.ConfigurationProvider) + .AsNoTracking(); + + return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); + } } } \ No newline at end of file diff --git a/API/Data/UnitOfWork.cs b/API/Data/UnitOfWork.cs index caa97523f..178136e3a 100644 --- a/API/Data/UnitOfWork.cs +++ b/API/Data/UnitOfWork.cs @@ -28,6 +28,7 @@ namespace API.Data public ISettingsRepository SettingsRepository => new SettingsRepository(_context, _mapper); public IAppUserProgressRepository AppUserProgressRepository => new AppUserProgressRepository(_context); + public ICollectionTagRepository CollectionTagRepository => new CollectionTagRepository(_context, _mapper); public async Task Complete() { diff --git a/API/Entities/CollectionTag.cs b/API/Entities/CollectionTag.cs new file mode 100644 index 000000000..685b70841 --- /dev/null +++ b/API/Entities/CollectionTag.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using API.Entities.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace API.Entities +{ + /// + /// Represents a user entered field that is used as a tagging and grouping mechanism + /// + [Index(nameof(Id), nameof(Promoted), IsUnique = true)] + public class CollectionTag : IHasConcurrencyToken + { + public int Id { get; set; } + /// + /// Visible title of the Tag + /// + public string Title { get; set; } + + /// + /// Cover Image for the collection tag + /// + public byte[] CoverImage { get; set; } + + /// + /// A description of the tag + /// + public string Summary { get; set; } + + /// + /// A normalized string used to check if the tag already exists in the DB + /// + public string NormalizedTitle { get; set; } + /// + /// A promoted collection tag will allow all linked seriesMetadata's Series to show for all users. + /// + public bool Promoted { get; set; } + + public ICollection SeriesMetadatas { get; set; } + + + [ConcurrencyCheck] + public uint RowVersion { get; set; } + + public void OnSavingChanges() + { + RowVersion++; + } + } +} \ No newline at end of file diff --git a/API/Entities/Enums/PersonRole.cs b/API/Entities/Enums/PersonRole.cs new file mode 100644 index 000000000..47e60721b --- /dev/null +++ b/API/Entities/Enums/PersonRole.cs @@ -0,0 +1,19 @@ +namespace API.Entities.Enums +{ + public enum PersonRole + { + /// + /// Another role, not covered by other types + /// + Other = 0, + /// + /// Author + /// + Author = 1, + /// + /// Artist + /// + Artist = 2, + + } +} \ No newline at end of file diff --git a/API/Entities/Genre.cs b/API/Entities/Genre.cs new file mode 100644 index 000000000..743c2b793 --- /dev/null +++ b/API/Entities/Genre.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; +using API.Entities.Interfaces; + +namespace API.Entities +{ + public class Genre : IHasConcurrencyToken + { + public int Id { get; set; } + public string Name { get; set; } + // TODO: MetadataUpdate add ProviderId + + [ConcurrencyCheck] + public uint RowVersion { get; set; } + + public void OnSavingChanges() + { + RowVersion++; + } + } +} \ No newline at end of file diff --git a/API/Entities/Person.cs b/API/Entities/Person.cs new file mode 100644 index 000000000..750274b8a --- /dev/null +++ b/API/Entities/Person.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; +using API.Entities.Enums; +using API.Entities.Interfaces; + +namespace API.Entities +{ + public class Person : IHasConcurrencyToken + { + public int Id { get; set; } + public string Name { get; set; } + public PersonRole Role { get; set; } + + [ConcurrencyCheck] + public uint RowVersion { get; set; } + + public void OnSavingChanges() + { + RowVersion++; + } + } +} \ No newline at end of file diff --git a/API/Entities/Series.cs b/API/Entities/Series.cs index 0ad7c8c16..4ea8f1cf4 100644 --- a/API/Entities/Series.cs +++ b/API/Entities/Series.cs @@ -32,7 +32,7 @@ namespace API.Entities /// /// Summary information related to the Series /// - public string Summary { get; set; } + public string Summary { get; set; } // TODO: Migrate into SeriesMetdata public DateTime Created { get; set; } public DateTime LastModified { get; set; } public byte[] CoverImage { get; set; } @@ -40,6 +40,8 @@ namespace API.Entities /// Sum of all Volume page counts /// public int Pages { get; set; } + + public SeriesMetadata Metadata { get; set; } // Relationships public List Volumes { get; set; } diff --git a/API/Entities/SeriesMetadata.cs b/API/Entities/SeriesMetadata.cs new file mode 100644 index 000000000..e848c696e --- /dev/null +++ b/API/Entities/SeriesMetadata.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using API.Entities.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace API.Entities +{ + [Index(nameof(Id), nameof(SeriesId), IsUnique = true)] + public class SeriesMetadata : IHasConcurrencyToken + { + public int Id { get; set; } + /// + /// Publisher of book or manga/comic + /// + //public string Publisher { get; set; } + + public ICollection CollectionTags { get; set; } + + // Relationship + public Series Series { get; set; } + public int SeriesId { get; set; } + + [ConcurrencyCheck] + public uint RowVersion { get; set; } + + public void OnSavingChanges() + { + RowVersion++; + } + } +} \ No newline at end of file diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index 328a27ade..33f2c2223 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -20,6 +20,12 @@ namespace API.Helpers CreateMap(); CreateMap(); + + CreateMap(); + + CreateMap(); + + CreateMap(); CreateMap(); diff --git a/API/Interfaces/IBookService.cs b/API/Interfaces/IBookService.cs index f0b5a8826..297bef3aa 100644 --- a/API/Interfaces/IBookService.cs +++ b/API/Interfaces/IBookService.cs @@ -10,13 +10,16 @@ namespace API.Interfaces int GetNumberOfPages(string filePath); byte[] GetCoverImage(string fileFilePath, bool createThumbnail = true); Task> CreateKeyToPageMappingAsync(EpubBookRef book); + /// /// Scopes styles to .reading-section and replaces img src to the passed apiBase /// /// /// + /// If the stylesheetHtml contains Import statements, when scoping the filename, scope needs to be wrt filepath. + /// Book Reference, needed for if you expect Import statements /// - Task ScopeStyles(string stylesheetHtml, string apiBase); + Task ScopeStyles(string stylesheetHtml, string apiBase, string filename, EpubBookRef book); string GetSummaryInfo(string filePath); ParserInfo ParseInfo(string filePath); } diff --git a/API/Interfaces/ICollectionTagRepository.cs b/API/Interfaces/ICollectionTagRepository.cs new file mode 100644 index 000000000..5d820d8c2 --- /dev/null +++ b/API/Interfaces/ICollectionTagRepository.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using API.DTOs; +using API.Entities; + +namespace API.Interfaces +{ + public interface ICollectionTagRepository + { + void Remove(CollectionTag tag); + Task> GetAllTagDtosAsync(); + Task> SearchTagDtosAsync(string searchQuery); + Task GetCoverImageAsync(int collectionTagId); + Task> GetAllPromotedTagDtosAsync(); + Task GetTagAsync(int tagId); + Task GetFullTagAsync(int tagId); + void Update(CollectionTag tag); + } +} \ No newline at end of file diff --git a/API/Interfaces/ISeriesRepository.cs b/API/Interfaces/ISeriesRepository.cs index eff8e7c08..266173ace 100644 --- a/API/Interfaces/ISeriesRepository.cs +++ b/API/Interfaces/ISeriesRepository.cs @@ -59,5 +59,7 @@ namespace API.Interfaces Task GetSeriesCoverImageAsync(int seriesId); Task> GetInProgress(int userId, int libraryId, int limit); Task> GetRecentlyAdded(int userId, int libraryId, int limit); + Task GetSeriesMetadata(int seriesId); + Task> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams); } } \ No newline at end of file diff --git a/API/Interfaces/IUnitOfWork.cs b/API/Interfaces/IUnitOfWork.cs index fb81313eb..8f4b53c8f 100644 --- a/API/Interfaces/IUnitOfWork.cs +++ b/API/Interfaces/IUnitOfWork.cs @@ -10,6 +10,7 @@ namespace API.Interfaces IVolumeRepository VolumeRepository { get; } ISettingsRepository SettingsRepository { get; } IAppUserProgressRepository AppUserProgressRepository { get; } + ICollectionTagRepository CollectionTagRepository { get; } Task Complete(); bool HasChanges(); } diff --git a/API/Parser/Parser.cs b/API/Parser/Parser.cs index 45b8940e7..83dbd8ebb 100644 --- a/API/Parser/Parser.cs +++ b/API/Parser/Parser.cs @@ -13,6 +13,7 @@ namespace API.Parser public static readonly string BookFileExtensions = @"\.epub"; public static readonly string ImageFileExtensions = @"^(\.png|\.jpeg|\.jpg)"; public static readonly Regex FontSrcUrlRegex = new Regex("(src:url\\(\"?'?)([a-z0-9/\\._]+)(\"?'?\\))", RegexOptions.IgnoreCase | RegexOptions.Compiled); + public static readonly Regex CssImportUrlRegex = new Regex("(@import\\s[\"|'])(?[\\w\\d/\\._-]+)([\"|'];?)", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly string XmlRegexExtensions = @"\.xml"; private static readonly Regex ImageRegex = new Regex(ImageFileExtensions, RegexOptions.IgnoreCase | RegexOptions.Compiled); diff --git a/API/Program.cs b/API/Program.cs index d3091c0ea..e59a9fbb0 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -59,6 +59,8 @@ namespace API await context.Database.MigrateAsync(); await Seed.SeedRoles(roleManager); await Seed.SeedSettings(context); + // TODO: Remove this in v0.4.2 + await Seed.SeedSeriesMetadata(context); } catch (Exception ex) { @@ -81,6 +83,7 @@ namespace API }); }); + webBuilder.UseSentry(options => { options.Dsn = "https://40f4e7b49c094172a6f99d61efb2740f@o641015.ingest.sentry.io/5757423"; diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index 2dfbd4798..20de5f92c 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; using API.Entities.Enums; @@ -103,8 +104,33 @@ namespace API.Services anchor.Attributes.Add("href", "javascript:void(0)"); } - public async Task ScopeStyles(string stylesheetHtml, string apiBase) + public async Task ScopeStyles(string stylesheetHtml, string apiBase, string filename, EpubBookRef book) { + // @Import statements will be handled by browser, so we must inline the css into the original file that request it, so they can be + // Scoped + var prepend = filename.Length > 0 ? filename.Replace(Path.GetFileName(filename), "") : string.Empty; + var importBuilder = new StringBuilder(); + foreach (Match match in Parser.Parser.CssImportUrlRegex.Matches(stylesheetHtml)) + { + if (!match.Success) continue; + + var importFile = match.Groups["Filename"].Value; + var key = CleanContentKeys(importFile); + if (!key.Contains(prepend)) + { + key = prepend + key; + } + if (!book.Content.AllFiles.ContainsKey(key)) continue; + + var bookFile = book.Content.AllFiles[key]; + var content = await bookFile.ReadContentAsBytesAsync(); + importBuilder.Append(Encoding.UTF8.GetString(content)); + } + + stylesheetHtml = stylesheetHtml.Insert(0, importBuilder.ToString()); + stylesheetHtml = + Parser.Parser.CssImportUrlRegex.Replace(stylesheetHtml, "$1" + apiBase + prepend + "$2" + "$3"); + var styleContent = RemoveWhiteSpaceFromStylesheets(stylesheetHtml); styleContent = Parser.Parser.FontSrcUrlRegex.Replace(styleContent, "$1" + apiBase + "$2" + "$3"); @@ -131,7 +157,7 @@ namespace API.Services { if (!IsValidFile(filePath)) return string.Empty; - var epubBook = EpubReader.OpenBook(filePath); + using var epubBook = EpubReader.OpenBook(filePath); return epubBook.Schema.Package.Metadata.Description; } @@ -155,7 +181,7 @@ namespace API.Services try { - var epubBook = EpubReader.OpenBook(filePath); + using var epubBook = EpubReader.OpenBook(filePath); return epubBook.Content.Html.Count; } catch (Exception ex) @@ -195,7 +221,7 @@ namespace API.Services { try { - var epubBook = EpubReader.OpenBook(filePath); + using var epubBook = EpubReader.OpenBook(filePath); return new ParserInfo() { @@ -217,12 +243,13 @@ namespace API.Services return null; } + public byte[] GetCoverImage(string fileFilePath, bool createThumbnail = true) { if (!IsValidFile(fileFilePath)) return Array.Empty(); - var epubBook = EpubReader.OpenBook(fileFilePath); + using var epubBook = EpubReader.OpenBook(fileFilePath); try diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index 432212f6f..12f30afad 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -239,6 +239,7 @@ namespace API.Services.Tasks _logger.LogInformation("Processing series {SeriesName}", series.OriginalName); UpdateVolumes(series, parsedSeries[Parser.Parser.Normalize(series.OriginalName)].ToArray()); series.Pages = series.Volumes.Sum(v => v.Pages); + // Test } catch (Exception ex) { diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index 1b93ba622..e562ee5e6 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -4,7 +4,7 @@ net5.0 kareadita.github.io Kavita - 0.4.1 + 0.4.1.2 en diff --git a/README.md b/README.md index 86b8ae964..6e938811c 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,45 @@ # Kavita +
![alt text](https://github.com/Kareadita/kareadita.github.io/blob/main/img/features/seriesdetail.PNG?raw=true) -Kavita is a fast, feature rich, cross platform OSS manga server. Built with a focus for manga, +Kavita is a fast, feature rich, cross platform reading server. Built with a focus for manga, and the goal of being a full solution for all your reading needs. Setup your own server and share -your manga collection with your friends and family! +your reading collection with your friends and family! +[![Release](https://img.shields.io/github/release/Kareadita/Kavita.svg?style=flat&maxAge=3600)](https://github.com/Kareadita/Kavita/releases) +[![License](https://img.shields.io/badge/license-GPLv3-blue.svg?style=flat)](https://github.com/Kareadita/Kavita/blob/master/LICENSE) [![Discord](https://img.shields.io/badge/discord-chat-7289DA.svg?maxAge=60)](https://discord.gg/eczRp9eeem) -![Github Downloads](https://img.shields.io/github/downloads/Kareadita/Kavita/total.svg) -[![Feature Requests](https://feathub.com/Kareadita/Kavita?format=svg)](https://feathub.com/Kareadita/Kavita) - +[![Downloads](https://img.shields.io/github/downloads/Kareadita/Kavita/total.svg?style=flat)](https://github.com/Kareadita/Kavita/releases) +[![Docker Pulls](https://img.shields.io/docker/pulls/kizaing/kavita.svg)](https://hub.docker.com/r/kizaing/kavita/) +[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=Kareadita_Kavita&metric=alert_status)](https://sonarcloud.io/dashboard?id=Kareadita_Kavita) +[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=Kareadita_Kavita&metric=sqale_rating)](https://sonarcloud.io/dashboard?id=Kareadita_Kavita) +[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=Kareadita_Kavita&metric=security_rating)](https://sonarcloud.io/dashboard?id=Kareadita_Kavita) +[![Donate via Paypal](https://img.shields.io/badge/donate-paypal-blue.svg?style=popout&logo=paypal)](https://paypal.me/majora2007?locale.x=en_US) +
## Goals: * Serve up Manga/Webtoons/Comics (cbr, cbz, zip/rar, raw images) and Books (epub, mobi, azw, djvu, pdf) -* Provide Readers via web app that is responsive +* First class responsive readers that work great on any device * Provide a dark theme for web app -* Provide hooks into metadata providers to fetch Manga data +* Provide hooks into metadata providers to fetch metadata for Comics, Manga, and Books * Metadata should allow for collections, want to read integration from 3rd party services, genres. * Ability to manage users, access, and ratings * Ability to sync ratings and reviews to external services +* And so much [more...](https://github.com/Kareadita/Kavita/projects) -## How to Build + +# How to contribute - Ensure you've cloned Kavita-webui. You should have Projects/Kavita and Projects/Kavita-webui - In Kavita-webui, run ng serve. This will start the webserver on localhost:4200 - Run API project in Kavita, this will start the backend on localhost:5000 -## How to Deploy +## Deploy local build - Run build.sh and pass the Runtime Identifier for your OS or just build.sh for all supported RIDs. ## How to install - Unzip the archive for your target OS - Place in a directory that is writable. If on windows, do not place in Program Files -- Open appsettings.json and modify TokenKey to a random string ideally generated from [https://passwordsgenerator.net/](https://passwordsgenerator.net/) - Run Kavita - If you are updating, do not copy appsettings.json from the new version over. It will override your TokenKey and you will have to reauthenticate on your devices. @@ -63,10 +71,14 @@ services: Note: Kavita is under heavy development and is being updated all the time, so the tag for current builds is :nightly. The :latest tag will be the latest stable release. There is also the :alpine tag if you want a smaller image, but it is only available for x64 systems. -## Want to help? -I am looking for developers with a passion for building the next Plex for Manga, Comics, and Ebooks. I need developers with C#/ASP.NET, Angular 11 or CSS experience. -Reach out to me on [Discord]((https://discord.gg/eczRp9eeem)). +## Got an Idea? +Got a great idea? Throw it up on the FeatHub or vote on another persons. Please check the [Project Board](https://github.com/Kareadita/Kavita/projects) first for a list of planned features. +[![Feature Requests](https://feathub.com/Kareadita/Kavita?format=svg)](https://feathub.com/Kareadita/Kavita) -## Buy me a beer -I've gone through many beers building Kavita and expect to go through many more. If you want to throw me a few bucks you can [here](https://paypal.me/majora2007?locale.x=en_US). Money will go -towards beer or hosting for the upcoming Metadata release. +## Want to help? +I am looking for developers with a passion for building the next Plex for Reading. Developers with C#/ASP.NET, Angular 11 please reach out on [Discord](https://discord.gg/eczRp9eeem). + +## Donate +If you like Kavita, have gotten good use out of it or feel like you want to say thanks with a few bucks, feel free to donate. Money will +likely go towards beer or hosting. +[![Donate via Paypal](https://img.shields.io/badge/donate-paypal-blue.svg?style=popout&logo=paypal)](https://paypal.me/majora2007?locale.x=en_US)