Bookmarking Pages within the Reader (#469)

# Added
- Added: Added the ability to bookmark certain pages within the manga (image) reader and later download them from the series context menu. 

# Fixed
- Fixed: Fixed an issue where after adding a new folder to an existing library, a scan wouldn't be kicked off
- Fixed: In some cases, after clicking the background of a modal, the modal would close, but state wouldn't be handled as if cancel was pushed

# Changed
- Changed: Admin contextual actions on cards will now be separated by a line to help differentiate. 
- Changed: Performance enhancement on an API used before reading

# Dev
- Bumped dependencies to latest versions

=============================================
* Bumped versions of dependencies and refactored bookmark to progress.

* Refactored method names in UI from bookmark to progress to prepare for new bookmark entity

* Basic code is done, user can now bookmark a page (currently image reader only).

* Comments and pipes

* Some accessibility for new bookmark button

* Fixed up the APIs to work correctly, added a new modal to quickly explore bookmarks (not implemented, not final).

* Cleanup on the UI side to get the modal to look decent

* Added dismissed handlers for modals where appropriate

* Refactored UI to only show number of bookmarks across files to simplify delivery. Admin actionables are now separated by hr vs non-admin actions.

* Basic API implemented, now to implement the ability to actually extract files.

* Implemented the ability to download bookmarks.

* Fixed a bug where adding a new folder to an existing library would not trigger a scan library task.

* Fixed an issue that could cause bookmarked pages to get copied out of order.

* Added handler from series-card component
This commit is contained in:
Joseph Milazzo 2021-08-10 18:18:07 -05:00 committed by GitHub
parent d1d7df9291
commit e9ec6671d5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
49 changed files with 1860 additions and 241 deletions

View file

@ -35,35 +35,35 @@
<PackageReference Include="ExCSS" Version="4.1.0" />
<PackageReference Include="Flurl" Version="3.0.2" />
<PackageReference Include="Flurl.Http" Version="3.2.0" />
<PackageReference Include="Hangfire" Version="1.7.20" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.7.20" />
<PackageReference Include="Hangfire" Version="1.7.24" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.7.24" />
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
<PackageReference Include="Hangfire.MemoryStorage.Core" Version="1.4.0" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.32" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.35" />
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="5.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="5.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="5.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="5.0.8" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="5.0.8" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="5.0.8" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.4">
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.8">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.4" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.1" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="2.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.8" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.2" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="2.1.3" />
<PackageReference Include="NetVips" Version="2.0.1" />
<PackageReference Include="NetVips.Native" Version="8.11.0" />
<PackageReference Include="NReco.Logging.File" Version="1.1.1" />
<PackageReference Include="Sentry.AspNetCore" Version="3.8.2" />
<PackageReference Include="NReco.Logging.File" Version="1.1.2" />
<PackageReference Include="Sentry.AspNetCore" Version="3.8.3" />
<PackageReference Include="SharpCompress" Version="0.28.3" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="8.26.0.34506">
<PackageReference Include="SonarAnalyzer.CSharp" Version="8.27.0.35380">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.1.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.1.5" />
<PackageReference Include="System.Drawing.Common" Version="5.0.2" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.10.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.12.0" />
<PackageReference Include="VersOne.Epub" Version="3.0.3.1" />
</ItemGroup>
@ -77,22 +77,38 @@
<None Remove="Hangfire-log.db" />
<None Remove="obj\**" />
<None Remove="wwwroot\**" />
<None Remove="cache\**" />
<None Remove="backups\**" />
<None Remove="logs\**" />
<None Remove="temp\**" />
</ItemGroup>
<ItemGroup>
<Compile Remove="Interfaces\IMetadataService.cs" />
<Compile Remove="obj\**" />
<Compile Remove="wwwroot\**" />
<Compile Remove="cache\**" />
<Compile Remove="backups\**" />
<Compile Remove="logs\**" />
<Compile Remove="temp\**" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Remove="obj\**" />
<EmbeddedResource Remove="wwwroot\**" />
<EmbeddedResource Remove="cache\**" />
<EmbeddedResource Remove="backups\**" />
<EmbeddedResource Remove="logs\**" />
<EmbeddedResource Remove="temp\**" />
</ItemGroup>
<ItemGroup>
<Content Remove="obj\**" />
<Content Remove="wwwroot\**" />
<Content Remove="cache\**" />
<Content Remove="backups\**" />
<Content Remove="logs\**" />
<Content Remove="temp\**" />
</ItemGroup>
<ItemGroup>

View file

@ -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<ActionResult<long>> 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<ActionResult> 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<ActionResult> 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<string>();
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");
}
}
}

