From 3ed99afd32eb3a97de244bb374df5c28b5997b4d Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Tue, 17 Aug 2021 14:15:32 -0700 Subject: [PATCH] Misc Bugfixes and Enhancements (#507) * Removed some extra spam for the console * Implemented the code to update RowVersion, which is our concurrency check * Fixed a critical issue where more than one bookmark could occur for a given chapter due to a race condition. Now we use concurrency checks and we also gracefully allow more than one row, by only grabbing first. * Cleaned up the logic for IHasConcurencyToken and updated the setters to be private. * Lots of comments and when deleting a library, remove any user progress items for which chapters don't exist. * When deleting a Series, cleanup user progress rows. * Now after a scan of library, if a series is removed, collection tags are pruned as well if there are no longer any series bound to it. * Updated the image on the Readme to show a better picture * Small code cleanup to remove null check modifier as I check for null just before then * Fixed images loading multiple times due to using function in binding with random. You can now click chapter images to read that chapter specifically. * Fixed cards being different sizes when read vs unread * Moved over Robbie's workflow changes from notifier. Commented out activity indicators as that is not shipping with this release. * Remove code that isn't needed * Reverted GA * Changed GA to trigger only when HEAD is updated --- .github/workflows/sonar-scan.yml | 16 +- API.Tests/Parser/MangaParserTests.cs | 2 + API/Archive/ArchiveLibrary.cs | 11 +- API/Archive/CoverAndPages.cs | 7 - API/Constants/PolicyConstants.cs | 11 +- API/Controllers/AccountController.cs | 56 +- API/Controllers/LibraryController.cs | 2 + API/Controllers/ReaderController.cs | 72 +- API/Controllers/SeriesController.cs | 3 + API/Data/CollectionTagRepository.cs | 24 +- API/Data/DataContext.cs | 46 + ...152226_ProgressConcurencyCheck.Designer.cs | 926 ++++++++++++++++++ .../20210817152226_ProgressConcurencyCheck.cs | 24 + .../Migrations/DataContextModelSnapshot.cs | 4 + API/Data/UnitOfWork.cs | 21 + API/Entities/AppUser.cs | 4 +- API/Entities/AppUserProgress.cs | 49 +- API/Entities/CollectionTag.cs | 4 +- API/Entities/Genre.cs | 4 +- API/Entities/Person.cs | 6 +- API/Entities/SeriesMetadata.cs | 4 +- API/Entities/ServerSetting.cs | 7 +- API/Interfaces/ICollectionTagRepository.cs | 3 +- API/Services/MetadataService.cs | 2 +- API/Services/TaskScheduler.cs | 1 + API/Services/Tasks/CleanupService.cs | 6 +- API/Services/Tasks/ScannerService.cs | 18 +- Kavita.Common/Configuration.cs | 3 +- README.md | 3 +- UI/Web/src/app/_models/chapter.ts | 5 +- .../app/_services/action-factory.service.ts | 13 +- .../manage-users/manage-users.component.html | 2 +- .../card-details-modal.component.html | 15 +- .../card-details-modal.component.ts | 74 +- .../edit-series-modal.component.ts | 4 - .../cards/card-item/card-item.component.html | 4 +- .../cards/card-item/card-item.component.scss | 2 +- .../series-detail/series-detail.component.ts | 1 + 38 files changed, 1378 insertions(+), 81 deletions(-) delete mode 100644 API/Archive/CoverAndPages.cs create mode 100644 API/Data/Migrations/20210817152226_ProgressConcurencyCheck.Designer.cs create mode 100644 API/Data/Migrations/20210817152226_ProgressConcurencyCheck.cs diff --git a/.github/workflows/sonar-scan.yml b/.github/workflows/sonar-scan.yml index eb688ef39..e66510d8b 100644 --- a/.github/workflows/sonar-scan.yml +++ b/.github/workflows/sonar-scan.yml @@ -5,7 +5,7 @@ on: branches: '**' pull_request: branches: [ main, develop ] - types: [opened, synchronize, reopened] + types: [synchronize] jobs: build: @@ -98,7 +98,7 @@ jobs: needs: [ build, test ] runs-on: ubuntu-latest if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }} - steps: + steps: - uses: actions/checkout@v2 with: fetch-depth: 0 @@ -122,7 +122,7 @@ jobs: develop: name: Build Nightly Docker if Develop push - needs: [ build, test, version ] + needs: [ build, test, version ] runs-on: ubuntu-latest if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }} steps: @@ -222,7 +222,7 @@ jobs: stable: name: Build Stable Docker if Main push - needs: [ build, test ] + needs: [ build, test ] runs-on: ubuntu-latest if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} steps: @@ -263,9 +263,9 @@ jobs: echo 'Copying back to Kavita wwwroot' rsync -a dist/ ../../API/wwwroot/ - + cd ../ || exit - + - name: Get csproj Version uses: naminodarie/get-net-sdk-project-versions-action@v1 id: get-version @@ -281,7 +281,7 @@ jobs: newVersion=${version%.*} echo $newVersion echo "::set-output name=VERSION::$newVersion" - id: parse-version + id: parse-version - name: Compile dotnet app uses: actions/setup-dotnet@v1 @@ -320,7 +320,7 @@ jobs: - name: Image digest run: echo ${{ steps.docker_build.outputs.digest }} - + - name: Notify Discord uses: rjstone/discord-webhook-notify@v1 with: diff --git a/API.Tests/Parser/MangaParserTests.cs b/API.Tests/Parser/MangaParserTests.cs index b03fda33a..d2785edc7 100644 --- a/API.Tests/Parser/MangaParserTests.cs +++ b/API.Tests/Parser/MangaParserTests.cs @@ -275,6 +275,7 @@ namespace API.Tests.Parser Assert.Equal(expected, API.Parser.Parser.ParseMangaSpecial(inputFile)); } +/* private static ParserInfo CreateParserInfo(string series, string chapter, string volume, bool isSpecial = false) { return new ParserInfo() @@ -285,6 +286,7 @@ namespace API.Tests.Parser Series = series, }; } +*/ [Theory] [InlineData("/manga/Btooom!/Vol.1/Chapter 1/1.cbz", "Btooom!~1~1")] diff --git a/API/Archive/ArchiveLibrary.cs b/API/Archive/ArchiveLibrary.cs index 2d05a7a55..2d87e24b6 100644 --- a/API/Archive/ArchiveLibrary.cs +++ b/API/Archive/ArchiveLibrary.cs @@ -5,8 +5,17 @@ /// public enum ArchiveLibrary { + /// + /// The underlying archive cannot be opened + /// NotSupported = 0, + /// + /// The underlying archive can be opened by SharpCompress + /// SharpCompress = 1, + /// + /// The underlying archive can be opened by default .NET + /// Default = 2 } -} \ No newline at end of file +} diff --git a/API/Archive/CoverAndPages.cs b/API/Archive/CoverAndPages.cs deleted file mode 100644 index d40d3009c..000000000 --- a/API/Archive/CoverAndPages.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace API.Archive -{ - public class CoverAndPages - { - - } -} \ No newline at end of file diff --git a/API/Constants/PolicyConstants.cs b/API/Constants/PolicyConstants.cs index c76d71926..389a87eee 100644 --- a/API/Constants/PolicyConstants.cs +++ b/API/Constants/PolicyConstants.cs @@ -1,12 +1,21 @@ namespace API.Constants { + /// + /// Role-based Security + /// public static class PolicyConstants { + /// + /// Admin User. Has all privileges + /// public const string AdminRole = "Admin"; + /// + /// Non-Admin User. Must be granted privileges by an Admin. + /// public const string PlebRole = "Pleb"; /// /// Used to give a user ability to download files from the server /// public const string DownloadRole = "Download"; } -} \ No newline at end of file +} diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index 876cecf84..18635ea03 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -18,6 +18,9 @@ using Microsoft.Extensions.Logging; namespace API.Controllers { + /// + /// All Account matters + /// public class AccountController : BaseApiController { private readonly UserManager _userManager; @@ -27,9 +30,10 @@ namespace API.Controllers private readonly ILogger _logger; private readonly IMapper _mapper; + /// public AccountController(UserManager userManager, - SignInManager signInManager, - ITokenService tokenService, IUnitOfWork unitOfWork, + SignInManager signInManager, + ITokenService tokenService, IUnitOfWork unitOfWork, ILogger logger, IMapper mapper) { @@ -40,7 +44,12 @@ namespace API.Controllers _logger = logger; _mapper = mapper; } - + + /// + /// Update a user's password + /// + /// + /// [HttpPost("reset-password")] public async Task UpdatePassword(ResetPasswordDto resetPasswordDto) { @@ -49,7 +58,7 @@ namespace API.Controllers if (resetPasswordDto.UserName != User.GetUsername() && !User.IsInRole(PolicyConstants.AdminRole)) return Unauthorized("You are not permitted to this operation."); - + // Validate Password foreach (var validator in _userManager.PasswordValidators) { @@ -60,26 +69,31 @@ namespace API.Controllers validationResult.Errors.Select(e => new ApiException(400, e.Code, e.Description))); } } - + var result = await _userManager.RemovePasswordAsync(user); if (!result.Succeeded) { _logger.LogError("Could not update password"); return BadRequest(result.Errors.Select(e => new ApiException(400, e.Code, e.Description))); } - - + + result = await _userManager.AddPasswordAsync(user, resetPasswordDto.Password); if (!result.Succeeded) { _logger.LogError("Could not update password"); return BadRequest(result.Errors.Select(e => new ApiException(400, e.Code, e.Description))); } - + _logger.LogInformation("{User}'s Password has been reset", resetPasswordDto.UserName); return Ok(); } + /// + /// Register a new user on the server + /// + /// + /// [HttpPost("register")] public async Task> Register(RegisterDto registerDto) { @@ -134,6 +148,11 @@ namespace API.Controllers return BadRequest("Something went wrong when registering user"); } + /// + /// Perform a login. Will send JWT Token of the logged in user back. + /// + /// + /// [HttpPost("login")] public async Task> Login(LoginDto loginDto) { @@ -147,14 +166,14 @@ namespace API.Controllers .CheckPasswordSignInAsync(user, loginDto.Password, false); if (!result.Succeeded) return Unauthorized("Your credentials are not correct."); - + // Update LastActive on account user.LastActive = DateTime.Now; user.UserPreferences ??= new AppUserPreferences(); - + _unitOfWork.UserRepository.Update(user); await _unitOfWork.CommitAsync(); - + _logger.LogInformation("{UserName} logged in at {Time}", user.UserName, user.LastActive); return new UserDto @@ -165,6 +184,10 @@ namespace API.Controllers }; } + /// + /// Get All Roles back. See + /// + /// [HttpGet("roles")] public ActionResult> GetRoles() { @@ -175,6 +198,11 @@ namespace API.Controllers f => (string) f.GetValue(null)).Values.ToList(); } + /// + /// Sets the given roles to the user. + /// + /// + /// [HttpPost("update-rbs")] public async Task UpdateRoles(UpdateRbsDto updateRbsDto) { @@ -190,7 +218,7 @@ namespace API.Controllers var existingRoles = (await _userManager.GetRolesAsync(user)) .Where(s => s != PolicyConstants.AdminRole && s != PolicyConstants.PlebRole) .ToList(); - + // Find what needs to be added and what needs to be removed var rolesToRemove = existingRoles.Except(updateRbsDto.Roles); var result = await _userManager.AddToRolesAsync(user, updateRbsDto.Roles); @@ -204,10 +232,10 @@ namespace API.Controllers { return Ok(); } - + await _unitOfWork.RollbackAsync(); return BadRequest("Something went wrong, unable to update user's roles"); } } -} \ No newline at end of file +} diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index dda5543c3..53c3953ac 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -185,6 +185,8 @@ namespace API.Controllers if (chapterIds.Any()) { + await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters(); + await _unitOfWork.CommitAsync(); _taskScheduler.CleanupChapters(chapterIds); } return Ok(true); diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index 947ce05a0..47e96be37 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -14,6 +14,9 @@ using Microsoft.AspNetCore.Mvc; namespace API.Controllers { + /// + /// For all things regarding reading, mainly focusing on non-Book related entities + /// public class ReaderController : BaseApiController { private readonly IDirectoryService _directoryService; @@ -23,6 +26,7 @@ namespace API.Controllers private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst(); private readonly NaturalSortComparer _naturalSortComparer = new NaturalSortComparer(); + /// public ReaderController(IDirectoryService directoryService, ICacheService cacheService, IUnitOfWork unitOfWork) { _directoryService = directoryService; @@ -30,6 +34,12 @@ namespace API.Controllers _unitOfWork = unitOfWork; } + /// + /// Returns an image for a given chapter. Side effect: This will cache the chapter images for reading. + /// + /// + /// + /// [HttpGet("image")] public async Task GetImage(int chapterId, int page) { @@ -57,6 +67,12 @@ namespace API.Controllers } } + /// + /// Returns various information about a Chapter. Side effect: This will cache the chapter images for reading. + /// + /// + /// + /// [HttpGet("chapter-info")] public async Task> GetChapterInfo(int seriesId, int chapterId) { @@ -149,6 +165,11 @@ namespace API.Controllers return userProgress; } + /// + /// Marks a Chapter as Unread (progress) + /// + /// + /// [HttpPost("mark-unread")] public async Task MarkUnread(MarkReadDto markReadDto) { @@ -179,6 +200,11 @@ namespace API.Controllers return BadRequest("There was an issue saving progress"); } + /// + /// Marks all chapters within a volume as Read + /// + /// + /// [HttpPost("mark-volume-read")] public async Task MarkVolumeAsRead(MarkVolumeReadDto markVolumeReadDto) { @@ -218,6 +244,11 @@ namespace API.Controllers return BadRequest("Could not save progress"); } + /// + /// Returns Progress (page number) for a chapter for the logged in user + /// + /// + /// [HttpGet("get-progress")] public async Task> GetProgress(int chapterId) { @@ -242,6 +273,11 @@ namespace API.Controllers return Ok(progressBookmark); } + /// + /// Save page against Chapter for logged in user + /// + /// + /// [HttpPost("progress")] public async Task BookmarkProgress(ProgressDto progressDto) { @@ -259,13 +295,11 @@ namespace API.Controllers progressDto.PageNum = 0; } - try { - // 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(); + user.Progresses ??= new List(); var userProgress = - user.Progresses.SingleOrDefault(x => x.ChapterId == progressDto.ChapterId && x.AppUserId == user.Id); + user.Progresses.FirstOrDefault(x => x.ChapterId == progressDto.ChapterId && x.AppUserId == user.Id); if (userProgress == null) { @@ -303,6 +337,11 @@ namespace API.Controllers return BadRequest("Could not save progress"); } + /// + /// Returns a list of bookmarked pages for a given Chapter + /// + /// + /// [HttpGet("get-bookmarks")] public async Task>> GetBookmarks(int chapterId) { @@ -311,6 +350,11 @@ namespace API.Controllers return Ok(await _unitOfWork.UserRepository.GetBookmarkDtosForChapter(user.Id, chapterId)); } + /// + /// Removes all bookmarks for all chapters linked to a Series + /// + /// + /// [HttpPost("remove-bookmarks")] public async Task RemoveBookmarks(int seriesId) { @@ -335,6 +379,11 @@ namespace API.Controllers } + /// + /// Returns all bookmarked pages for a given volume + /// + /// + /// [HttpGet("get-volume-bookmarks")] public async Task>> GetBookmarksForVolume(int volumeId) { @@ -343,6 +392,11 @@ namespace API.Controllers return Ok(await _unitOfWork.UserRepository.GetBookmarkDtosForVolume(user.Id, volumeId)); } + /// + /// Returns all bookmarked pages for a given series + /// + /// + /// [HttpGet("get-series-bookmarks")] public async Task>> GetBookmarksForSeries(int seriesId) { @@ -352,6 +406,11 @@ namespace API.Controllers return Ok(await _unitOfWork.UserRepository.GetBookmarkDtosForSeries(user.Id, seriesId)); } + /// + /// Bookmarks a page against a Chapter + /// + /// + /// [HttpPost("bookmark")] public async Task BookmarkPage(BookmarkDto bookmarkDto) { @@ -408,6 +467,11 @@ namespace API.Controllers return BadRequest("Could not save bookmark"); } + /// + /// Removes a bookmarked page for a Chapter + /// + /// + /// [HttpPost("unbookmark")] public async Task UnBookmarkPage(BookmarkDto bookmarkDto) { diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index c41ae1841..5ec47b53b 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -79,6 +79,9 @@ namespace API.Controllers if (result) { + await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters(); + await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries(); + await _unitOfWork.CommitAsync(); _taskScheduler.CleanupChapters(chapterIds); } return Ok(result); diff --git a/API/Data/CollectionTagRepository.cs b/API/Data/CollectionTagRepository.cs index 77cfe70f2..7530ffdb2 100644 --- a/API/Data/CollectionTagRepository.cs +++ b/API/Data/CollectionTagRepository.cs @@ -25,12 +25,26 @@ namespace API.Data { _context.CollectionTag.Remove(tag); } - + public void Update(CollectionTag tag) { _context.Entry(tag).State = EntityState.Modified; } + /// + /// Removes any collection tags without any series + /// + public async Task RemoveTagsWithoutSeries() + { + var tagsToDelete = await _context.CollectionTag + .Include(c => c.SeriesMetadatas) + .Where(c => c.SeriesMetadatas.Count == 0) + .ToListAsync(); + _context.RemoveRange(tagsToDelete); + + return await _context.SaveChangesAsync(); + } + public async Task> GetAllTagDtosAsync() { return await _context.CollectionTag @@ -40,7 +54,7 @@ namespace API.Data .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); } - + public async Task> GetAllPromotedTagDtosAsync() { return await _context.CollectionTag @@ -57,7 +71,7 @@ namespace API.Data .Where(c => c.Id == tagId) .SingleOrDefaultAsync(); } - + public async Task GetFullTagAsync(int tagId) { return await _context.CollectionTag @@ -69,7 +83,7 @@ namespace API.Data public async Task> SearchTagDtosAsync(string searchQuery) { return await _context.CollectionTag - .Where(s => EF.Functions.Like(s.Title, $"%{searchQuery}%") + .Where(s => EF.Functions.Like(s.Title, $"%{searchQuery}%") || EF.Functions.Like(s.NormalizedTitle, $"%{searchQuery}%")) .OrderBy(s => s.Title) .AsNoTracking() @@ -87,4 +101,4 @@ namespace API.Data .SingleOrDefaultAsync(); } } -} \ No newline at end of file +} diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index 98e0cb8cd..62765f607 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -1,4 +1,7 @@ using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; using API.Entities; using API.Entities.Interfaces; using Microsoft.AspNetCore.Identity; @@ -67,5 +70,48 @@ namespace API.Data if (e.NewState == EntityState.Modified && e.Entry.Entity is IEntityDate entity) entity.LastModified = DateTime.Now; } + + private void OnSaveChanges() + { + foreach (var saveEntity in ChangeTracker.Entries() + .Where(e => e.State == EntityState.Modified) + .Select(entry => entry.Entity) + .OfType()) + { + saveEntity.OnSavingChanges(); + } + } + + #region SaveChanges overrides + + public override int SaveChanges() + { + this.OnSaveChanges(); + + return base.SaveChanges(); + } + + public override int SaveChanges(bool acceptAllChangesOnSuccess) + { + this.OnSaveChanges(); + + return base.SaveChanges(acceptAllChangesOnSuccess); + } + + public override Task SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default(CancellationToken)) + { + this.OnSaveChanges(); + + return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken); + } + + public override Task SaveChangesAsync(CancellationToken cancellationToken = default(CancellationToken)) + { + this.OnSaveChanges(); + + return base.SaveChangesAsync(cancellationToken); + } + + #endregion } } diff --git a/API/Data/Migrations/20210817152226_ProgressConcurencyCheck.Designer.cs b/API/Data/Migrations/20210817152226_ProgressConcurencyCheck.Designer.cs new file mode 100644 index 000000000..830e86064 --- /dev/null +++ b/API/Data/Migrations/20210817152226_ProgressConcurencyCheck.Designer.cs @@ -0,0 +1,926 @@ +// +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("20210817152226_ProgressConcurencyCheck")] + partial class ProgressConcurencyCheck + { + 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("RowVersion") + .IsConcurrencyToken() + .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("CoverImageLocked") + .HasColumnType("INTEGER"); + + 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("CoverImageLocked") + .HasColumnType("INTEGER"); + + 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("CoverImageLocked") + .HasColumnType("INTEGER"); + + 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/20210817152226_ProgressConcurencyCheck.cs b/API/Data/Migrations/20210817152226_ProgressConcurencyCheck.cs new file mode 100644 index 000000000..d6ec6aba9 --- /dev/null +++ b/API/Data/Migrations/20210817152226_ProgressConcurencyCheck.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace API.Data.Migrations +{ + public partial class ProgressConcurencyCheck : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "RowVersion", + table: "AppUserProgresses", + type: "INTEGER", + nullable: false, + defaultValue: 0u); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "RowVersion", + table: "AppUserProgresses"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 4d36d906f..2a54ba454 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -226,6 +226,10 @@ namespace API.Data.Migrations b.Property("PagesRead") .HasColumnType("INTEGER"); + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + b.Property("SeriesId") .HasColumnType("INTEGER"); diff --git a/API/Data/UnitOfWork.cs b/API/Data/UnitOfWork.cs index 01277281b..25ac5654c 100644 --- a/API/Data/UnitOfWork.cs +++ b/API/Data/UnitOfWork.cs @@ -33,25 +33,46 @@ namespace API.Data public IFileRepository FileRepository => new FileRepository(_context); public IChapterRepository ChapterRepository => new ChapterRepository(_context); + /// + /// Commits changes to the DB. Completes the open transaction. + /// + /// public bool Commit() { + return _context.SaveChanges() > 0; } + /// + /// Commits changes to the DB. Completes the open transaction. + /// + /// public async Task CommitAsync() { return await _context.SaveChangesAsync() > 0; } + /// + /// Is the DB Context aware of Changes in loaded entities + /// + /// public bool HasChanges() { return _context.ChangeTracker.HasChanges(); } + /// + /// Rollback transaction + /// + /// public async Task RollbackAsync() { await _context.DisposeAsync(); return true; } + /// + /// Rollback transaction + /// + /// public bool Rollback() { _context.Dispose(); diff --git a/API/Entities/AppUser.cs b/API/Entities/AppUser.cs index b75c871f2..eec9d90bf 100644 --- a/API/Entities/AppUser.cs +++ b/API/Entities/AppUser.cs @@ -18,9 +18,11 @@ namespace API.Entities public AppUserPreferences UserPreferences { get; set; } public ICollection Bookmarks { get; set; } + /// [ConcurrencyCheck] - public uint RowVersion { get; set; } + public uint RowVersion { get; private set; } + /// public void OnSavingChanges() { RowVersion++; diff --git a/API/Entities/AppUserProgress.cs b/API/Entities/AppUserProgress.cs index 3443a68d1..1efe4b102 100644 --- a/API/Entities/AppUserProgress.cs +++ b/API/Entities/AppUserProgress.cs @@ -1,30 +1,71 @@  using System; +using System.ComponentModel.DataAnnotations; using API.Entities.Interfaces; +using Microsoft.EntityFrameworkCore; namespace API.Entities { /// /// Represents the progress a single user has on a given Chapter. /// - public class AppUserProgress : IEntityDate + //[Index(nameof(SeriesId), nameof(VolumeId), nameof(ChapterId), nameof(AppUserId), IsUnique = true)] + public class AppUserProgress : IEntityDate, IHasConcurrencyToken { + /// + /// Id of Entity + /// public int Id { get; set; } + /// + /// Pages Read for given Chapter + /// public int PagesRead { get; set; } + /// + /// Volume belonging to Chapter + /// public int VolumeId { get; set; } + /// + /// Series belonging to Chapter + /// public int SeriesId { get; set; } + /// + /// Chapter + /// public int ChapterId { get; set; } /// /// For Book Reader, represents the nearest passed anchor on the screen that can be used to resume scroll point /// on next load /// public string BookScrollId { get; set; } - + // Relationships + /// + /// Navigational Property for EF. Links to a unique AppUser + /// public AppUser AppUser { get; set; } + /// + /// User this progress belongs to + /// public int AppUserId { get; set; } - + + /// + /// When this was first created + /// public DateTime Created { get; set; } + /// + /// Last date this was updated + /// public DateTime LastModified { get; set; } + + /// + [ConcurrencyCheck] + public uint RowVersion { get; private set; } + + + /// + public void OnSavingChanges() + { + RowVersion++; + } } -} \ No newline at end of file +} diff --git a/API/Entities/CollectionTag.cs b/API/Entities/CollectionTag.cs index 798e90f84..cc1a8acd2 100644 --- a/API/Entities/CollectionTag.cs +++ b/API/Entities/CollectionTag.cs @@ -43,9 +43,11 @@ namespace API.Entities public ICollection SeriesMetadatas { get; set; } + /// [ConcurrencyCheck] - public uint RowVersion { get; set; } + public uint RowVersion { get; private set; } + /// public void OnSavingChanges() { RowVersion++; diff --git a/API/Entities/Genre.cs b/API/Entities/Genre.cs index 51d9a9dbc..9490c03e7 100644 --- a/API/Entities/Genre.cs +++ b/API/Entities/Genre.cs @@ -9,9 +9,11 @@ namespace API.Entities public string Name { get; set; } // MetadataUpdate add ProviderId + /// [ConcurrencyCheck] - public uint RowVersion { get; set; } + public uint RowVersion { get; private set; } + /// public void OnSavingChanges() { RowVersion++; diff --git a/API/Entities/Person.cs b/API/Entities/Person.cs index 750274b8a..c9f182215 100644 --- a/API/Entities/Person.cs +++ b/API/Entities/Person.cs @@ -10,12 +10,14 @@ namespace API.Entities public string Name { get; set; } public PersonRole Role { get; set; } + /// [ConcurrencyCheck] - public uint RowVersion { get; set; } + public uint RowVersion { get; private set; } + /// public void OnSavingChanges() { RowVersion++; } } -} \ No newline at end of file +} diff --git a/API/Entities/SeriesMetadata.cs b/API/Entities/SeriesMetadata.cs index ec8955e1e..f86c5430e 100644 --- a/API/Entities/SeriesMetadata.cs +++ b/API/Entities/SeriesMetadata.cs @@ -16,9 +16,11 @@ namespace API.Entities public Series Series { get; set; } public int SeriesId { get; set; } + /// [ConcurrencyCheck] - public uint RowVersion { get; set; } + public uint RowVersion { get; private set; } + /// public void OnSavingChanges() { RowVersion++; diff --git a/API/Entities/ServerSetting.cs b/API/Entities/ServerSetting.cs index 09501c20e..b09ae71e0 100644 --- a/API/Entities/ServerSetting.cs +++ b/API/Entities/ServerSetting.cs @@ -10,11 +10,14 @@ namespace API.Entities public ServerSettingKey Key { get; set; } public string Value { get; set; } + /// [ConcurrencyCheck] - public uint RowVersion { get; set; } + public uint RowVersion { get; private set; } + + /// public void OnSavingChanges() { RowVersion++; } } -} \ No newline at end of file +} diff --git a/API/Interfaces/ICollectionTagRepository.cs b/API/Interfaces/ICollectionTagRepository.cs index 5d820d8c2..234670e70 100644 --- a/API/Interfaces/ICollectionTagRepository.cs +++ b/API/Interfaces/ICollectionTagRepository.cs @@ -15,5 +15,6 @@ namespace API.Interfaces Task GetTagAsync(int tagId); Task GetFullTagAsync(int tagId); void Update(CollectionTag tag); + Task RemoveTagsWithoutSeries(); } -} \ No newline at end of file +} diff --git a/API/Services/MetadataService.cs b/API/Services/MetadataService.cs index fec286c92..921f46b82 100644 --- a/API/Services/MetadataService.cs +++ b/API/Services/MetadataService.cs @@ -93,7 +93,7 @@ namespace API.Services { // NOTE: Why do I do this? By the time this method gets executed, the chapter has already been calculated for // Plus how can we have a volume without at least 1 chapter? - var firstFile = firstChapter?.Files.OrderBy(x => x.Chapter).FirstOrDefault(); + var firstFile = firstChapter.Files.OrderBy(x => x.Chapter).FirstOrDefault(); if (firstFile != null && !new FileInfo(firstFile.FilePath).IsLastWriteLessThan(firstFile.LastModified)) { firstChapter.CoverImage = GetCoverImage(firstFile); diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index 3e0939b2a..faa171a3e 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -117,6 +117,7 @@ namespace API.Services BackgroundJob.Enqueue(() => _cleanupService.Cleanup()); } + public void CleanupChapters(int[] chapterIds) { BackgroundJob.Enqueue(() => _cacheService.CleanupChapters(chapterIds)); diff --git a/API/Services/Tasks/CleanupService.cs b/API/Services/Tasks/CleanupService.cs index aaa7eba9b..ae04d46bd 100644 --- a/API/Services/Tasks/CleanupService.cs +++ b/API/Services/Tasks/CleanupService.cs @@ -21,6 +21,9 @@ namespace API.Services.Tasks _backupService = backupService; } + /// + /// Cleans up Temp, cache, and old database backups + /// [AutomaticRetry(Attempts = 3, LogEvents = false, OnAttemptsExceeded = AttemptsExceededAction.Fail)] public void Cleanup() { @@ -31,7 +34,6 @@ namespace API.Services.Tasks _cacheService.Cleanup(); _logger.LogInformation("Cleaning old database backups"); _backupService.CleanupBackups(); - } } -} \ No newline at end of file +} diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index 18b87b1f3..f54366d21 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -72,7 +72,8 @@ namespace API.Services.Tasks _logger.LogInformation( "Processed {TotalFiles} files and {ParsedSeriesCount} series in {ElapsedScanTime} milliseconds for {SeriesName}", totalFiles, parsedSeries.Keys.Count, sw.ElapsedMilliseconds + scanElapsedTime, series.Name); - CleanupUserProgress(); + + CleanupDbEntities(); BackgroundJob.Enqueue(() => _metadataService.RefreshMetadata(libraryId, forceUpdate)); BackgroundJob.Enqueue(() => _cacheService.CleanupChapters(chapterIds)); } @@ -134,6 +135,11 @@ namespace API.Services.Tasks } + /// + /// Scans a library for file changes. If force update passed, all entities will be rechecked for new cover images and comicInfo.xml changes. + /// + /// + /// [DisableConcurrentExecution(360)] [AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)] public void ScanLibrary(int libraryId, bool forceUpdate) @@ -190,6 +196,16 @@ namespace API.Services.Tasks _logger.LogInformation("Removed {Count} abandoned progress rows", cleanedUp); } + /// + /// Cleans up any abandoned rows due to removals from Scan loop + /// + private void CleanupDbEntities() + { + CleanupUserProgress(); + var cleanedUp = Task.Run( () => _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries()).Result; + _logger.LogInformation("Removed {Count} abandoned collection tags", cleanedUp); + } + private void UpdateLibrary(Library library, Dictionary> parsedSeries) { if (parsedSeries == null) throw new ArgumentNullException(nameof(parsedSeries)); diff --git a/Kavita.Common/Configuration.cs b/Kavita.Common/Configuration.cs index 57e96b234..1ea788bc8 100644 --- a/Kavita.Common/Configuration.cs +++ b/Kavita.Common/Configuration.cs @@ -141,8 +141,7 @@ namespace Kavita.Common public static int GetPort(string filePath) { - Console.WriteLine(GetAppSettingFilename()); - const int defaultPort = 5000; + const int defaultPort = 5000; if (new OsInfo(Array.Empty()).IsDocker) { return defaultPort; diff --git a/README.md b/README.md index 52b38e484..16ca17853 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # []() Kavita
-![Cover Image](https://github.com/Kareadita/kareadita.github.io/blob/main/img/features/seriesdetail.PNG?raw=true) +!![high level view](https://user-images.githubusercontent.com/735851/129777364-2c82d01e-5c03-4daf-b203-92b1d48e5b7b.gif) Kavita is a fast, feature rich, cross platform reading server. Built with a focus for manga, and the goal of being a full solution for all your reading needs. Setup your own server and share @@ -122,3 +122,4 @@ Thank you to [](https://sentry.io/ * [GNU GPL v3](http://www.gnu.org/licenses/gpl.html) * Copyright 2020-2021 + diff --git a/UI/Web/src/app/_models/chapter.ts b/UI/Web/src/app/_models/chapter.ts index 5b6956e74..94dd1b2d1 100644 --- a/UI/Web/src/app/_models/chapter.ts +++ b/UI/Web/src/app/_models/chapter.ts @@ -5,7 +5,10 @@ export interface Chapter { range: string; number: string; files: Array; - //coverImage: string; + /** + * This is used in the UI, it is not updated or sent to Backend + */ + coverImage: string; coverImageLocked: boolean; pages: number; volumeId: number; diff --git a/UI/Web/src/app/_services/action-factory.service.ts b/UI/Web/src/app/_services/action-factory.service.ts index 28e5480f7..8ed3385cd 100644 --- a/UI/Web/src/app/_services/action-factory.service.ts +++ b/UI/Web/src/app/_services/action-factory.service.ts @@ -104,13 +104,6 @@ export class ActionFactoryService { callback: this.dummyCallback, requiresAdmin: true }); - - this.volumeActions.push({ - action: Action.Edit, - title: 'Edit', - callback: this.dummyCallback, - requiresAdmin: false - }); this.chapterActions.push({ action: Action.Edit, @@ -203,6 +196,12 @@ export class ActionFactoryService { title: 'Mark as Unread', callback: this.dummyCallback, requiresAdmin: false + }, + { + action: Action.Edit, + title: 'Info', + callback: this.dummyCallback, + requiresAdmin: false } ]; diff --git a/UI/Web/src/app/admin/manage-users/manage-users.component.html b/UI/Web/src/app/admin/manage-users/manage-users.component.html index df1881ea8..232b9b2da 100644 --- a/UI/Web/src/app/admin/manage-users/manage-users.component.html +++ b/UI/Web/src/app/admin/manage-users/manage-users.component.html @@ -9,7 +9,7 @@
  • - {{member.username | titlecase}} Admin + {{member.username | titlecase}} Admin
    diff --git a/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.html b/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.html index 1766c2701..4542f7938 100644 --- a/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.html +++ b/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.html @@ -30,11 +30,20 @@

    Chapters

    • - + + +
      - Chapter {{formatChapterNumber(chapter)}} + + +  Chapter {{formatChapterNumber(chapter)}} + + {{chapter.pagesRead}} / {{chapter.pages}} + UNREAD + READ + File(s)
      @@ -57,7 +66,7 @@
    diff --git a/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.ts b/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.ts index c50702b11..6cd02f5c8 100644 --- a/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.ts +++ b/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.ts @@ -1,11 +1,15 @@ import { Component, Input, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { ToastrService } from 'ngx-toastr'; +import { take } from 'rxjs/operators'; import { UtilityService } from 'src/app/shared/_services/utility.service'; import { Chapter } from 'src/app/_models/chapter'; import { MangaFile } from 'src/app/_models/manga-file'; import { MangaFormat } from 'src/app/_models/manga-format'; -import { Volume } from 'src/app/_models/volume'; +import { AccountService } from 'src/app/_services/account.service'; +import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/action-factory.service'; +import { ActionService } from 'src/app/_services/action.service'; import { ImageService } from 'src/app/_services/image.service'; import { UploadService } from 'src/app/_services/upload.service'; import { ChangeCoverImageModalComponent } from '../change-cover-image/change-cover-image-modal.component'; @@ -21,6 +25,7 @@ export class CardDetailsModalComponent implements OnInit { @Input() parentName = ''; @Input() seriesId: number = 0; + @Input() libraryId: number = 0; @Input() data!: any; // Volume | Chapter isChapter = false; chapters: Chapter[] = []; @@ -31,20 +36,34 @@ export class CardDetailsModalComponent implements OnInit { * If a cover image update occured. */ coverImageUpdate: boolean = false; + isAdmin: boolean = false; + actions: ActionItem[] = []; + chapterActions: ActionItem[] = []; constructor(private modalService: NgbModal, public modal: NgbActiveModal, public utilityService: UtilityService, - public imageService: ImageService, private uploadService: UploadService, private toastr: ToastrService) { } + public imageService: ImageService, private uploadService: UploadService, private toastr: ToastrService, + private accountService: AccountService, private actionFactoryService: ActionFactoryService, + private actionService: ActionService, private router: Router) { } ngOnInit(): void { this.isChapter = this.utilityService.isChapter(this.data); + this.accountService.currentUser$.pipe(take(1)).subscribe(user => { + if (user) { + this.isAdmin = this.accountService.hasAdminRole(user); + } + }); + + this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this)).filter(item => item.action !== Action.Edit); + if (this.isChapter) { this.chapters.push(this.data); } else if (!this.isChapter) { this.chapters.push(...this.data?.chapters); } this.chapters.sort(this.utilityService.sortChapters); + this.chapters.forEach(c => c.coverImage = this.imageService.getChapterCoverImage(c.id)); // Try to show an approximation of the reading order for files var collator = new Intl.Collator(undefined, {numeric: true, sensitivity: 'base'}); this.chapters.forEach((c: Chapter) => { @@ -63,10 +82,17 @@ export class CardDetailsModalComponent implements OnInit { return chapter.number; } + performAction(action: ActionItem, chapter: Chapter) { + if (typeof action.callback === 'function') { + action.callback(action.action, chapter); + } + } + updateCover() { const modalRef = this.modalService.open(ChangeCoverImageModalComponent, { size: 'lg' }); // scrollable: true, size: 'lg', windowClass: 'scrollable-modal' (these don't work well on mobile) if (this.utilityService.isChapter(this.data)) { const chapter = this.utilityService.asChapter(this.data) + chapter.coverImage = this.imageService.getChapterCoverImage(chapter.id); modalRef.componentInstance.chapter = chapter; modalRef.componentInstance.title = 'Select ' + (chapter.isSpecial ? '' : 'Chapter ') + chapter.range + '\'s Cover'; } else { @@ -85,8 +111,52 @@ export class CardDetailsModalComponent implements OnInit { this.uploadService.resetChapterCoverLock(closeResult.chapter.id).subscribe(() => { this.toastr.info('Please refresh in a bit for the cover image to be reflected.'); }); + } else { + closeResult.chapter.coverImage = this.imageService.randomize(this.imageService.getChapterCoverImage(closeResult.chapter.id)); } } }); } + + markChapterAsRead(chapter: Chapter) { + if (this.seriesId === 0) { + return; + } + + this.actionService.markChapterAsRead(this.seriesId, chapter, () => { /* No Action */ }); + } + + markChapterAsUnread(chapter: Chapter) { + if (this.seriesId === 0) { + return; + } + + this.actionService.markChapterAsUnread(this.seriesId, chapter, () => { /* No Action */ }); + } + + handleChapterActionCallback(action: Action, chapter: Chapter) { + switch (action) { + case(Action.MarkAsRead): + this.markChapterAsRead(chapter); + break; + case(Action.MarkAsUnread): + this.markChapterAsUnread(chapter); + break; + default: + break; + } + } + + readChapter(chapter: Chapter) { + if (chapter.pages === 0) { + this.toastr.error('There are no pages. Kavita was not able to read this archive.'); + return; + } + + if (chapter.files.length > 0 && chapter.files[0].format === MangaFormat.EPUB) { + this.router.navigate(['library', this.libraryId, 'series', this.seriesId, 'book', chapter.id]); + } else { + this.router.navigate(['library', this.libraryId, 'series', this.seriesId, 'manga', chapter.id]); + } + } } diff --git a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts index 61e02c4ae..2ed296572 100644 --- a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts +++ b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts @@ -53,10 +53,6 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { private uploadService: UploadService) { } ngOnInit(): void { - // this.imageUrls.push({ - // imageUrl: this.imageService.getSeriesCoverImage(this.series.id), - // source: 'Url' - // }); this.imageUrls.push(this.imageService.getSeriesCoverImage(this.series.id)); this.libraryService.getLibraryNames().pipe(takeUntil(this.onDestroy)).subscribe(names => { diff --git a/UI/Web/src/app/cards/card-item/card-item.component.html b/UI/Web/src/app/cards/card-item/card-item.component.html index 32b18b9e2..478764aaf 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.html +++ b/UI/Web/src/app/cards/card-item/card-item.component.html @@ -1,8 +1,8 @@
    - -

    diff --git a/UI/Web/src/app/cards/card-item/card-item.component.scss b/UI/Web/src/app/cards/card-item/card-item.component.scss index 722c71c4e..e1b5ceecc 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.scss +++ b/UI/Web/src/app/cards/card-item/card-item.component.scss @@ -38,7 +38,7 @@ $image-width: 160px; margin-bottom: 0px; } -.card-img-top { +.img-top { height: $image-height; } diff --git a/UI/Web/src/app/series-detail/series-detail.component.ts b/UI/Web/src/app/series-detail/series-detail.component.ts index f094d4b54..f2fc17ee9 100644 --- a/UI/Web/src/app/series-detail/series-detail.component.ts +++ b/UI/Web/src/app/series-detail/series-detail.component.ts @@ -390,6 +390,7 @@ export class SeriesDetailComponent implements OnInit { modalRef.componentInstance.data = data; modalRef.componentInstance.parentName = this.series?.name; modalRef.componentInstance.seriesId = this.series?.id; + modalRef.componentInstance.libraryId = this.series?.libraryId; modalRef.closed.subscribe((result: {coverImageUpdate: boolean}) => { if (result.coverImageUpdate) { this.coverImageOffset += 1;