diff --git a/API.Tests/API.Tests.csproj b/API.Tests/API.Tests.csproj
index d486d9877..73a19fd5d 100644
--- a/API.Tests/API.Tests.csproj
+++ b/API.Tests/API.Tests.csproj
@@ -7,15 +7,15 @@
-
-
+
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
-
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
diff --git a/API/API.csproj b/API/API.csproj
index 504cf7271..d824795e5 100644
--- a/API/API.csproj
+++ b/API/API.csproj
@@ -35,35 +35,35 @@
-
-
+
+
-
+
-
-
-
+
+
+
-
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
-
+
+
+
-
-
+
+
-
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
+
-
+
@@ -77,22 +77,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/API/Controllers/DownloadController.cs b/API/Controllers/DownloadController.cs
index 3e89c98ef..cc4fd0214 100644
--- a/API/Controllers/DownloadController.cs
+++ b/API/Controllers/DownloadController.cs
@@ -3,7 +3,11 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
+using API.Comparators;
+using API.DTOs;
+using API.DTOs.Downloads;
using API.Entities;
+using API.Entities.Enums;
using API.Extensions;
using API.Interfaces;
using API.Interfaces.Services;
@@ -21,12 +25,16 @@ namespace API.Controllers
private readonly IUnitOfWork _unitOfWork;
private readonly IArchiveService _archiveService;
private readonly IDirectoryService _directoryService;
+ private readonly ICacheService _cacheService;
+ private readonly NumericComparer _numericComparer;
- public DownloadController(IUnitOfWork unitOfWork, IArchiveService archiveService, IDirectoryService directoryService)
+ public DownloadController(IUnitOfWork unitOfWork, IArchiveService archiveService, IDirectoryService directoryService, ICacheService cacheService)
{
_unitOfWork = unitOfWork;
_archiveService = archiveService;
_directoryService = directoryService;
+ _cacheService = cacheService;
+ _numericComparer = new NumericComparer();
}
[HttpGet("volume-size")]
@@ -39,7 +47,7 @@ namespace API.Controllers
[HttpGet("chapter-size")]
public async Task> GetChapterSize(int chapterId)
{
- var files = await _unitOfWork.VolumeRepository.GetFilesForChapter(chapterId);
+ var files = await _unitOfWork.VolumeRepository.GetFilesForChapterAsync(chapterId);
return Ok(DirectoryService.GetTotalSize(files.Select(c => c.FilePath)));
}
@@ -96,7 +104,7 @@ namespace API.Controllers
[HttpGet("chapter")]
public async Task DownloadChapter(int chapterId)
{
- var files = await _unitOfWork.VolumeRepository.GetFilesForChapter(chapterId);
+ var files = await _unitOfWork.VolumeRepository.GetFilesForChapterAsync(chapterId);
try
{
if (files.Count == 1)
@@ -132,5 +140,62 @@ namespace API.Controllers
return BadRequest(ex.Message);
}
}
+
+ [HttpPost("bookmarks")]
+ public async Task DownloadBookmarkPages(DownloadBookmarkDto downloadBookmarkDto)
+ {
+ // We know that all bookmarks will be for one single seriesId
+ var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(downloadBookmarkDto.Bookmarks.First().SeriesId);
+ var totalFilePaths = new List();
+
+ var tempFolder = $"download_{series.Id}_bookmarks";
+ var fullExtractPath = Path.Join(DirectoryService.TempDirectory, tempFolder);
+ if (new DirectoryInfo(fullExtractPath).Exists)
+ {
+ return BadRequest(
+ "Server is currently processing this exact download. Please try again in a few minutes.");
+ }
+ DirectoryService.ExistOrCreate(fullExtractPath);
+
+ var uniqueChapterIds = downloadBookmarkDto.Bookmarks.Select(b => b.ChapterId).Distinct().ToList();
+
+ foreach (var chapterId in uniqueChapterIds)
+ {
+ var chapterExtractPath = Path.Join(fullExtractPath, $"{series.Id}_bookmark_{chapterId}");
+ var chapterPages = downloadBookmarkDto.Bookmarks.Where(b => b.ChapterId == chapterId)
+ .Select(b => b.Page).ToList();
+ var mangaFiles = await _unitOfWork.VolumeRepository.GetFilesForChapterAsync(chapterId);
+ switch (series.Format)
+ {
+ case MangaFormat.Image:
+ DirectoryService.ExistOrCreate(chapterExtractPath);
+ _directoryService.CopyFilesToDirectory(mangaFiles.Select(f => f.FilePath), chapterExtractPath, $"{chapterId}_");
+ break;
+ case MangaFormat.Archive:
+ case MangaFormat.Pdf:
+ _cacheService.ExtractChapterFiles(chapterExtractPath, mangaFiles.ToList());
+ var originalFiles = _directoryService.GetFilesWithExtension(chapterExtractPath,
+ Parser.Parser.ImageFileExtensions);
+ _directoryService.CopyFilesToDirectory(originalFiles, chapterExtractPath, $"{chapterId}_");
+ DirectoryService.DeleteFiles(originalFiles);
+ break;
+ case MangaFormat.Epub:
+ return BadRequest("Series is not in a valid format.");
+ default:
+ return BadRequest("Series is not in a valid format. Please rescan series and try again.");
+ }
+
+ var files = _directoryService.GetFilesWithExtension(chapterExtractPath, Parser.Parser.ImageFileExtensions);
+ // Filter out images that aren't in bookmarks
+ Array.Sort(files, _numericComparer);
+ totalFilePaths.AddRange(files.Where((t, i) => chapterPages.Contains(i)));
+ }
+
+
+ var (fileBytes, zipPath) = await _archiveService.CreateZipForDownload(totalFilePaths,
+ tempFolder);
+ DirectoryService.ClearAndDeleteDirectory(fullExtractPath);
+ return File(fileBytes, "application/zip", $"{series.Name} - Bookmarks.zip");
+ }
}
}
diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs
index 4d47e3f06..dda5543c3 100644
--- a/API/Controllers/LibraryController.cs
+++ b/API/Controllers/LibraryController.cs
@@ -203,8 +203,7 @@ namespace API.Controllers
{
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryForUserDto.Id);
- var originalFolders = library.Folders.Select(x => x.Path);
- var differenceBetweenFolders = originalFolders.Except(libraryForUserDto.Folders);
+ var originalFolders = library.Folders.Select(x => x.Path).ToList();
library.Name = libraryForUserDto.Name;
library.Folders = libraryForUserDto.Folders.Select(s => new FolderPath() {Path = s}).ToList();
@@ -212,9 +211,9 @@ namespace API.Controllers
_unitOfWork.LibraryRepository.Update(library);
if (!await _unitOfWork.CommitAsync()) return BadRequest("There was a critical issue updating the library.");
- if (differenceBetweenFolders.Any())
+ if (originalFolders.Count != libraryForUserDto.Folders.Count())
{
- _taskScheduler.ScanLibrary(library.Id, true);
+ _taskScheduler.ScanLibrary(library.Id);
}
return Ok();
diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs
index 5a10676d1..b1d42b6cb 100644
--- a/API/Controllers/ReaderController.cs
+++ b/API/Controllers/ReaderController.cs
@@ -50,15 +50,16 @@ namespace API.Controllers
}
[HttpGet("chapter-info")]
- public async Task> GetChapterInfo(int chapterId)
+ public async Task> GetChapterInfo(int seriesId, int chapterId)
{
// PERF: Write this in one DB call
var chapter = await _cacheService.Ensure(chapterId);
if (chapter == null) return BadRequest("Could not find Chapter");
- var volume = await _unitOfWork.SeriesRepository.GetVolumeAsync(chapter.VolumeId);
+
+ var volume = await _unitOfWork.SeriesRepository.GetVolumeDtoAsync(chapter.VolumeId);
if (volume == null) return BadRequest("Could not find Volume");
- var (_, mangaFile) = await _cacheService.GetCachedPagePath(chapter, 0);
- var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId);
+ var mangaFile = (await _unitOfWork.VolumeRepository.GetFilesForChapterAsync(chapterId)).First();
+ var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId);
return Ok(new ChapterInfoDto()
{
@@ -72,29 +73,6 @@ namespace API.Controllers
});
}
- [HttpGet("get-bookmark")]
- public async Task> GetBookmark(int chapterId)
- {
- var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
- var bookmark = new BookmarkDto()
- {
- PageNum = 0,
- ChapterId = chapterId,
- VolumeId = 0,
- SeriesId = 0
- };
- if (user.Progresses == null) return Ok(bookmark);
- var progress = user.Progresses.SingleOrDefault(x => x.AppUserId == user.Id && x.ChapterId == chapterId);
-
- if (progress != null)
- {
- bookmark.SeriesId = progress.SeriesId;
- bookmark.VolumeId = progress.VolumeId;
- bookmark.PageNum = progress.PagesRead;
- bookmark.BookScrollId = progress.BookScrollId;
- }
- return Ok(bookmark);
- }
[HttpPost("mark-read")]
public async Task MarkRead(MarkReadDto markReadDto)
@@ -232,21 +210,45 @@ namespace API.Controllers
return BadRequest("Could not save progress");
}
- [HttpPost("bookmark")]
- public async Task Bookmark(BookmarkDto bookmarkDto)
+ [HttpGet("get-progress")]
+ public async Task> GetProgress(int chapterId)
+ {
+ var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
+ var progressBookmark = new ProgressDto()
+ {
+ PageNum = 0,
+ ChapterId = chapterId,
+ VolumeId = 0,
+ SeriesId = 0
+ };
+ if (user.Progresses == null) return Ok(progressBookmark);
+ var progress = user.Progresses.SingleOrDefault(x => x.AppUserId == user.Id && x.ChapterId == chapterId);
+
+ if (progress != null)
+ {
+ progressBookmark.SeriesId = progress.SeriesId;
+ progressBookmark.VolumeId = progress.VolumeId;
+ progressBookmark.PageNum = progress.PagesRead;
+ progressBookmark.BookScrollId = progress.BookScrollId;
+ }
+ return Ok(progressBookmark);
+ }
+
+ [HttpPost("progress")]
+ public async Task BookmarkProgress(ProgressDto progressDto)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
- // Don't let user bookmark past total pages.
- var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(bookmarkDto.ChapterId);
- if (bookmarkDto.PageNum > chapter.Pages)
+ // Don't let user save past total pages.
+ var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(progressDto.ChapterId);
+ if (progressDto.PageNum > chapter.Pages)
{
- bookmarkDto.PageNum = chapter.Pages;
+ progressDto.PageNum = chapter.Pages;
}
- if (bookmarkDto.PageNum < 0)
+ if (progressDto.PageNum < 0)
{
- bookmarkDto.PageNum = 0;
+ progressDto.PageNum = 0;
}
@@ -255,26 +257,26 @@ namespace API.Controllers
// TODO: Look into creating a progress entry when a new item is added to the DB so we can just look it up and modify it
user.Progresses ??= new List();
var userProgress =
- user.Progresses.SingleOrDefault(x => x.ChapterId == bookmarkDto.ChapterId && x.AppUserId == user.Id);
+ user.Progresses.SingleOrDefault(x => x.ChapterId == progressDto.ChapterId && x.AppUserId == user.Id);
if (userProgress == null)
{
user.Progresses.Add(new AppUserProgress
{
- PagesRead = bookmarkDto.PageNum,
- VolumeId = bookmarkDto.VolumeId,
- SeriesId = bookmarkDto.SeriesId,
- ChapterId = bookmarkDto.ChapterId,
- BookScrollId = bookmarkDto.BookScrollId,
+ PagesRead = progressDto.PageNum,
+ VolumeId = progressDto.VolumeId,
+ SeriesId = progressDto.SeriesId,
+ ChapterId = progressDto.ChapterId,
+ BookScrollId = progressDto.BookScrollId,
LastModified = DateTime.Now
});
}
else
{
- userProgress.PagesRead = bookmarkDto.PageNum;
- userProgress.SeriesId = bookmarkDto.SeriesId;
- userProgress.VolumeId = bookmarkDto.VolumeId;
- userProgress.BookScrollId = bookmarkDto.BookScrollId;
+ userProgress.PagesRead = progressDto.PageNum;
+ userProgress.SeriesId = progressDto.SeriesId;
+ userProgress.VolumeId = progressDto.VolumeId;
+ userProgress.BookScrollId = progressDto.BookScrollId;
userProgress.LastModified = DateTime.Now;
}
@@ -293,6 +295,139 @@ namespace API.Controllers
return BadRequest("Could not save progress");
}
+ [HttpGet("get-bookmarks")]
+ public async Task>> GetBookmarks(int chapterId)
+ {
+ var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
+ if (user.Bookmarks == null) return Ok(Array.Empty());
+ return Ok(await _unitOfWork.UserRepository.GetBookmarkDtosForChapter(user.Id, chapterId));
+ }
+
+ [HttpPost("remove-bookmarks")]
+ public async Task RemoveBookmarks(int seriesId)
+ {
+ var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
+ if (user.Bookmarks == null) return Ok("Nothing to remove");
+ try
+ {
+ user.Bookmarks = user.Bookmarks.Where(bmk => bmk.SeriesId == seriesId).ToList();
+ _unitOfWork.UserRepository.Update(user);
+
+ if (await _unitOfWork.CommitAsync())
+ {
+ return Ok();
+ }
+ }
+ catch (Exception)
+ {
+ await _unitOfWork.RollbackAsync();
+ }
+
+ return BadRequest("Could not clear bookmarks");
+
+ }
+
+ [HttpGet("get-volume-bookmarks")]
+ public async Task>> GetBookmarksForVolume(int volumeId)
+ {
+ var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
+ if (user.Bookmarks == null) return Ok(Array.Empty());
+ return Ok(await _unitOfWork.UserRepository.GetBookmarkDtosForVolume(user.Id, volumeId));
+ }
+
+ [HttpGet("get-series-bookmarks")]
+ public async Task>> GetBookmarksForSeries(int seriesId)
+ {
+ var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
+ if (user.Bookmarks == null) return Ok(Array.Empty());
+
+ return Ok(await _unitOfWork.UserRepository.GetBookmarkDtosForSeries(user.Id, seriesId));
+ }
+
+ [HttpPost("bookmark")]
+ public async Task BookmarkPage(BookmarkDto bookmarkDto)
+ {
+ var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
+
+ // Don't let user save past total pages.
+ var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(bookmarkDto.ChapterId);
+ if (bookmarkDto.Page > chapter.Pages)
+ {
+ bookmarkDto.Page = chapter.Pages;
+ }
+
+ if (bookmarkDto.Page < 0)
+ {
+ bookmarkDto.Page = 0;
+ }
+
+
+ try
+ {
+ user.Bookmarks ??= new List();
+ var userBookmark =
+ user.Bookmarks.SingleOrDefault(x => x.ChapterId == bookmarkDto.ChapterId && x.AppUserId == user.Id && x.Page == bookmarkDto.Page);
+
+ if (userBookmark == null)
+ {
+ user.Bookmarks.Add(new AppUserBookmark()
+ {
+ Page = bookmarkDto.Page,
+ VolumeId = bookmarkDto.VolumeId,
+ SeriesId = bookmarkDto.SeriesId,
+ ChapterId = bookmarkDto.ChapterId,
+ });
+ }
+ else
+ {
+ userBookmark.Page = bookmarkDto.Page;
+ userBookmark.SeriesId = bookmarkDto.SeriesId;
+ userBookmark.VolumeId = bookmarkDto.VolumeId;
+ }
+
+ _unitOfWork.UserRepository.Update(user);
+
+ if (await _unitOfWork.CommitAsync())
+ {
+ return Ok();
+ }
+ }
+ catch (Exception)
+ {
+ await _unitOfWork.RollbackAsync();
+ }
+
+ return BadRequest("Could not save bookmark");
+ }
+
+ [HttpPost("unbookmark")]
+ public async Task UnBookmarkPage(BookmarkDto bookmarkDto)
+ {
+ var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
+
+ if (user.Bookmarks == null) return Ok();
+ try {
+ user.Bookmarks = user.Bookmarks.Where(x =>
+ x.ChapterId == bookmarkDto.ChapterId
+ && x.AppUserId == user.Id
+ && x.Page != bookmarkDto.Page).ToList();
+
+
+ _unitOfWork.UserRepository.Update(user);
+
+ if (await _unitOfWork.CommitAsync())
+ {
+ return Ok();
+ }
+ }
+ catch (Exception)
+ {
+ await _unitOfWork.RollbackAsync();
+ }
+
+ return BadRequest("Could not remove bookmark");
+ }
+
///
/// Returns the next logical chapter from the series.
///
diff --git a/API/DTOs/BookmarkDto.cs b/API/DTOs/BookmarkDto.cs
index c06f6d30a..c45a183c3 100644
--- a/API/DTOs/BookmarkDto.cs
+++ b/API/DTOs/BookmarkDto.cs
@@ -2,14 +2,10 @@
{
public class BookmarkDto
{
+ public int Id { get; set; }
+ public int Page { get; set; }
public int VolumeId { get; set; }
- public int ChapterId { get; set; }
- public int PageNum { get; set; }
public int SeriesId { get; set; }
- ///
- /// For Book reader, this can be an optional string of the id of a part marker, to help resume reading position
- /// on pages that combine multiple "chapters".
- ///
- public string BookScrollId { get; set; }
+ public int ChapterId { get; set; }
}
-}
\ No newline at end of file
+}
diff --git a/API/DTOs/Downloads/DownloadBookmarkDto.cs b/API/DTOs/Downloads/DownloadBookmarkDto.cs
new file mode 100644
index 000000000..5239b4aae
--- /dev/null
+++ b/API/DTOs/Downloads/DownloadBookmarkDto.cs
@@ -0,0 +1,9 @@
+using System.Collections.Generic;
+
+namespace API.DTOs.Downloads
+{
+ public class DownloadBookmarkDto
+ {
+ public IEnumerable Bookmarks { get; set; }
+ }
+}
diff --git a/API/DTOs/ProgressDto.cs b/API/DTOs/ProgressDto.cs
new file mode 100644
index 000000000..4810a40a9
--- /dev/null
+++ b/API/DTOs/ProgressDto.cs
@@ -0,0 +1,15 @@
+namespace API.DTOs
+{
+ public class ProgressDto
+ {
+ public int VolumeId { get; set; }
+ public int ChapterId { get; set; }
+ public int PageNum { get; set; }
+ public int SeriesId { get; set; }
+ ///
+ /// For Book reader, this can be an optional string of the id of a part marker, to help resume reading position
+ /// on pages that combine multiple "chapters".
+ ///
+ public string BookScrollId { get; set; }
+ }
+}
diff --git a/API/Data/BookmarkRepository.cs b/API/Data/BookmarkRepository.cs
new file mode 100644
index 000000000..af212bc72
--- /dev/null
+++ b/API/Data/BookmarkRepository.cs
@@ -0,0 +1,7 @@
+namespace API.Data
+{
+ public class BookmarkRepository
+ {
+
+ }
+}
diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs
index 008d96ed2..98e0cb8cd 100644
--- a/API/Data/DataContext.cs
+++ b/API/Data/DataContext.cs
@@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore.ChangeTracking;
namespace API.Data
{
- public sealed class DataContext : IdentityDbContext, AppUserRole, IdentityUserLogin,
IdentityRoleClaim, IdentityUserToken>
{
@@ -17,10 +17,10 @@ namespace API.Data
ChangeTracker.Tracked += OnEntityTracked;
ChangeTracker.StateChanged += OnEntityStateChanged;
}
-
+
public DbSet Library { get; set; }
public DbSet Series { get; set; }
-
+
public DbSet Chapter { get; set; }
public DbSet Volume { get; set; }
public DbSet AppUser { get; set; }
@@ -31,18 +31,19 @@ namespace API.Data
public DbSet AppUserPreferences { get; set; }
public DbSet SeriesMetadata { get; set; }
public DbSet CollectionTag { get; set; }
+ public DbSet AppUserBookmark { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
-
+
builder.Entity()
.HasMany(ur => ur.UserRoles)
.WithOne(u => u.User)
.HasForeignKey(ur => ur.UserId)
.IsRequired();
-
+
builder.Entity()
.HasMany(ur => ur.UserRoles)
.WithOne(u => u.Role)
@@ -50,7 +51,7 @@ namespace API.Data
.IsRequired();
}
-
+
void OnEntityTracked(object sender, EntityTrackedEventArgs e)
{
if (!e.FromQuery && e.Entry.State == EntityState.Added && e.Entry.Entity is IEntityDate entity)
@@ -58,7 +59,7 @@ namespace API.Data
entity.Created = DateTime.Now;
entity.LastModified = DateTime.Now;
}
-
+
}
void OnEntityStateChanged(object sender, EntityStateChangedEventArgs e)
@@ -67,4 +68,4 @@ namespace API.Data
entity.LastModified = DateTime.Now;
}
}
-}
\ No newline at end of file
+}
diff --git a/API/Data/Migrations/20210809210326_BookmarkPages.Designer.cs b/API/Data/Migrations/20210809210326_BookmarkPages.Designer.cs
new file mode 100644
index 000000000..b339bbd99
--- /dev/null
+++ b/API/Data/Migrations/20210809210326_BookmarkPages.Designer.cs
@@ -0,0 +1,913 @@
+//
+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("20210809210326_BookmarkPages")]
+ partial class BookmarkPages
+ {
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "5.0.8");
+
+ 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.AppUserBookmark", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ChapterId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Page")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.Property("VolumeId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.ToTable("AppUserBookmark");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("AutoCloseMenu")
+ .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("BookReaderReadingDirection")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderTapToPaginate")
+ .HasColumnType("INTEGER");
+
+ b.Property("PageSplitOption")
+ .HasColumnType("INTEGER");
+
+ b.Property("ReaderMode")
+ .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("BookScrollId")
+ .HasColumnType("TEXT");
+
+ 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("Format")
+ .HasColumnType("INTEGER");
+
+ 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", "Format")
+ .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.AppUserBookmark", b =>
+ {
+ b.HasOne("API.Entities.AppUser", "AppUser")
+ .WithMany("Bookmarks")
+ .HasForeignKey("AppUserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("AppUser");
+ });
+
+ 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("Bookmarks");
+
+ 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/20210809210326_BookmarkPages.cs b/API/Data/Migrations/20210809210326_BookmarkPages.cs
new file mode 100644
index 000000000..0ae48eeed
--- /dev/null
+++ b/API/Data/Migrations/20210809210326_BookmarkPages.cs
@@ -0,0 +1,44 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+namespace API.Data.Migrations
+{
+ public partial class BookmarkPages : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "AppUserBookmark",
+ columns: table => new
+ {
+ Id = table.Column(type: "INTEGER", nullable: false)
+ .Annotation("Sqlite:Autoincrement", true),
+ Page = table.Column(type: "INTEGER", nullable: false),
+ VolumeId = table.Column(type: "INTEGER", nullable: false),
+ SeriesId = table.Column(type: "INTEGER", nullable: false),
+ ChapterId = table.Column(type: "INTEGER", nullable: false),
+ AppUserId = table.Column(type: "INTEGER", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_AppUserBookmark", x => x.Id);
+ table.ForeignKey(
+ name: "FK_AppUserBookmark_AspNetUsers_AppUserId",
+ column: x => x.AppUserId,
+ principalTable: "AspNetUsers",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_AppUserBookmark_AppUserId",
+ table: "AppUserBookmark",
+ column: "AppUserId");
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "AppUserBookmark");
+ }
+ }
+}
diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs
index ebf940768..c6d708f16 100644
--- a/API/Data/Migrations/DataContextModelSnapshot.cs
+++ b/API/Data/Migrations/DataContextModelSnapshot.cs
@@ -14,7 +14,7 @@ namespace API.Data.Migrations
{
#pragma warning disable 612, 618
modelBuilder
- .HasAnnotation("ProductVersion", "5.0.4");
+ .HasAnnotation("ProductVersion", "5.0.8");
modelBuilder.Entity("API.Entities.AppRole", b =>
{
@@ -118,6 +118,34 @@ namespace API.Data.Migrations
b.ToTable("AspNetUsers");
});
+ modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ChapterId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Page")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.Property("VolumeId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.ToTable("AppUserBookmark");
+ });
+
modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
{
b.Property("Id")
@@ -641,6 +669,17 @@ namespace API.Data.Migrations
b.ToTable("AspNetUserTokens");
});
+ modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
+ {
+ b.HasOne("API.Entities.AppUser", "AppUser")
+ .WithMany("Bookmarks")
+ .HasForeignKey("AppUserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("AppUser");
+ });
+
modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
@@ -832,6 +871,8 @@ namespace API.Data.Migrations
modelBuilder.Entity("API.Entities.AppUser", b =>
{
+ b.Navigation("Bookmarks");
+
b.Navigation("Progresses");
b.Navigation("Ratings");
diff --git a/API/Data/UnitOfWork.cs b/API/Data/UnitOfWork.cs
index 394e6fed1..564043577 100644
--- a/API/Data/UnitOfWork.cs
+++ b/API/Data/UnitOfWork.cs
@@ -20,7 +20,7 @@ namespace API.Data
}
public ISeriesRepository SeriesRepository => new SeriesRepository(_context, _mapper);
- public IUserRepository UserRepository => new UserRepository(_context, _userManager);
+ public IUserRepository UserRepository => new UserRepository(_context, _userManager, _mapper);
public ILibraryRepository LibraryRepository => new LibraryRepository(_context, _mapper);
public IVolumeRepository VolumeRepository => new VolumeRepository(_context, _mapper);
@@ -56,4 +56,4 @@ namespace API.Data
return true;
}
}
-}
\ No newline at end of file
+}
diff --git a/API/Data/UserRepository.cs b/API/Data/UserRepository.cs
index 4f86ca5b5..d330b7954 100644
--- a/API/Data/UserRepository.cs
+++ b/API/Data/UserRepository.cs
@@ -5,6 +5,8 @@ using API.Constants;
using API.DTOs;
using API.Entities;
using API.Interfaces;
+using AutoMapper;
+using AutoMapper.QueryableExtensions;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
@@ -14,18 +16,20 @@ namespace API.Data
{
private readonly DataContext _context;
private readonly UserManager _userManager;
+ private readonly IMapper _mapper;
- public UserRepository(DataContext context, UserManager userManager)
+ public UserRepository(DataContext context, UserManager userManager, IMapper mapper)
{
_context = context;
_userManager = userManager;
+ _mapper = mapper;
}
public void Update(AppUser user)
{
_context.Entry(user).State = EntityState.Modified;
}
-
+
public void Update(AppUserPreferences preferences)
{
_context.Entry(preferences).State = EntityState.Modified;
@@ -45,6 +49,7 @@ namespace API.Data
{
return await _context.Users
.Include(u => u.Progresses)
+ .Include(u => u.Bookmarks)
.SingleOrDefaultAsync(x => x.UserName == username);
}
@@ -71,6 +76,36 @@ namespace API.Data
.SingleOrDefaultAsync(p => p.AppUser.UserName == username);
}
+ public async Task> GetBookmarkDtosForSeries(int userId, int seriesId)
+ {
+ return await _context.AppUserBookmark
+ .Where(x => x.AppUserId == userId && x.SeriesId == seriesId)
+ .OrderBy(x => x.Page)
+ .AsNoTracking()
+ .ProjectTo(_mapper.ConfigurationProvider)
+ .ToListAsync();
+ }
+
+ public async Task> GetBookmarkDtosForVolume(int userId, int volumeId)
+ {
+ return await _context.AppUserBookmark
+ .Where(x => x.AppUserId == userId && x.VolumeId == volumeId)
+ .OrderBy(x => x.Page)
+ .AsNoTracking()
+ .ProjectTo(_mapper.ConfigurationProvider)
+ .ToListAsync();
+ }
+
+ public async Task> GetBookmarkDtosForChapter(int userId, int chapterId)
+ {
+ return await _context.AppUserBookmark
+ .Where(x => x.AppUserId == userId && x.ChapterId == chapterId)
+ .OrderBy(x => x.Page)
+ .AsNoTracking()
+ .ProjectTo(_mapper.ConfigurationProvider)
+ .ToListAsync();
+ }
+
public async Task> GetMembersAsync()
{
return await _context.Users
@@ -97,4 +132,4 @@ namespace API.Data
.ToListAsync();
}
}
-}
\ No newline at end of file
+}
diff --git a/API/Data/VolumeRepository.cs b/API/Data/VolumeRepository.cs
index 3bcd092a9..bcab3d113 100644
--- a/API/Data/VolumeRepository.cs
+++ b/API/Data/VolumeRepository.cs
@@ -1,7 +1,9 @@
using System.Collections.Generic;
+using System.IO;
using System.Linq;
using System.Threading.Tasks;
using API.DTOs;
+using API.DTOs.Reader;
using API.Entities;
using API.Interfaces;
using AutoMapper;
@@ -38,7 +40,6 @@ namespace API.Data
.SingleOrDefaultAsync(c => c.Id == chapterId);
}
-
///
/// Returns Chapters for a volume id.
///
@@ -79,13 +80,30 @@ namespace API.Data
return chapter;
}
- public async Task> GetFilesForChapter(int chapterId)
+ ///
+ /// Returns non-tracked files for a given chapterId
+ ///
+ ///
+ ///
+ public async Task> GetFilesForChapterAsync(int chapterId)
{
return await _context.MangaFile
.Where(c => chapterId == c.ChapterId)
.AsNoTracking()
.ToListAsync();
}
+ ///
+ /// Returns non-tracked files for a set of chapterIds
+ ///
+ ///
+ ///
+ public async Task> GetFilesForChaptersAsync(IReadOnlyList chapterIds)
+ {
+ return await _context.MangaFile
+ .Where(c => chapterIds.Contains(c.ChapterId))
+ .AsNoTracking()
+ .ToListAsync();
+ }
public async Task> GetFilesForVolume(int volumeId)
{
diff --git a/API/Entities/AppUser.cs b/API/Entities/AppUser.cs
index 49df4d7af..b75c871f2 100644
--- a/API/Entities/AppUser.cs
+++ b/API/Entities/AppUser.cs
@@ -16,7 +16,8 @@ namespace API.Entities
public ICollection Progresses { get; set; }
public ICollection Ratings { get; set; }
public AppUserPreferences UserPreferences { get; set; }
-
+ public ICollection Bookmarks { get; set; }
+
[ConcurrencyCheck]
public uint RowVersion { get; set; }
@@ -26,4 +27,4 @@ namespace API.Entities
}
}
-}
\ No newline at end of file
+}
diff --git a/API/Entities/AppUserBookmark.cs b/API/Entities/AppUserBookmark.cs
new file mode 100644
index 000000000..cfb9aa29a
--- /dev/null
+++ b/API/Entities/AppUserBookmark.cs
@@ -0,0 +1,22 @@
+using System.Text.Json.Serialization;
+
+namespace API.Entities
+{
+ ///
+ /// Represents a saved page in a Chapter entity for a given user.
+ ///
+ public class AppUserBookmark
+ {
+ public int Id { get; set; }
+ public int Page { get; set; }
+ public int VolumeId { get; set; }
+ public int SeriesId { get; set; }
+ public int ChapterId { get; set; }
+
+
+ // Relationships
+ [JsonIgnore]
+ public AppUser AppUser { get; set; }
+ public int AppUserId { get; set; }
+ }
+}
diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs
index 33f2c2223..084a7d28c 100644
--- a/API/Helpers/AutoMapperProfiles.cs
+++ b/API/Helpers/AutoMapperProfiles.cs
@@ -16,11 +16,11 @@ namespace API.Helpers
CreateMap();
CreateMap();
-
+
CreateMap();
CreateMap();
-
+
CreateMap();
CreateMap();
@@ -29,18 +29,20 @@ namespace API.Helpers
CreateMap();
+ CreateMap();
+
CreateMap()
.ForMember(dest => dest.SeriesId,
opt => opt.MapFrom(src => src.Id))
.ForMember(dest => dest.LibraryName,
opt => opt.MapFrom(src => src.Library.Name));
-
-
+
+
CreateMap()
.ForMember(dest => dest.Folders,
- opt =>
+ opt =>
opt.MapFrom(src => src.Folders.Select(x => x.Path).ToList()));
-
+
CreateMap()
.AfterMap((ps, pst, context) => context.Mapper.Map(ps.Libraries, pst.Libraries));
@@ -50,4 +52,4 @@ namespace API.Helpers
.ConvertUsing();
}
}
-}
\ No newline at end of file
+}
diff --git a/API/Interfaces/IUserRepository.cs b/API/Interfaces/IUserRepository.cs
index 6821da667..eb2062646 100644
--- a/API/Interfaces/IUserRepository.cs
+++ b/API/Interfaces/IUserRepository.cs
@@ -16,5 +16,8 @@ namespace API.Interfaces
Task GetUserRating(int seriesId, int userId);
void AddRatingTracking(AppUserRating userRating);
Task GetPreferencesAsync(string username);
+ Task> GetBookmarkDtosForSeries(int userId, int seriesId);
+ Task> GetBookmarkDtosForVolume(int userId, int volumeId);
+ Task> GetBookmarkDtosForChapter(int userId, int chapterId);
}
-}
\ No newline at end of file
+}
diff --git a/API/Interfaces/IVolumeRepository.cs b/API/Interfaces/IVolumeRepository.cs
index b5ac06087..0cd703ee9 100644
--- a/API/Interfaces/IVolumeRepository.cs
+++ b/API/Interfaces/IVolumeRepository.cs
@@ -10,9 +10,10 @@ namespace API.Interfaces
void Update(Volume volume);
Task GetChapterAsync(int chapterId);
Task GetChapterDtoAsync(int chapterId);
- Task> GetFilesForChapter(int chapterId);
+ Task> GetFilesForChapterAsync(int chapterId);
+ Task> GetFilesForChaptersAsync(IReadOnlyList chapterIds);
Task> GetChaptersAsync(int volumeId);
Task GetChapterCoverImageAsync(int chapterId);
Task> GetFilesForVolume(int volumeId);
}
-}
\ No newline at end of file
+}
diff --git a/API/Interfaces/Services/ICacheService.cs b/API/Interfaces/Services/ICacheService.cs
index 8499702b1..395898dc2 100644
--- a/API/Interfaces/Services/ICacheService.cs
+++ b/API/Interfaces/Services/ICacheService.cs
@@ -36,5 +36,6 @@ namespace API.Interfaces.Services
void EnsureCacheDirectory();
string GetCachedEpubFile(int chapterId, Chapter chapter);
+ public void ExtractChapterFiles(string extractPath, IReadOnlyList files);
}
}
diff --git a/API/Interfaces/Services/IDirectoryService.cs b/API/Interfaces/Services/IDirectoryService.cs
index 4e6979bd5..43779774c 100644
--- a/API/Interfaces/Services/IDirectoryService.cs
+++ b/API/Interfaces/Services/IDirectoryService.cs
@@ -20,7 +20,7 @@ namespace API.Interfaces.Services
///
string[] GetFilesWithExtension(string path, string searchPatternExpression = "");
Task ReadFileAsync(string path);
- bool CopyFilesToDirectory(IEnumerable filePaths, string directoryPath);
+ bool CopyFilesToDirectory(IEnumerable filePaths, string directoryPath, string prepend = "");
bool Exists(string directory);
IEnumerable GetFiles(string path, string searchPatternExpression = "",
diff --git a/API/Services/ArchiveService.cs b/API/Services/ArchiveService.cs
index 36eded36b..e0a6fa596 100644
--- a/API/Services/ArchiveService.cs
+++ b/API/Services/ArchiveService.cs
@@ -224,17 +224,16 @@ namespace API.Services
public async Task> CreateZipForDownload(IEnumerable files, string tempFolder)
{
- var tempDirectory = Path.Join(Directory.GetCurrentDirectory(), "temp");
var dateString = DateTime.Now.ToShortDateString().Replace("/", "_");
- var tempLocation = Path.Join(tempDirectory, $"{tempFolder}_{dateString}");
+ var tempLocation = Path.Join(DirectoryService.TempDirectory, $"{tempFolder}_{dateString}");
DirectoryService.ExistOrCreate(tempLocation);
if (!_directoryService.CopyFilesToDirectory(files, tempLocation))
{
throw new KavitaException("Unable to copy files to temp directory archive download.");
}
- var zipPath = Path.Join(tempDirectory, $"kavita_{tempFolder}_{dateString}.zip");
+ var zipPath = Path.Join(DirectoryService.TempDirectory, $"kavita_{tempFolder}_{dateString}.zip");
try
{
ZipFile.CreateFromDirectory(tempLocation, zipPath);
diff --git a/API/Services/CacheService.cs b/API/Services/CacheService.cs
index 89d1cf395..05b8bd21b 100644
--- a/API/Services/CacheService.cs
+++ b/API/Services/CacheService.cs
@@ -37,7 +37,7 @@ namespace API.Services
public void EnsureCacheDirectory()
{
- if (!DirectoryService.ExistOrCreate(CacheDirectory))
+ if (!DirectoryService.ExistOrCreate(DirectoryService.CacheDirectory))
{
_logger.LogError("Cache directory {CacheDirectory} is not accessible or does not exist. Creating...", CacheDirectory);
}
@@ -60,58 +60,77 @@ namespace API.Services
return path;
}
+ ///
+ /// Caches the files for the given chapter to CacheDirectory
+ ///
+ ///
+ /// This will always return the Chapter for the chpaterId
public async Task Ensure(int chapterId)
{
EnsureCacheDirectory();
var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(chapterId);
- var files = chapter.Files.ToList();
- var fileCount = files.Count;
var extractPath = GetCachePath(chapterId);
- var extraPath = "";
- var removeNonImages = true;
- if (Directory.Exists(extractPath))
+ if (!Directory.Exists(extractPath))
{
- return chapter;
+ var files = chapter.Files.ToList();
+ ExtractChapterFiles(extractPath, files);
}
+ return chapter;
+ }
+
+ ///
+ /// This is an internal method for cache service for extracting chapter files to disk. The code is structured
+ /// for cache service, but can be re-used (download bookmarks)
+ ///
+ ///
+ ///
+ ///
+ public void ExtractChapterFiles(string extractPath, IReadOnlyList files)
+ {
+ var removeNonImages = true;
+ var fileCount = files.Count;
+ var extraPath = "";
var extractDi = new DirectoryInfo(extractPath);
if (files.Count > 0 && files[0].Format == MangaFormat.Image)
{
- DirectoryService.ExistOrCreate(extractPath);
- if (files.Count == 1)
- {
- _directoryService.CopyFileToDirectory(files[0].FilePath, extractPath);
- }
- else
- {
- _directoryService.CopyDirectoryToDirectory(Path.GetDirectoryName(files[0].FilePath), extractPath, Parser.Parser.ImageFileExtensions);
- }
+ DirectoryService.ExistOrCreate(extractPath);
+ if (files.Count == 1)
+ {
+ _directoryService.CopyFileToDirectory(files[0].FilePath, extractPath);
+ }
+ else
+ {
+ _directoryService.CopyDirectoryToDirectory(Path.GetDirectoryName(files[0].FilePath), extractPath,
+ Parser.Parser.ImageFileExtensions);
+ }
- extractDi.Flatten();
- return chapter;
+ extractDi.Flatten();
}
foreach (var file in files)
{
- if (fileCount > 1)
- {
- extraPath = file.Id + string.Empty;
- }
+ if (fileCount > 1)
+ {
+ extraPath = file.Id + string.Empty;
+ }
- if (file.Format == MangaFormat.Archive)
- {
- _archiveService.ExtractArchive(file.FilePath, Path.Join(extractPath, extraPath));
- } else if (file.Format == MangaFormat.Pdf)
- {
- _bookService.ExtractPdfImages(file.FilePath, Path.Join(extractPath, extraPath));
- } else if (file.Format == MangaFormat.Epub)
- {
- removeNonImages = false;
- DirectoryService.ExistOrCreate(extractPath);
- _directoryService.CopyFileToDirectory(files[0].FilePath, extractPath);
- }
+ if (file.Format == MangaFormat.Archive)
+ {
+ _archiveService.ExtractArchive(file.FilePath, Path.Join(extractPath, extraPath));
+ }
+ else if (file.Format == MangaFormat.Pdf)
+ {
+ _bookService.ExtractPdfImages(file.FilePath, Path.Join(extractPath, extraPath));
+ }
+ else if (file.Format == MangaFormat.Epub)
+ {
+ removeNonImages = false;
+ DirectoryService.ExistOrCreate(extractPath);
+ _directoryService.CopyFileToDirectory(files[0].FilePath, extractPath);
+ }
}
extractDi.Flatten();
@@ -119,9 +138,6 @@ namespace API.Services
{
extractDi.RemoveNonImages();
}
-
-
- return chapter;
}
@@ -173,7 +189,7 @@ namespace API.Services
{
// Calculate what chapter the page belongs to
var pagesSoFar = 0;
- var chapterFiles = chapter.Files ?? await _unitOfWork.VolumeRepository.GetFilesForChapter(chapter.Id);
+ var chapterFiles = chapter.Files ?? await _unitOfWork.VolumeRepository.GetFilesForChapterAsync(chapter.Id);
foreach (var mangaFile in chapterFiles)
{
if (page <= (mangaFile.Pages + pagesSoFar))
diff --git a/API/Services/DirectoryService.cs b/API/Services/DirectoryService.cs
index 80156bd2a..0451088e9 100644
--- a/API/Services/DirectoryService.cs
+++ b/API/Services/DirectoryService.cs
@@ -16,6 +16,9 @@ namespace API.Services
private static readonly Regex ExcludeDirectories = new Regex(
@"@eaDir|\.DS_Store",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
+ public static readonly string TempDirectory = Path.Join(Directory.GetCurrentDirectory(), "temp");
+ public static readonly string LogDirectory = Path.Join(Directory.GetCurrentDirectory(), "logs");
+ public static readonly string CacheDirectory = Path.Join(Directory.GetCurrentDirectory(), "cache");
public DirectoryService(ILogger logger)
{
@@ -247,33 +250,40 @@ namespace API.Services
}
}
- public bool CopyFilesToDirectory(IEnumerable filePaths, string directoryPath)
+ ///