View file

@ -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();

View file

@ -50,15 +50,16 @@ namespace API.Controllers
}
[HttpGet("chapter-info")]
public async Task<ActionResult<ChapterInfoDto>> GetChapterInfo(int chapterId)
public async Task<ActionResult<ChapterInfoDto>> 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<ActionResult<BookmarkDto>> 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<ActionResult> MarkRead(MarkReadDto markReadDto)
@ -232,21 +210,45 @@ namespace API.Controllers
return BadRequest("Could not save progress");
}
[HttpPost("bookmark")]
public async Task<ActionResult> Bookmark(BookmarkDto bookmarkDto)
[HttpGet("get-progress")]
public async Task<ActionResult<ProgressDto>> 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<ActionResult> 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<AppUserProgress>();
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<ActionResult<IEnumerable<BookmarkDto>>> GetBookmarks(int chapterId)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (user.Bookmarks == null) return Ok(Array.Empty<BookmarkDto>());
return Ok(await _unitOfWork.UserRepository.GetBookmarkDtosForChapter(user.Id, chapterId));
}
[HttpPost("remove-bookmarks")]
public async Task<ActionResult> 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<ActionResult<IEnumerable<BookmarkDto>>> GetBookmarksForVolume(int volumeId)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (user.Bookmarks == null) return Ok(Array.Empty<BookmarkDto>());
return Ok(await _unitOfWork.UserRepository.GetBookmarkDtosForVolume(user.Id, volumeId));
}
[HttpGet("get-series-bookmarks")]
public async Task<ActionResult<IEnumerable<BookmarkDto>>> GetBookmarksForSeries(int seriesId)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (user.Bookmarks == null) return Ok(Array.Empty<BookmarkDto>());
return Ok(await _unitOfWork.UserRepository.GetBookmarkDtosForSeries(user.Id, seriesId));
}
[HttpPost("bookmark")]
public async Task<ActionResult> BookmarkPage(BookmarkDto bookmarkDto)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
// Don't let user save past total pages.
var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(bookmarkDto.ChapterId);
if (bookmarkDto.Page > chapter.Pages)
{
bookmarkDto.Page = chapter.Pages;
}
if (bookmarkDto.Page < 0)
{
bookmarkDto.Page = 0;
}
try
{
user.Bookmarks ??= new List<AppUserBookmark>();
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<ActionResult> 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");
}
/// <summary>
/// Returns the next logical chapter from the series.
/// </summary>

View file

@ -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; }
/// <summary>
/// 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".
/// </summary>
public string BookScrollId { get; set; }
public int ChapterId { get; set; }
}
}
}

View file

@ -0,0 +1,9 @@
using System.Collections.Generic;
namespace API.DTOs.Downloads
{
public class DownloadBookmarkDto
{
public IEnumerable<BookmarkDto> Bookmarks { get; set; }
}
}

15
API/DTOs/ProgressDto.cs Normal file
View file

@ -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; }
/// <summary>
/// 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".
/// </summary>
public string BookScrollId { get; set; }
}
}

View file

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

View file

