diff --git a/API.Tests/Services/ReadingListServiceTests.cs b/API.Tests/Services/ReadingListServiceTests.cs new file mode 100644 index 000000000..4df8fb688 --- /dev/null +++ b/API.Tests/Services/ReadingListServiceTests.cs @@ -0,0 +1,109 @@ +using System.Collections.Generic; +using System.Data.Common; +using System.IO.Abstractions.TestingHelpers; +using System.Linq; +using System.Threading.Tasks; +using API.Data; +using API.Entities; +using API.Entities.Enums; +using API.Helpers; +using API.Services; +using AutoMapper; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace API.Tests.Services; + +public class ReadingListServiceTests +{ + private readonly IUnitOfWork _unitOfWork; + private readonly IReadingListService _readingListService; + + private readonly DataContext _context; + + private const string CacheDirectory = "C:/kavita/config/cache/"; + private const string CoverImageDirectory = "C:/kavita/config/covers/"; + private const string BackupDirectory = "C:/kavita/config/backups/"; + private const string DataDirectory = "C:/data/"; + + public ReadingListServiceTests() + { + var contextOptions = new DbContextOptionsBuilder().UseSqlite(CreateInMemoryDatabase()).Options; + + _context = new DataContext(contextOptions); + Task.Run(SeedDb).GetAwaiter().GetResult(); + + var config = new MapperConfiguration(cfg => cfg.AddProfile()); + var mapper = config.CreateMapper(); + _unitOfWork = new UnitOfWork(_context, mapper, null); + + _readingListService = new ReadingListService(_unitOfWork, Substitute.For>()); + } + + #region Setup + + private static DbConnection CreateInMemoryDatabase() + { + var connection = new SqliteConnection("Filename=:memory:"); + + connection.Open(); + + return connection; + } + + private async Task SeedDb() + { + await _context.Database.MigrateAsync(); + var filesystem = CreateFileSystem(); + + await Seed.SeedSettings(_context, + new DirectoryService(Substitute.For>(), filesystem)); + + var setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.CacheDirectory).SingleAsync(); + setting.Value = CacheDirectory; + + setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BackupDirectory).SingleAsync(); + setting.Value = BackupDirectory; + + _context.ServerSetting.Update(setting); + + _context.Library.Add(new Library() + { + Name = "Manga", Folders = new List() {new FolderPath() {Path = "C:/data/"}} + }); + return await _context.SaveChangesAsync() > 0; + } + + private async Task ResetDb() + { + _context.Series.RemoveRange(_context.Series.ToList()); + + await _context.SaveChangesAsync(); + } + + private static MockFileSystem CreateFileSystem() + { + var fileSystem = new MockFileSystem(); + fileSystem.Directory.SetCurrentDirectory("C:/kavita/"); + fileSystem.AddDirectory("C:/kavita/config/"); + fileSystem.AddDirectory(CacheDirectory); + fileSystem.AddDirectory(CoverImageDirectory); + fileSystem.AddDirectory(BackupDirectory); + fileSystem.AddDirectory(DataDirectory); + + return fileSystem; + } + + #endregion + + + #region RemoveFullyReadItems + + // TODO: Implement all methods here + + #endregion + +} diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index 8133d2493..bae44b208 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -477,6 +477,7 @@ namespace API.Controllers var emailLink = GenerateEmailLink(token, "confirm-email", dto.Email); _logger.LogCritical("[Invite User]: Email Link for {UserName}: {Link}", user.UserName, emailLink); + _logger.LogCritical("[Invite User]: Token {UserName}: {Token}", user.UserName, token); var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString(); var accessible = await _emailService.CheckIfAccessible(host); if (accessible) @@ -600,8 +601,10 @@ namespace API.Controllers if (!roles.Any(r => r is PolicyConstants.AdminRole or PolicyConstants.ChangePasswordRole)) return Unauthorized("You are not permitted to this operation."); - var emailLink = GenerateEmailLink(await _userManager.GeneratePasswordResetTokenAsync(user), "confirm-reset-password", user.Email); + var token = await _userManager.GeneratePasswordResetTokenAsync(user); + var emailLink = GenerateEmailLink(token, "confirm-reset-password", user.Email); _logger.LogCritical("[Forgot Password]: Email Link for {UserName}: {Link}", user.UserName, emailLink); + _logger.LogCritical("[Forgot Password]: Token {UserName}: {Token}", user.UserName, token); var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString(); if (await _emailService.CheckIfAccessible(host)) { @@ -654,8 +657,10 @@ namespace API.Controllers "This user needs to migrate. Have them log out and login to trigger a migration flow"); if (user.EmailConfirmed) return BadRequest("User already confirmed"); - var emailLink = GenerateEmailLink(await _userManager.GenerateEmailConfirmationTokenAsync(user), "confirm-email", user.Email); + var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); + var emailLink = GenerateEmailLink(token, "confirm-email", user.Email); _logger.LogCritical("[Email Migration]: Email Link: {Link}", emailLink); + _logger.LogCritical("[Email Migration]: Token {UserName}: {Token}", user.UserName, token); await _emailService.SendMigrationEmail(new EmailMigrationDto() { EmailAddress = user.Email, @@ -732,6 +737,8 @@ namespace API.Controllers var result = await _userManager.ConfirmEmailAsync(user, token); if (result.Succeeded) return true; + + _logger.LogCritical("[Account] Email validation failed"); if (!result.Errors.Any()) return false; diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index 255c38f19..e5165ae42 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using System.Xml.Serialization; using API.Comparators; using API.Data; +using API.Data.Repositories; using API.DTOs; using API.DTOs.CollectionTags; using API.DTOs.Filtering; @@ -305,7 +306,7 @@ public class OpdsController : BaseApiController var userId = await GetUser(apiKey); var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); - var userWithLists = await _unitOfWork.UserRepository.GetUserWithReadingListsByUsernameAsync(user.UserName); + var userWithLists = await _unitOfWork.UserRepository.GetUserByUsernameAsync(user.UserName, AppUserIncludes.ReadingListsWithItems); var readingList = userWithLists.ReadingLists.SingleOrDefault(t => t.Id == readingListId); if (readingList == null) { diff --git a/API/Controllers/PluginController.cs b/API/Controllers/PluginController.cs index dfb32f406..4a1209710 100644 --- a/API/Controllers/PluginController.cs +++ b/API/Controllers/PluginController.cs @@ -1,4 +1,5 @@ -using System.Threading.Tasks; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; using API.Data; using API.DTOs; using API.Services; @@ -24,12 +25,13 @@ namespace API.Controllers /// /// Authenticate with the Server given an apiKey. This will log you in by returning the user object and the JWT token. /// - /// + /// This API is not fully built out and may require more information in later releases + /// API key which will be used to authenticate and return a valid user token back /// Name of the Plugin /// [AllowAnonymous] [HttpPost("authenticate")] - public async Task> Authenticate(string apiKey, string pluginName) + public async Task> Authenticate([Required] string apiKey, [Required] string pluginName) { // NOTE: In order to log information about plugins, we need some Plugin Description information for each request // Should log into access table so we can tell the user diff --git a/API/Controllers/ReadingListController.cs b/API/Controllers/ReadingListController.cs index 53d6cfb56..f07f17724 100644 --- a/API/Controllers/ReadingListController.cs +++ b/API/Controllers/ReadingListController.cs @@ -8,6 +8,7 @@ using API.DTOs.ReadingLists; using API.Entities; using API.Extensions; using API.Helpers; +using API.Services; using API.SignalR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -19,12 +20,14 @@ namespace API.Controllers { private readonly IUnitOfWork _unitOfWork; private readonly IEventHub _eventHub; + private readonly IReadingListService _readingListService; private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst(); - public ReadingListController(IUnitOfWork unitOfWork, IEventHub eventHub) + public ReadingListController(IUnitOfWork unitOfWork, IEventHub eventHub, IReadingListService readingListService) { _unitOfWork = unitOfWork; _eventHub = eventHub; + _readingListService = readingListService; } /// @@ -55,6 +58,11 @@ namespace API.Controllers return Ok(items); } + /// + /// Returns all Reading Lists the user has access to that have a series within it. + /// + /// + /// [HttpGet("lists-for-series")] public async Task>> GetListsForSeries(int seriesId) { @@ -78,17 +86,6 @@ namespace API.Controllers return Ok(items); } - private async Task UserHasReadingListAccess(int readingListId) - { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), - AppUserIncludes.ReadingLists); - if (user.ReadingLists.SingleOrDefault(rl => rl.Id == readingListId) == null && !await _unitOfWork.UserRepository.IsUserAdminAsync(user)) - { - return null; - } - - return user; - } /// /// Updates an items position @@ -99,25 +96,14 @@ namespace API.Controllers public async Task UpdateListItemPosition(UpdateReadingListPosition dto) { // Make sure UI buffers events - var user = await UserHasReadingListAccess(dto.ReadingListId); + var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); if (user == null) { return BadRequest("You do not have permissions on this reading list or the list doesn't exist"); } - var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemsByIdAsync(dto.ReadingListId)).ToList(); - var item = items.Find(r => r.Id == dto.ReadingListItemId); - items.Remove(item); - items.Insert(dto.ToPosition, item); - for (var i = 0; i < items.Count; i++) - { - items[i].Order = i; - } + if (await _readingListService.UpdateReadingListItemPosition(dto)) return Ok("Updated"); - if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync()) - { - return Ok("Updated"); - } return BadRequest("Couldn't update position"); } @@ -130,25 +116,13 @@ namespace API.Controllers [HttpPost("delete-item")] public async Task DeleteListItem(UpdateReadingListPosition dto) { - var user = await UserHasReadingListAccess(dto.ReadingListId); + var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); if (user == null) { return BadRequest("You do not have permissions on this reading list or the list doesn't exist"); } - var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(dto.ReadingListId); - readingList.Items = readingList.Items.Where(r => r.Id != dto.ReadingListItemId).ToList(); - - var index = 0; - foreach (var readingListItem in readingList.Items) - { - readingListItem.Order = index; - index++; - } - - if (!_unitOfWork.HasChanges()) return Ok(); - - if (await _unitOfWork.CommitAsync()) + if (await _readingListService.DeleteReadingListItem(dto)) { return Ok("Updated"); } @@ -164,34 +138,16 @@ namespace API.Controllers [HttpPost("remove-read")] public async Task DeleteReadFromList([FromQuery] int readingListId) { - var user = await UserHasReadingListAccess(readingListId); + var user = await _readingListService.UserHasReadingListAccess(readingListId, User.GetUsername()); if (user == null) { return BadRequest("You do not have permissions on this reading list or the list doesn't exist"); } - var items = await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, user.Id); - items = await _unitOfWork.ReadingListRepository.AddReadingProgressModifiers(user.Id, items.ToList()); - - // Collect all Ids to remove - var itemIdsToRemove = items.Where(item => item.PagesRead == item.PagesTotal).Select(item => item.Id); - - try + if (await _readingListService.RemoveFullyReadItems(readingListId, user)) { - var listItems = - (await _unitOfWork.ReadingListRepository.GetReadingListItemsByIdAsync(readingListId)).Where(r => - itemIdsToRemove.Contains(r.Id)); - _unitOfWork.ReadingListRepository.BulkRemove(listItems); - - if (!_unitOfWork.HasChanges()) return Ok("Nothing to remove"); - - await _unitOfWork.CommitAsync(); return Ok("Updated"); } - catch - { - await _unitOfWork.RollbackAsync(); - } return BadRequest("Could not remove read items"); } @@ -204,20 +160,13 @@ namespace API.Controllers [HttpDelete] public async Task DeleteList([FromQuery] int readingListId) { - var user = await UserHasReadingListAccess(readingListId); + var user = await _readingListService.UserHasReadingListAccess(readingListId, User.GetUsername()); if (user == null) { return BadRequest("You do not have permissions on this reading list or the list doesn't exist"); } - var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(readingListId); - - user.ReadingLists.Remove(readingList); - - if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync()) - { - return Ok("Deleted"); - } + if (await _readingListService.DeleteReadingList(readingListId, user)) return Ok("List was deleted"); return BadRequest("There was an issue deleting reading list"); } @@ -230,7 +179,8 @@ namespace API.Controllers [HttpPost("create")] public async Task> CreateList(CreateReadingListDto dto) { - var user = await _unitOfWork.UserRepository.GetUserWithReadingListsByUsernameAsync(User.GetUsername()); + + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.ReadingListsWithItems); // When creating, we need to make sure Title is unique var hasExisting = user.ReadingLists.Any(l => l.Title.Equals(dto.Title)); @@ -260,7 +210,7 @@ namespace API.Controllers var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(dto.ReadingListId); if (readingList == null) return BadRequest("List does not exist"); - var user = await UserHasReadingListAccess(readingList.Id); + var user = await _readingListService.UserHasReadingListAccess(readingList.Id, User.GetUsername()); if (user == null) { return BadRequest("You do not have permissions on this reading list or the list doesn't exist"); @@ -308,7 +258,7 @@ namespace API.Controllers [HttpPost("update-by-series")] public async Task UpdateListBySeries(UpdateReadingListBySeriesDto dto) { - var user = await UserHasReadingListAccess(dto.ReadingListId); + var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); if (user == null) { return BadRequest("You do not have permissions on this reading list or the list doesn't exist"); @@ -320,7 +270,7 @@ namespace API.Controllers await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(new [] {dto.SeriesId}); // If there are adds, tell tracking this has been modified - if (await AddChaptersToReadingList(dto.SeriesId, chapterIdsForSeries, readingList)) + if (await _readingListService.AddChaptersToReadingList(dto.SeriesId, chapterIdsForSeries, readingList)) { _unitOfWork.ReadingListRepository.Update(readingList); } @@ -350,7 +300,7 @@ namespace API.Controllers [HttpPost("update-by-multiple")] public async Task UpdateListByMultiple(UpdateReadingListByMultipleDto dto) { - var user = await UserHasReadingListAccess(dto.ReadingListId); + var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); if (user == null) { return BadRequest("You do not have permissions on this reading list or the list doesn't exist"); @@ -365,7 +315,7 @@ namespace API.Controllers } // If there are adds, tell tracking this has been modified - if (await AddChaptersToReadingList(dto.SeriesId, chapterIds, readingList)) + if (await _readingListService.AddChaptersToReadingList(dto.SeriesId, chapterIds, readingList)) { _unitOfWork.ReadingListRepository.Update(readingList); } @@ -394,7 +344,7 @@ namespace API.Controllers [HttpPost("update-by-multiple-series")] public async Task UpdateListByMultipleSeries(UpdateReadingListByMultipleSeriesDto dto) { - var user = await UserHasReadingListAccess(dto.ReadingListId); + var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); if (user == null) { return BadRequest("You do not have permissions on this reading list or the list doesn't exist"); @@ -407,7 +357,7 @@ namespace API.Controllers foreach (var seriesId in ids.Keys) { // If there are adds, tell tracking this has been modified - if (await AddChaptersToReadingList(seriesId, ids[seriesId], readingList)) + if (await _readingListService.AddChaptersToReadingList(seriesId, ids[seriesId], readingList)) { _unitOfWork.ReadingListRepository.Update(readingList); } @@ -432,7 +382,7 @@ namespace API.Controllers [HttpPost("update-by-volume")] public async Task UpdateListByVolume(UpdateReadingListByVolumeDto dto) { - var user = await UserHasReadingListAccess(dto.ReadingListId); + var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); if (user == null) { return BadRequest("You do not have permissions on this reading list or the list doesn't exist"); @@ -444,7 +394,7 @@ namespace API.Controllers (await _unitOfWork.ChapterRepository.GetChaptersAsync(dto.VolumeId)).Select(c => c.Id).ToList(); // If there are adds, tell tracking this has been modified - if (await AddChaptersToReadingList(dto.SeriesId, chapterIdsForVolume, readingList)) + if (await _readingListService.AddChaptersToReadingList(dto.SeriesId, chapterIdsForVolume, readingList)) { _unitOfWork.ReadingListRepository.Update(readingList); } @@ -468,7 +418,7 @@ namespace API.Controllers [HttpPost("update-by-chapter")] public async Task UpdateListByChapter(UpdateReadingListByChapterDto dto) { - var user = await UserHasReadingListAccess(dto.ReadingListId); + var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); if (user == null) { return BadRequest("You do not have permissions on this reading list or the list doesn't exist"); @@ -477,7 +427,7 @@ namespace API.Controllers if (readingList == null) return BadRequest("Reading List does not exist"); // If there are adds, tell tracking this has been modified - if (await AddChaptersToReadingList(dto.SeriesId, new List() { dto.ChapterId }, readingList)) + if (await _readingListService.AddChaptersToReadingList(dto.SeriesId, new List() { dto.ChapterId }, readingList)) { _unitOfWork.ReadingListRepository.Update(readingList); } @@ -498,39 +448,7 @@ namespace API.Controllers return Ok("Nothing to do"); } - /// - /// Adds a list of Chapters as reading list items to the passed reading list. - /// - /// - /// - /// - /// True if new chapters were added - private async Task AddChaptersToReadingList(int seriesId, IList chapterIds, - ReadingList readingList) - { - // TODO: Move to ReadingListService and Unit Test - readingList.Items ??= new List(); - var lastOrder = 0; - if (readingList.Items.Any()) - { - lastOrder = readingList.Items.DefaultIfEmpty().Max(rli => rli.Order); - } - var existingChapterExists = readingList.Items.Select(rli => rli.ChapterId).ToHashSet(); - var chaptersForSeries = (await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(chapterIds)) - .OrderBy(c => Parser.Parser.MinNumberFromRange(c.Volume.Name)) - .ThenBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting); - - var index = lastOrder + 1; - foreach (var chapter in chaptersForSeries) - { - if (existingChapterExists.Contains(chapter.Id)) continue; - readingList.Items.Add(DbFactory.ReadingListItem(index, seriesId, chapter.VolumeId, chapter.Id)); - index += 1; - } - - return index > lastOrder + 1; - } /// /// Returns the next chapter within the reading list diff --git a/API/DTOs/ReadingLists/UpdateReadingListPosition.cs b/API/DTOs/ReadingLists/UpdateReadingListPosition.cs index 023849024..5407a1ad5 100644 --- a/API/DTOs/ReadingLists/UpdateReadingListPosition.cs +++ b/API/DTOs/ReadingLists/UpdateReadingListPosition.cs @@ -1,10 +1,18 @@ -namespace API.DTOs.ReadingLists +using System.ComponentModel.DataAnnotations; + +namespace API.DTOs.ReadingLists { + /// + /// DTO for moving a reading list item to another position within the same list + /// public class UpdateReadingListPosition { + [Required] public int ReadingListId { get; set; } + [Required] public int ReadingListItemId { get; set; } public int FromPosition { get; set; } + [Required] public int ToPosition { get; set; } } } diff --git a/API/Data/MigrateRemoveExtraThemes.cs b/API/Data/MigrateRemoveExtraThemes.cs index 1c9a1e9b0..747c910c0 100644 --- a/API/Data/MigrateRemoveExtraThemes.cs +++ b/API/Data/MigrateRemoveExtraThemes.cs @@ -13,16 +13,15 @@ public static class MigrateRemoveExtraThemes { public static async Task Migrate(IUnitOfWork unitOfWork, IThemeService themeService) { - Console.WriteLine("Removing Dark and E-Ink themes"); - var themes = (await unitOfWork.SiteThemeRepository.GetThemes()).ToList(); if (themes.FirstOrDefault(t => t.Name.Equals("Light")) == null) { - Console.WriteLine("Done. Nothing to do"); return; } + Console.WriteLine("Removing Dark and E-Ink themes"); + var darkTheme = themes.Single(t => t.Name.Equals("Dark")); var lightTheme = themes.Single(t => t.Name.Equals("Light")); var eInkTheme = themes.Single(t => t.Name.Equals("E-Ink")); diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index 149148cb1..b3bf0f2a9 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -121,7 +121,7 @@ public interface ISeriesRepository Task GetSeriesIdByFolder(string folder); Task GetSeriesByFolderPath(string folder); Task GetFullSeriesByName(string series, int libraryId); - Task GetFullSeriesByAnyName(string seriesName, string localizedName, int libraryId, MangaFormat format); + Task GetFullSeriesByAnyName(string seriesName, string localizedName, int libraryId, MangaFormat format, bool withFullIncludes = true); Task RemoveSeriesNotInList(IList seenSeries, int libraryId); Task>> GetFolderPathMap(int libraryId); } @@ -1217,8 +1217,9 @@ public class SeriesRepository : ISeriesRepository /// /// /// + /// Defaults to true. This will query against all foreign keys (deep). If false, just the series will come back /// - public Task GetFullSeriesByAnyName(string seriesName, string localizedName, int libraryId, MangaFormat format) + public Task GetFullSeriesByAnyName(string seriesName, string localizedName, int libraryId, MangaFormat format, bool withFullIncludes = true) { var normalizedSeries = Parser.Parser.Normalize(seriesName); var normalizedLocalized = Parser.Parser.Normalize(localizedName); @@ -1233,6 +1234,11 @@ public class SeriesRepository : ISeriesRepository s.NormalizedName.Equals(normalizedLocalized) || s.NormalizedLocalizedName.Equals(normalizedLocalized)); } + if (!withFullIncludes) + { + return query.SingleOrDefaultAsync(); + } + return query.Include(s => s.Metadata) .ThenInclude(m => m.People) .Include(s => s.Metadata) @@ -1261,15 +1267,28 @@ public class SeriesRepository : ISeriesRepository .SingleOrDefaultAsync(); } + + /// + /// Removes series that are not in the seenSeries list. Does not commit. + /// + /// + /// public async Task RemoveSeriesNotInList(IList seenSeries, int libraryId) { if (seenSeries.Count == 0) return; var ids = new List(); foreach (var parsedSeries in seenSeries) { - ids.Add(await _context.Series - .Where(s => s.Format == parsedSeries.Format && s.NormalizedName == parsedSeries.NormalizedName && s.LibraryId == libraryId) - .Select(s => s.Id).SingleAsync()); + var series = await _context.Series + .Where(s => s.Format == parsedSeries.Format && s.NormalizedName == parsedSeries.NormalizedName && + s.LibraryId == libraryId) + .Select(s => s.Id) + .SingleOrDefaultAsync(); + if (series > 0) + { + ids.Add(series); + } + } var seriesToRemove = await _context.Series diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index a9e78fe73..e02f414f4 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -23,7 +23,9 @@ public enum AppUserIncludes ReadingLists = 8, Ratings = 16, UserPreferences = 32, - WantToRead = 64 + WantToRead = 64, + ReadingListsWithItems = 128, + } public interface IUserRepository @@ -36,7 +38,6 @@ public interface IUserRepository Task> GetEmailConfirmedMemberDtosAsync(); Task> GetPendingMemberDtosAsync(); Task> GetAdminUsersAsync(); - Task> GetNonAdminUsersAsync(); Task IsUserAdminAsync(AppUser user); Task GetUserRatingAsync(int seriesId, int userId); Task GetPreferencesAsync(string username); @@ -51,11 +52,9 @@ public interface IUserRepository Task GetUserByUsernameAsync(string username, AppUserIncludes includeFlags = AppUserIncludes.None); Task GetUserByIdAsync(int userId, AppUserIncludes includeFlags = AppUserIncludes.None); Task GetUserIdByUsernameAsync(string username); - Task GetUserWithReadingListsByUsernameAsync(string username); Task> GetAllBookmarksByIds(IList bookmarkIds); Task GetUserByEmailAsync(string email); Task> GetAllUsers(); - Task> GetAllPreferencesByThemeAsync(int themeId); Task HasAccessToLibrary(int libraryId, int userId); Task> GetAllUsersAsync(AppUserIncludes includeFlags); @@ -167,6 +166,11 @@ public class UserRepository : IUserRepository query = query.Include(u => u.ReadingLists); } + if (includeFlags.HasFlag(AppUserIncludes.ReadingListsWithItems)) + { + query = query.Include(u => u.ReadingLists).ThenInclude(r => r.Items); + } + if (includeFlags.HasFlag(AppUserIncludes.Ratings)) { query = query.Include(u => u.Ratings); @@ -201,19 +205,6 @@ public class UserRepository : IUserRepository .SingleOrDefaultAsync(); } - /// - /// Gets an AppUser by username. Returns back Reading List and their Items. - /// - /// - /// - public async Task GetUserWithReadingListsByUsernameAsync(string username) - { - return await _context.Users - .Include(u => u.ReadingLists) - .ThenInclude(l => l.Items) - .AsSplitQuery() - .SingleOrDefaultAsync(x => x.UserName == username); - } /// /// Returns all Bookmarks for a given set of Ids @@ -267,11 +258,6 @@ public class UserRepository : IUserRepository return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole); } - public async Task> GetNonAdminUsersAsync() - { - return await _userManager.GetUsersInRoleAsync(PolicyConstants.PlebRole); - } - public async Task IsUserAdminAsync(AppUser user) { return await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole); @@ -404,14 +390,4 @@ public class UserRepository : IUserRepository .AsNoTracking() .ToListAsync(); } - - public async Task ValidateUserExists(string username) - { - if (await _userManager.Users.AnyAsync(x => x.NormalizedUserName == username.ToUpper())) - { - throw new ValidationException("Username is taken."); - } - - return true; - } } diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index b7f449aa5..d4fa19258 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -48,6 +48,7 @@ namespace API.Extensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/API/Services/ReadingListService.cs b/API/Services/ReadingListService.cs new file mode 100644 index 000000000..1669145dc --- /dev/null +++ b/API/Services/ReadingListService.cs @@ -0,0 +1,182 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.Comparators; +using API.Data; +using API.Data.Repositories; +using API.DTOs.ReadingLists; +using API.Entities; +using Microsoft.Extensions.Logging; + +namespace API.Services; + +public interface IReadingListService +{ + Task RemoveFullyReadItems(int readingListId, AppUser user); + Task UpdateReadingListItemPosition(UpdateReadingListPosition dto); + Task DeleteReadingListItem(UpdateReadingListPosition dto); + Task UserHasReadingListAccess(int readingListId, string username); + Task DeleteReadingList(int readingListId, AppUser user); + + Task AddChaptersToReadingList(int seriesId, IList chapterIds, + ReadingList readingList); +} + +/// +/// Methods responsible for management of Reading Lists +/// +/// If called from API layer, expected for to be called beforehand +public class ReadingListService : IReadingListService +{ + private readonly IUnitOfWork _unitOfWork; + private readonly ILogger _logger; + private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst(); + + public ReadingListService(IUnitOfWork unitOfWork, ILogger logger) + { + _unitOfWork = unitOfWork; + _logger = logger; + } + + + + /// + /// Removes all entries that are fully read from the reading list + /// + /// If called from API layer, expected for to be called beforehand + /// Reading List Id + /// User + /// + public async Task RemoveFullyReadItems(int readingListId, AppUser user) + { + var items = await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, user.Id); + items = await _unitOfWork.ReadingListRepository.AddReadingProgressModifiers(user.Id, items.ToList()); + + // Collect all Ids to remove + var itemIdsToRemove = items.Where(item => item.PagesRead == item.PagesTotal).Select(item => item.Id); + + try + { + var listItems = + (await _unitOfWork.ReadingListRepository.GetReadingListItemsByIdAsync(readingListId)).Where(r => + itemIdsToRemove.Contains(r.Id)); + _unitOfWork.ReadingListRepository.BulkRemove(listItems); + + if (!_unitOfWork.HasChanges()) return true; + + await _unitOfWork.CommitAsync(); + return true; + } + catch + { + await _unitOfWork.RollbackAsync(); + } + + return false; + } + + /// + /// Updates a reading list item from one position to another. This will cause items at that position to be pushed one index. + /// + /// + /// + public async Task UpdateReadingListItemPosition(UpdateReadingListPosition dto) + { + var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemsByIdAsync(dto.ReadingListId)).ToList(); + var item = items.Find(r => r.Id == dto.ReadingListItemId); + items.Remove(item); + items.Insert(dto.ToPosition, item); + + for (var i = 0; i < items.Count; i++) + { + items[i].Order = i; + } + + if (!_unitOfWork.HasChanges()) return true; + + return await _unitOfWork.CommitAsync(); + } + + public async Task DeleteReadingListItem(UpdateReadingListPosition dto) + { + var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(dto.ReadingListId); + readingList.Items = readingList.Items.Where(r => r.Id != dto.ReadingListItemId).ToList(); + + var index = 0; + foreach (var readingListItem in readingList.Items) + { + readingListItem.Order = index; + index++; + } + + if (!_unitOfWork.HasChanges()) return true; + + return await _unitOfWork.CommitAsync(); + } + + /// + /// Validates the user has access to the reading list to perform actions on it + /// + /// + /// + /// + public async Task UserHasReadingListAccess(int readingListId, string username) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(username, + AppUserIncludes.ReadingListsWithItems); + if (user.ReadingLists.SingleOrDefault(rl => rl.Id == readingListId) == null && !await _unitOfWork.UserRepository.IsUserAdminAsync(user)) + { + return null; + } + + return user; + } + + /// + /// Removes the Reading List from kavita + /// + /// + /// User should have ReadingLists populated + /// + public async Task DeleteReadingList(int readingListId, AppUser user) + { + var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(readingListId); + user.ReadingLists.Remove(readingList); + + if (!_unitOfWork.HasChanges()) return true; + + return await _unitOfWork.CommitAsync(); + } + + /// + /// Adds a list of Chapters as reading list items to the passed reading list. + /// + /// + /// + /// + /// True if new chapters were added + public async Task AddChaptersToReadingList(int seriesId, IList chapterIds, ReadingList readingList) + { + readingList.Items ??= new List(); + var lastOrder = 0; + if (readingList.Items.Any()) + { + lastOrder = readingList.Items.DefaultIfEmpty().Max(rli => rli.Order); + } + + var existingChapterExists = readingList.Items.Select(rli => rli.ChapterId).ToHashSet(); + var chaptersForSeries = (await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(chapterIds)) + .OrderBy(c => Parser.Parser.MinNumberFromRange(c.Volume.Name)) + .ThenBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting); + + var index = lastOrder + 1; + foreach (var chapter in chaptersForSeries) + { + if (existingChapterExists.Contains(chapter.Id)) continue; + readingList.Items.Add(DbFactory.ReadingListItem(index, seriesId, chapter.VolumeId, chapter.Id)); + index += 1; + } + + return index > lastOrder + 1; + } +} diff --git a/API/Services/Tasks/Scanner/LibraryWatcher.cs b/API/Services/Tasks/Scanner/LibraryWatcher.cs index 4105e431b..fb4f8abb7 100644 --- a/API/Services/Tasks/Scanner/LibraryWatcher.cs +++ b/API/Services/Tasks/Scanner/LibraryWatcher.cs @@ -78,7 +78,7 @@ public class LibraryWatcher : ILibraryWatcher _logger = logger; _scannerService = scannerService; - _queueWaitTime = environment.IsDevelopment() ? TimeSpan.FromSeconds(10) : TimeSpan.FromSeconds(30); + _queueWaitTime = environment.IsDevelopment() ? TimeSpan.FromSeconds(10) : TimeSpan.FromMinutes(1); } @@ -93,7 +93,7 @@ public class LibraryWatcher : ILibraryWatcher .ToList(); foreach (var libraryFolder in _libraryFolders) { - _logger.LogInformation("Watching {FolderPath}", libraryFolder); + _logger.LogDebug("Watching {FolderPath}", libraryFolder); var watcher = new FileSystemWatcher(libraryFolder); watcher.NotifyFilter = NotifyFilters.CreationTime | NotifyFilters.DirectoryName @@ -178,7 +178,7 @@ public class LibraryWatcher : ILibraryWatcher private void ProcessChange(string filePath, bool isDirectoryChange = false) { // We need to check if directory or not - if (!isDirectoryChange && !new Regex(Parser.Parser.SupportedExtensions).IsMatch(new FileInfo(filePath).Extension)) return; + if (!isDirectoryChange && !new Regex(Parser.Parser.SupportedExtensions).IsMatch(new FileInfo(filePath).Extension)) return; var parentDirectory = _directoryService.GetParentDirectoryName(filePath); if (string.IsNullOrEmpty(parentDirectory)) return; @@ -231,7 +231,7 @@ public class LibraryWatcher : ILibraryWatcher if (_scanQueue.Count > 0) { - Task.Delay(_queueWaitTime).ContinueWith(t=> ProcessQueue()); + Task.Delay(TimeSpan.FromSeconds(30)).ContinueWith(t=> ProcessQueue()); } } diff --git a/UI/Web/src/app/admin/manage-users/manage-users.component.ts b/UI/Web/src/app/admin/manage-users/manage-users.component.ts index 9dd9ac7a5..fc127a886 100644 --- a/UI/Web/src/app/admin/manage-users/manage-users.component.ts +++ b/UI/Web/src/app/admin/manage-users/manage-users.component.ts @@ -109,9 +109,11 @@ export class ManageUsersComponent implements OnInit, OnDestroy { async deleteUser(member: Member) { if (await this.confirmService.confirm('Are you sure you want to delete this user?')) { this.memberService.deleteMember(member.username).subscribe(() => { - this.loadMembers(); - this.loadPendingInvites(); - this.toastr.success(member.username + ' has been deleted.'); + setTimeout(() => { + this.loadMembers(); + this.loadPendingInvites(); + this.toastr.success(member.username + ' has been deleted.'); + }, 30); // SetTimeout because I've noticed this can run super fast and not give enough time for data to flush }); } } diff --git a/UI/Web/src/app/collections/all-collections/all-collections.component.html b/UI/Web/src/app/collections/all-collections/all-collections.component.html index 40f5c928e..9a6e4dea0 100644 --- a/UI/Web/src/app/collections/all-collections/all-collections.component.html +++ b/UI/Web/src/app/collections/all-collections/all-collections.component.html @@ -9,6 +9,7 @@ [items]="collections" [filterOpen]="filterOpen" [jumpBarKeys]="jumpbarKeys" + [trackByIdentity]="trackByIdentity" > diff --git a/UI/Web/src/app/collections/all-collections/all-collections.component.ts b/UI/Web/src/app/collections/all-collections/all-collections.component.ts index 8b4605ffe..97560267c 100644 --- a/UI/Web/src/app/collections/all-collections/all-collections.component.ts +++ b/UI/Web/src/app/collections/all-collections/all-collections.component.ts @@ -24,6 +24,7 @@ export class AllCollectionsComponent implements OnInit { collections: CollectionTag[] = []; collectionTagActions: ActionItem[] = []; jumpbarKeys: Array = []; + trackByIdentity = (index: number, item: CollectionTag) => `${item.id}_${item.title}`; filterOpen: EventEmitter = new EventEmitter(); diff --git a/UI/Web/src/app/series-detail/series-detail.component.html b/UI/Web/src/app/series-detail/series-detail.component.html index 2667b761e..d737bdaf7 100644 --- a/UI/Web/src/app/series-detail/series-detail.component.html +++ b/UI/Web/src/app/series-detail/series-detail.component.html @@ -11,42 +11,44 @@ -
-

Page Settings

- -
-
-
- -
-
- -
-
- - - - - - +
+
+

Page Settings

+ +
+
+ + +
+
+ +
+
+ + + + + + +
-
- + +
diff --git a/UI/Web/tsconfig.json b/UI/Web/tsconfig.json index 30b8fc1a0..fb52a67a3 100644 --- a/UI/Web/tsconfig.json +++ b/UI/Web/tsconfig.json @@ -16,7 +16,7 @@ "experimentalDecorators": true, "moduleResolution": "node", "importHelpers": true, - "target": "es2020", + "target": "ES6", "module": "es2020", "lib": [ "es2019",