@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore.ChangeTracking;
namespace API.Data
{
public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
IdentityUserClaim<int>, AppUserRole, IdentityUserLogin<int>,
IdentityRoleClaim<int>, IdentityUserToken<int>>
{
@ -17,10 +17,10 @@ namespace API.Data
ChangeTracker.Tracked += OnEntityTracked;
ChangeTracker.StateChanged += OnEntityStateChanged;
}
public DbSet<Library> Library { get; set; }
public DbSet<Series> Series { get; set; }
public DbSet<Chapter> Chapter { get; set; }
public DbSet<Volume> Volume { get; set; }
public DbSet<AppUser> AppUser { get; set; }
@ -31,18 +31,19 @@ namespace API.Data
public DbSet<AppUserPreferences> AppUserPreferences { get; set; }
public DbSet<SeriesMetadata> SeriesMetadata { get; set; }
public DbSet<CollectionTag> CollectionTag { get; set; }
public DbSet<AppUserBookmark> AppUserBookmark { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.Entity<AppUser>()
.HasMany(ur => ur.UserRoles)
.WithOne(u => u.User)
.HasForeignKey(ur => ur.UserId)
.IsRequired();
builder.Entity<AppRole>()
.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;
}
}
}
}

View file

@ -0,0 +1,913 @@
// <auto-generated />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastActive")
.HasColumnType("TEXT");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<int>("Page")
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int>("VolumeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.ToTable("AppUserBookmark");
});
modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<bool>("AutoCloseMenu")
.HasColumnType("INTEGER");
b.Property<bool>("BookReaderDarkMode")
.HasColumnType("INTEGER");
b.Property<string>("BookReaderFontFamily")
.HasColumnType("TEXT");
b.Property<int>("BookReaderFontSize")
.HasColumnType("INTEGER");
b.Property<int>("BookReaderLineSpacing")
.HasColumnType("INTEGER");
b.Property<int>("BookReaderMargin")
.HasColumnType("INTEGER");
b.Property<int>("BookReaderReadingDirection")
.HasColumnType("INTEGER");
b.Property<bool>("BookReaderTapToPaginate")
.HasColumnType("INTEGER");
b.Property<int>("PageSplitOption")
.HasColumnType("INTEGER");
b.Property<int>("ReaderMode")
.HasColumnType("INTEGER");
b.Property<int>("ReadingDirection")
.HasColumnType("INTEGER");
b.Property<int>("ScalingOption")
.HasColumnType("INTEGER");
b.Property<bool>("SiteDarkMode")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId")
.IsUnique();
b.ToTable("AppUserPreferences");
});
modelBuilder.Entity("API.Entities.AppUserProgress", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<string>("BookScrollId")
.HasColumnType("TEXT");
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<int>("PagesRead")
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int>("VolumeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.ToTable("AppUserProgresses");
});
modelBuilder.Entity("API.Entities.AppUserRating", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<int>("Rating")
.HasColumnType("INTEGER");
b.Property<string>("Review")
.HasColumnType("TEXT");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.ToTable("AppUserRating");
});
modelBuilder.Entity("API.Entities.AppUserRole", b =>
{
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.Property<int>("RoleId")
.HasColumnType("INTEGER");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles");
});
modelBuilder.Entity("API.Entities.Chapter", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<bool>("IsSpecial")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<string>("Number")
.HasColumnType("TEXT");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.Property<string>("Range")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.Property<int>("VolumeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("VolumeId");
b.ToTable("Chapter");
});
modelBuilder.Entity("API.Entities.CollectionTag", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<string>("NormalizedTitle")
.HasColumnType("TEXT");
b.Property<bool>("Promoted")
.HasColumnType("INTEGER");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<string>("Summary")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Id", "Promoted")
.IsUnique();
b.ToTable("CollectionTag");
});
modelBuilder.Entity("API.Entities.FolderPath", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("LastScanned")
.HasColumnType("TEXT");
b.Property<int>("LibraryId")
.HasColumnType("INTEGER");
b.Property<string>("Path")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("LibraryId");
b.ToTable("FolderPath");
});
modelBuilder.Entity("API.Entities.Library", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CoverImage")
.HasColumnType("TEXT");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Library");
});
modelBuilder.Entity("API.Entities.MangaFile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<string>("FilePath")
.HasColumnType("TEXT");
b.Property<int>("Format")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ChapterId");
b.ToTable("MangaFile");
});
modelBuilder.Entity("API.Entities.Series", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<int>("Format")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<int>("LibraryId")
.HasColumnType("INTEGER");
b.Property<string>("LocalizedName")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasColumnType("TEXT");
b.Property<string>("OriginalName")
.HasColumnType("TEXT");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.Property<string>("SortName")
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<int>("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<int>("Key")
.HasColumnType("INTEGER");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("ServerSetting");
});
modelBuilder.Entity("API.Entities.Volume", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Number")
.HasColumnType("INTEGER");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("SeriesId");
b.ToTable("Volume");
});
modelBuilder.Entity("AppUserLibrary", b =>
{
b.Property<int>("AppUsersId")
.HasColumnType("INTEGER");
b.Property<int>("LibrariesId")
.HasColumnType("INTEGER");
b.HasKey("AppUsersId", "LibrariesId");
b.HasIndex("LibrariesId");
b.ToTable("AppUserLibrary");
});
modelBuilder.Entity("CollectionTagSeriesMetadata", b =>
{
b.Property<int>("CollectionTagsId")
.HasColumnType("INTEGER");
b.Property<int>("SeriesMetadatasId")
.HasColumnType("INTEGER");
b.HasKey("CollectionTagsId", "SeriesMetadatasId");
b.HasIndex("SeriesMetadatasId");
b.ToTable("CollectionTagSeriesMetadata");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<int>("RoleId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<int>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<int>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<int>", b =>
{
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("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<int>", b =>
{
b.HasOne("API.Entities.AppRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<int>", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<int>", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<int>", 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
}
}
}

View file

@ -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<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Page = table.Column<int>(type: "INTEGER", nullable: false),
VolumeId = table.Column<int>(type: "INTEGER", nullable: false),
SeriesId = table.Column<int>(type: "INTEGER", nullable: false),
ChapterId = table.Column<int>(type: "INTEGER", nullable: false),
AppUserId = table.Column<int>(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");
}
}
}

View file

@ -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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<int>("Page")
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int>("VolumeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.ToTable("AppUserBookmark");
});
modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
{
b.Property<int>("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");

View file

@ -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;
}
}
}
}

View file

@ -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<AppUser> _userManager;
private readonly IMapper _mapper;
public UserRepository(DataContext context, UserManager<AppUser> userManager)
public UserRepository(DataContext context, UserManager<AppUser> 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<IEnumerable<BookmarkDto>> GetBookmarkDtosForSeries(int userId, int seriesId)
{
return await _context.AppUserBookmark
.Where(x => x.AppUserId == userId && x.SeriesId == seriesId)
.OrderBy(x => x.Page)
.AsNoTracking()
.ProjectTo<BookmarkDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
public async Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForVolume(int userId, int volumeId)
{
return await _context.AppUserBookmark
.Where(x => x.AppUserId == userId && x.VolumeId == volumeId)
.OrderBy(x => x.Page)
.AsNoTracking()
.ProjectTo<BookmarkDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
public async Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForChapter(int userId, int chapterId)
{
return await _context.AppUserBookmark
.Where(x => x.AppUserId == userId && x.ChapterId == chapterId)
.OrderBy(x => x.Page)
.AsNoTracking()
.ProjectTo<BookmarkDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
public async Task<IEnumerable<MemberDto>> GetMembersAsync()
{
return await _context.Users
@ -97,4 +132,4 @@ namespace API.Data
.ToListAsync();
}
}
}
}

View file

@ -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);
}
/// <summary>
/// Returns Chapters for a volume id.
/// </summary>
@ -79,13 +80,30 @@ namespace API.Data
return chapter;
}
public async Task<IList<MangaFile>> GetFilesForChapter(int chapterId)
/// <summary>
/// Returns non-tracked files for a given chapterId
/// </summary>
/// <param name="chapterId"></param>
/// <returns></returns>
public async Task<IList<MangaFile>> GetFilesForChapterAsync(int chapterId)
{
return await _context.MangaFile
.Where(c => chapterId == c.ChapterId)
.AsNoTracking()
.ToListAsync();
}
/// <summary>
/// Returns non-tracked files for a set of chapterIds
/// </summary>
/// <param name="chapterIds"></param>
/// <returns></returns>
public async Task<IList<MangaFile>> GetFilesForChaptersAsync(IReadOnlyList<int> chapterIds)
{
return await _context.MangaFile
.Where(c => chapterIds.Contains(c.ChapterId))
.AsNoTracking()
.ToListAsync();
}
public async Task<IList<MangaFile>> GetFilesForVolume(int volumeId)
{

View file

@ -16,7 +16,8 @@ namespace API.Entities
public ICollection<AppUserProgress> Progresses { get; set; }
public ICollection<AppUserRating> Ratings { get; set; }
public AppUserPreferences UserPreferences { get; set; }
public ICollection<AppUserBookmark> Bookmarks { get; set; }
[ConcurrencyCheck]
public uint RowVersion { get; set; }
@ -26,4 +27,4 @@ namespace API.Entities
}
}
}
}

View file

@ -0,0 +1,22 @@
using System.Text.Json.Serialization;
namespace API.Entities
{
/// <summary>
/// Represents a saved page in a Chapter entity for a given user.
/// </summary>
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; }
}
}

View file

@ -16,11 +16,11 @@ namespace API.Helpers
CreateMap<Volume, VolumeDto>();
CreateMap<MangaFile, MangaFileDto>();
CreateMap<Chapter, ChapterDto>();
CreateMap<Series, SeriesDto>();
CreateMap<CollectionTag, CollectionTagDto>();
CreateMap<SeriesMetadata, SeriesMetadataDto>();
@ -29,18 +29,20 @@ namespace API.Helpers
CreateMap<AppUserPreferences, UserPreferencesDto>();
CreateMap<AppUserBookmark, BookmarkDto>();
CreateMap<Series, SearchResultDto>()
.ForMember(dest => dest.SeriesId,
opt => opt.MapFrom(src => src.Id))
.ForMember(dest => dest.LibraryName,
opt => opt.MapFrom(src => src.Library.Name));
CreateMap<Library, LibraryDto>()
.ForMember(dest => dest.Folders,
opt =>
opt =>
opt.MapFrom(src => src.Folders.Select(x => x.Path).ToList()));
CreateMap<AppUser, MemberDto>()
.AfterMap((ps, pst, context) => context.Mapper.Map(ps.Libraries, pst.Libraries));
@ -50,4 +52,4 @@ namespace API.Helpers
.ConvertUsing<ServerSettingConverter>();
}
}
}
}

View file

@ -16,5 +16,8 @@ namespace API.Interfaces
Task<AppUserRating> GetUserRating(int seriesId, int userId);
void AddRatingTracking(AppUserRating userRating);
Task<AppUserPreferences> GetPreferencesAsync(string username);
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForSeries(int userId, int seriesId);
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForVolume(int userId, int volumeId);
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForChapter(int userId, int chapterId);
}
}
}

View file

@ -10,9 +10,10 @@ namespace API.Interfaces
void Update(Volume volume);
Task<Chapter> GetChapterAsync(int chapterId);
Task<ChapterDto> GetChapterDtoAsync(int chapterId);
Task<IList<MangaFile>> GetFilesForChapter(int chapterId);
Task<IList<MangaFile>> GetFilesForChapterAsync(int chapterId);
Task<IList<MangaFile>> GetFilesForChaptersAsync(IReadOnlyList<int> chapterIds);
Task<IList<Chapter>> GetChaptersAsync(int volumeId);
Task<byte[]> GetChapterCoverImageAsync(int chapterId);
Task<IList<MangaFile>> GetFilesForVolume(int volumeId);
}
}
}

View file

@ -36,5 +36,6 @@ namespace API.Interfaces.Services
void EnsureCacheDirectory();
string GetCachedEpubFile(int chapterId, Chapter chapter);
public void ExtractChapterFiles(string extractPath, IReadOnlyList<MangaFile> files);
}
}

View file

@ -20,7 +20,7 @@ namespace API.Interfaces.Services
/// <returns></returns>
string[] GetFilesWithExtension(string path, string searchPatternExpression = "");
Task<byte[]> ReadFileAsync(string path);
bool CopyFilesToDirectory(IEnumerable<string> filePaths, string directoryPath);
bool CopyFilesToDirectory(IEnumerable<string> filePaths, string directoryPath, string prepend = "");
bool Exists(string directory);
IEnumerable<string> GetFiles(string path, string searchPatternExpression = "",

View file

@ -224,17 +224,16 @@ namespace API.Services
public async Task<Tuple<byte[], string>> CreateZipForDownload(IEnumerable<string> 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);

View file

@ -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;
}
/// <summary>
/// Caches the files for the given chapter to CacheDirectory
/// </summary>
/// <param name="chapterId"></param>
/// <returns>This will always return the Chapter for the chpaterId</returns>
public async Task<Chapter> 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;
}
/// <summary>
/// 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)
/// </summary>
/// <param name="extractPath"></param>
/// <param name="files"></param>
/// <returns></returns>
public void ExtractChapterFiles(string extractPath, IReadOnlyList<MangaFile> 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))

View file

@ -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<DirectoryService> logger)
{
@ -247,33 +250,40 @@ namespace API.Services
}
}
public bool CopyFilesToDirectory(IEnumerable<string> filePaths, string directoryPath)
/// <summary>
/// Copies files to a destination directory. If the destination directory doesn't exist, this will create it.
/// </summary>
/// <param name="filePaths"></param>
/// <param name="directoryPath"></param>
/// <param name="prepend">An optional string to prepend to the target file's name</param>
/// <returns></returns>
public bool CopyFilesToDirectory(IEnumerable<string> filePaths, string directoryPath, string prepend = "")
{
string currentFile = null;
try
{
foreach (var file in filePaths)
{
currentFile = file;
var fileInfo = new FileInfo(file);
if (fileInfo.Exists)
{
fileInfo.CopyTo(Path.Join(directoryPath, fileInfo.Name));
}
else
{
_logger.LogWarning("Tried to copy {File} but it doesn't exist", file);
}
ExistOrCreate(directoryPath);
string currentFile = null;
try
{
foreach (var file in filePaths)
{
currentFile = file;
var fileInfo = new FileInfo(file);
if (fileInfo.Exists)
{
fileInfo.CopyTo(Path.Join(directoryPath, prepend + fileInfo.Name));
}
else
{
_logger.LogWarning("Tried to copy {File} but it doesn't exist", file);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Unable to copy {File} to {DirectoryPath}", currentFile, directoryPath);
return false;
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Unable to copy {File} to {DirectoryPath}", currentFile, directoryPath);
return false;
}
return true;
return true;
}
public IEnumerable<string> ListDirectory(string rootPath)
@ -404,5 +414,23 @@ namespace API.Services
return fileCount;
}
/// <summary>
/// Attempts to delete the files passed to it. Swallows exceptions.
/// </summary>
/// <param name="files">Full path of files to delete</param>
public static void DeleteFiles(IEnumerable<string> files)
{
foreach (var file in files)
{
try
{
new FileInfo(file).Delete();
}
catch (Exception)
{
/* Swallow exception */
}
}
}
}
}