diff --git a/API.Tests/Services/ReadingListServiceTests.cs b/API.Tests/Services/ReadingListServiceTests.cs index 817890885..7b036b7e5 100644 --- a/API.Tests/Services/ReadingListServiceTests.cs +++ b/API.Tests/Services/ReadingListServiceTests.cs @@ -961,322 +961,382 @@ public class ReadingListServiceTests Assert.Single(userWithList.ReadingLists); } #endregion - // - // #region CreateReadingListFromCBL - // - // private static CblReadingList LoadCblFromPath(string path) - // { - // var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ReadingListService/"); - // - // var reader = new System.Xml.Serialization.XmlSerializer(typeof(CblReadingList)); - // using var file = new StreamReader(Path.Join(testDirectory, path)); - // var cblReadingList = (CblReadingList) reader.Deserialize(file); - // file.Close(); - // return cblReadingList; - // } - // - // [Fact] - // public async Task CreateReadingListFromCBL_ShouldCreateList() - // { - // await ResetDb(); - // var cblReadingList = LoadCblFromPath("Fables.cbl"); - // - // // Mock up our series - // var fablesSeries = DbFactory.Series("Fables"); - // var fables2Series = DbFactory.Series("Fables: The Last Castle"); - // - // fablesSeries.Volumes.Add(new Volume() - // { - // Number = 1, - // Name = "2002", - // Chapters = new List() - // { - // EntityFactory.CreateChapter("1", false), - // EntityFactory.CreateChapter("2", false), - // EntityFactory.CreateChapter("3", false), - // - // } - // }); - // fables2Series.Volumes.Add(new Volume() - // { - // Number = 1, - // Name = "2003", - // Chapters = new List() - // { - // EntityFactory.CreateChapter("1", false), - // EntityFactory.CreateChapter("2", false), - // EntityFactory.CreateChapter("3", false), - // - // } - // }); - // - // _context.AppUser.Add(new AppUser() - // { - // UserName = "majora2007", - // ReadingLists = new List(), - // Libraries = new List() - // { - // new Library() - // { - // Name = "Test LIb", - // Type = LibraryType.Book, - // Series = new List() - // { - // fablesSeries, - // fables2Series - // }, - // }, - // }, - // }); - // await _unitOfWork.CommitAsync(); - // - // var importSummary = await _readingListService.CreateReadingListFromCbl(1, cblReadingList); - // - // Assert.Equal(CblImportResult.Partial, importSummary.Success); - // Assert.NotEmpty(importSummary.Results); - // - // var createdList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(1); - // - // Assert.NotNull(createdList); - // Assert.Equal("Fables", createdList.Title); - // - // Assert.Equal(4, createdList.Items.Count); - // Assert.Equal(1, createdList.Items.First(item => item.Order == 0).ChapterId); - // Assert.Equal(2, createdList.Items.First(item => item.Order == 1).ChapterId); - // Assert.Equal(3, createdList.Items.First(item => item.Order == 2).ChapterId); - // Assert.Equal(4, createdList.Items.First(item => item.Order == 3).ChapterId); - // } - // - // [Fact] - // public async Task CreateReadingListFromCBL_ShouldCreateList_ButOnlyIncludeSeriesThatUserHasAccessTo() - // { - // await ResetDb(); - // var cblReadingList = LoadCblFromPath("Fables.cbl"); - // - // // Mock up our series - // var fablesSeries = DbFactory.Series("Fables"); - // var fables2Series = DbFactory.Series("Fables: The Last Castle"); - // - // fablesSeries.Volumes.Add(new Volume() - // { - // Number = 1, - // Name = "2002", - // Chapters = new List() - // { - // EntityFactory.CreateChapter("1", false), - // EntityFactory.CreateChapter("2", false), - // EntityFactory.CreateChapter("3", false), - // - // } - // }); - // fables2Series.Volumes.Add(new Volume() - // { - // Number = 1, - // Name = "2003", - // Chapters = new List() - // { - // EntityFactory.CreateChapter("1", false), - // EntityFactory.CreateChapter("2", false), - // EntityFactory.CreateChapter("3", false), - // - // } - // }); - // - // _context.AppUser.Add(new AppUser() - // { - // UserName = "majora2007", - // ReadingLists = new List(), - // Libraries = new List() - // { - // new Library() - // { - // Name = "Test LIb", - // Type = LibraryType.Book, - // Series = new List() - // { - // fablesSeries, - // }, - // }, - // }, - // }); - // - // _context.Library.Add(new Library() - // { - // Name = "Test Lib 2", - // Type = LibraryType.Book, - // Series = new List() - // { - // fables2Series, - // }, - // }); - // - // await _unitOfWork.CommitAsync(); - // - // var importSummary = await _readingListService.CreateReadingListFromCbl(1, cblReadingList); - // - // Assert.Equal(CblImportResult.Partial, importSummary.Success); - // Assert.NotEmpty(importSummary.Results); - // - // var createdList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(1); - // - // Assert.NotNull(createdList); - // Assert.Equal("Fables", createdList.Title); - // - // Assert.Equal(3, createdList.Items.Count); - // Assert.Equal(1, createdList.Items.First(item => item.Order == 0).ChapterId); - // Assert.Equal(2, createdList.Items.First(item => item.Order == 1).ChapterId); - // Assert.Equal(3, createdList.Items.First(item => item.Order == 2).ChapterId); - // Assert.NotNull(importSummary.Results.SingleOrDefault(r => r.Series == "Fables: The Last Castle" - // && r.Reason == CblImportReason.SeriesMissing)); - // } - // - // [Fact] - // public async Task CreateReadingListFromCBL_ShouldFail_UserHasAccessToNoSeries() - // { - // await ResetDb(); - // var cblReadingList = LoadCblFromPath("Fables.cbl"); - // - // // Mock up our series - // var fablesSeries = DbFactory.Series("Fables"); - // var fables2Series = DbFactory.Series("Fables: The Last Castle"); - // - // fablesSeries.Volumes.Add(new Volume() - // { - // Number = 1, - // Name = "2002", - // Chapters = new List() - // { - // EntityFactory.CreateChapter("1", false), - // EntityFactory.CreateChapter("2", false), - // EntityFactory.CreateChapter("3", false), - // - // } - // }); - // fables2Series.Volumes.Add(new Volume() - // { - // Number = 1, - // Name = "2003", - // Chapters = new List() - // { - // EntityFactory.CreateChapter("1", false), - // EntityFactory.CreateChapter("2", false), - // EntityFactory.CreateChapter("3", false), - // - // } - // }); - // - // _context.AppUser.Add(new AppUser() - // { - // UserName = "majora2007", - // ReadingLists = new List(), - // Libraries = new List(), - // }); - // - // _context.Library.Add(new Library() - // { - // Name = "Test Lib 2", - // Type = LibraryType.Book, - // Series = new List() - // { - // fablesSeries, - // fables2Series, - // }, - // }); - // - // await _unitOfWork.CommitAsync(); - // - // var importSummary = await _readingListService.CreateReadingListFromCbl(1, cblReadingList); - // - // Assert.Equal(CblImportResult.Fail, importSummary.Success); - // Assert.NotEmpty(importSummary.Results); - // - // var createdList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(1); - // - // Assert.Null(createdList); - // } - // - // - // [Fact] - // public async Task CreateReadingListFromCBL_ShouldUpdateAnExistingList() - // { - // await ResetDb(); - // var cblReadingList = LoadCblFromPath("Fables.cbl"); - // - // // Mock up our series - // var fablesSeries = DbFactory.Series("Fables"); - // var fables2Series = DbFactory.Series("Fables: The Last Castle"); - // - // fablesSeries.Volumes.Add(new Volume() - // { - // Number = 1, - // Name = "2002", - // Chapters = new List() - // { - // EntityFactory.CreateChapter("1", false), - // EntityFactory.CreateChapter("2", false), - // EntityFactory.CreateChapter("3", false), - // - // } - // }); - // fables2Series.Volumes.Add(new Volume() - // { - // Number = 1, - // Name = "2003", - // Chapters = new List() - // { - // EntityFactory.CreateChapter("1", false), - // EntityFactory.CreateChapter("2", false), - // EntityFactory.CreateChapter("3", false), - // - // } - // }); - // - // _context.AppUser.Add(new AppUser() - // { - // UserName = "majora2007", - // ReadingLists = new List(), - // Libraries = new List() - // { - // new Library() - // { - // Name = "Test LIb", - // Type = LibraryType.Book, - // Series = new List() - // { - // fablesSeries, - // fables2Series - // }, - // }, - // }, - // }); - // - // await _unitOfWork.CommitAsync(); - // - // // Create a reading list named Fables and add 2 chapters to it - // var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.ReadingLists); - // var readingList = await _readingListService.CreateReadingListForUser(user, "Fables"); - // Assert.True(await _readingListService.AddChaptersToReadingList(1, new List() {1, 3}, readingList)); - // Assert.Equal(2, readingList.Items.Count); - // - // // Attempt to import a Cbl with same reading list name - // var importSummary = await _readingListService.CreateReadingListFromCbl(1, cblReadingList); - // - // Assert.Equal(CblImportResult.Partial, importSummary.Success); - // Assert.NotEmpty(importSummary.Results); - // - // var createdList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(1); - // - // Assert.NotNull(createdList); - // Assert.Equal("Fables", createdList.Title); - // - // Assert.Equal(4, createdList.Items.Count); - // Assert.Equal(4, importSummary.SuccessfulInserts.Count); - // - // Assert.Equal(1, createdList.Items.First(item => item.Order == 0).ChapterId); - // Assert.Equal(3, createdList.Items.First(item => item.Order == 1).ChapterId); // we inserted 3 first - // Assert.Equal(2, createdList.Items.First(item => item.Order == 2).ChapterId); - // Assert.Equal(4, createdList.Items.First(item => item.Order == 3).ChapterId); - // } - // #endregion - // + + #region ValidateCBL + + [Fact] + public async Task ValidateCblFile_ShouldFail_UserHasAccessToNoSeries() + { + await ResetDb(); + var cblReadingList = LoadCblFromPath("Fables.cbl"); + + // Mock up our series + var fablesSeries = DbFactory.Series("Fables"); + var fables2Series = DbFactory.Series("Fables: The Last Castle"); + + fablesSeries.Volumes.Add(new Volume() + { + Number = 1, + Name = "2002", + Chapters = new List() + { + EntityFactory.CreateChapter("1", false), + EntityFactory.CreateChapter("2", false), + EntityFactory.CreateChapter("3", false), + + } + }); + fables2Series.Volumes.Add(new Volume() + { + Number = 1, + Name = "2003", + Chapters = new List() + { + EntityFactory.CreateChapter("1", false), + EntityFactory.CreateChapter("2", false), + EntityFactory.CreateChapter("3", false), + + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007", + ReadingLists = new List(), + Libraries = new List(), + }); + + _context.Library.Add(new Library() + { + Name = "Test Lib 2", + Type = LibraryType.Book, + Series = new List() + { + fablesSeries, + fables2Series, + }, + }); + + await _unitOfWork.CommitAsync(); + + var importSummary = await _readingListService.ValidateCblFile(1, cblReadingList); + + Assert.Equal(CblImportResult.Fail, importSummary.Success); + Assert.NotEmpty(importSummary.Results); + } + + [Fact] + public async Task ValidateCblFile_ShouldFail_ServerHasNoSeries() + { + await ResetDb(); + var cblReadingList = LoadCblFromPath("Fables.cbl"); + + // Mock up our series + var fablesSeries = DbFactory.Series("Fablesa"); + var fables2Series = DbFactory.Series("Fablesa: The Last Castle"); + + fablesSeries.Volumes.Add(new Volume() + { + Number = 1, + Name = "2002", + Chapters = new List() + { + EntityFactory.CreateChapter("1", false), + EntityFactory.CreateChapter("2", false), + EntityFactory.CreateChapter("3", false), + + } + }); + fables2Series.Volumes.Add(new Volume() + { + Number = 1, + Name = "2003", + Chapters = new List() + { + EntityFactory.CreateChapter("1", false), + EntityFactory.CreateChapter("2", false), + EntityFactory.CreateChapter("3", false), + + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007", + ReadingLists = new List(), + Libraries = new List(), + }); + + _context.Library.Add(new Library() + { + Name = "Test Lib 2", + Type = LibraryType.Book, + Series = new List() + { + fablesSeries, + fables2Series, + }, + }); + + await _unitOfWork.CommitAsync(); + + var importSummary = await _readingListService.ValidateCblFile(1, cblReadingList); + + Assert.Equal(CblImportResult.Fail, importSummary.Success); + Assert.NotEmpty(importSummary.Results); + } + + #endregion + + #region CreateReadingListFromCBL + + private static CblReadingList LoadCblFromPath(string path) + { + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ReadingListService/"); + + var reader = new System.Xml.Serialization.XmlSerializer(typeof(CblReadingList)); + using var file = new StreamReader(Path.Join(testDirectory, path)); + var cblReadingList = (CblReadingList) reader.Deserialize(file); + file.Close(); + return cblReadingList; + } + + [Fact] + public async Task CreateReadingListFromCBL_ShouldCreateList() + { + await ResetDb(); + var cblReadingList = LoadCblFromPath("Fables.cbl"); + + // Mock up our series + var fablesSeries = DbFactory.Series("Fables"); + var fables2Series = DbFactory.Series("Fables: The Last Castle"); + + fablesSeries.Volumes.Add(new Volume() + { + Number = 1, + Name = "2002", + Chapters = new List() + { + EntityFactory.CreateChapter("1", false), + EntityFactory.CreateChapter("2", false), + EntityFactory.CreateChapter("3", false), + + } + }); + fables2Series.Volumes.Add(new Volume() + { + Number = 1, + Name = "2003", + Chapters = new List() + { + EntityFactory.CreateChapter("1", false), + EntityFactory.CreateChapter("2", false), + EntityFactory.CreateChapter("3", false), + + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007", + ReadingLists = new List(), + Libraries = new List() + { + new Library() + { + Name = "Test LIb", + Type = LibraryType.Book, + Series = new List() + { + fablesSeries, + fables2Series + }, + }, + }, + }); + await _unitOfWork.CommitAsync(); + + var importSummary = await _readingListService.CreateReadingListFromCbl(1, cblReadingList); + + Assert.Equal(CblImportResult.Partial, importSummary.Success); + Assert.NotEmpty(importSummary.Results); + + var createdList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(1); + + Assert.NotNull(createdList); + Assert.Equal("Fables", createdList.Title); + + Assert.Equal(4, createdList.Items.Count); + Assert.Equal(1, createdList.Items.First(item => item.Order == 0).ChapterId); + Assert.Equal(2, createdList.Items.First(item => item.Order == 1).ChapterId); + Assert.Equal(3, createdList.Items.First(item => item.Order == 2).ChapterId); + Assert.Equal(4, createdList.Items.First(item => item.Order == 3).ChapterId); + } + + [Fact] + public async Task CreateReadingListFromCBL_ShouldCreateList_ButOnlyIncludeSeriesThatUserHasAccessTo() + { + await ResetDb(); + var cblReadingList = LoadCblFromPath("Fables.cbl"); + + // Mock up our series + var fablesSeries = DbFactory.Series("Fables"); + var fables2Series = DbFactory.Series("Fables: The Last Castle"); + + fablesSeries.Volumes.Add(new Volume() + { + Number = 1, + Name = "2002", + Chapters = new List() + { + EntityFactory.CreateChapter("1", false), + EntityFactory.CreateChapter("2", false), + EntityFactory.CreateChapter("3", false), + + } + }); + fables2Series.Volumes.Add(new Volume() + { + Number = 1, + Name = "2003", + Chapters = new List() + { + EntityFactory.CreateChapter("1", false), + EntityFactory.CreateChapter("2", false), + EntityFactory.CreateChapter("3", false), + + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007", + ReadingLists = new List(), + Libraries = new List() + { + new Library() + { + Name = "Test LIb", + Type = LibraryType.Book, + Series = new List() + { + fablesSeries, + }, + }, + }, + }); + + _context.Library.Add(new Library() + { + Name = "Test Lib 2", + Type = LibraryType.Book, + Series = new List() + { + fables2Series, + }, + }); + + await _unitOfWork.CommitAsync(); + + var importSummary = await _readingListService.CreateReadingListFromCbl(1, cblReadingList); + + Assert.Equal(CblImportResult.Partial, importSummary.Success); + Assert.NotEmpty(importSummary.Results); + + var createdList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(1); + + Assert.NotNull(createdList); + Assert.Equal("Fables", createdList.Title); + + Assert.Equal(3, createdList.Items.Count); + Assert.Equal(1, createdList.Items.First(item => item.Order == 0).ChapterId); + Assert.Equal(2, createdList.Items.First(item => item.Order == 1).ChapterId); + Assert.Equal(3, createdList.Items.First(item => item.Order == 2).ChapterId); + Assert.NotNull(importSummary.Results.SingleOrDefault(r => r.Series == "Fables: The Last Castle" + && r.Reason == CblImportReason.SeriesMissing)); + } + + [Fact] + public async Task CreateReadingListFromCBL_ShouldUpdateAnExistingList() + { + await ResetDb(); + var cblReadingList = LoadCblFromPath("Fables.cbl"); + + // Mock up our series + var fablesSeries = DbFactory.Series("Fables"); + var fables2Series = DbFactory.Series("Fables: The Last Castle"); + + fablesSeries.Volumes.Add(new Volume() + { + Number = 1, + Name = "2002", + Chapters = new List() + { + EntityFactory.CreateChapter("1", false), + EntityFactory.CreateChapter("2", false), + EntityFactory.CreateChapter("3", false), + + } + }); + fables2Series.Volumes.Add(new Volume() + { + Number = 1, + Name = "2003", + Chapters = new List() + { + EntityFactory.CreateChapter("1", false), + EntityFactory.CreateChapter("2", false), + EntityFactory.CreateChapter("3", false), + + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007", + ReadingLists = new List(), + Libraries = new List() + { + new Library() + { + Name = "Test LIb", + Type = LibraryType.Book, + Series = new List() + { + fablesSeries, + fables2Series + }, + }, + }, + }); + + await _unitOfWork.CommitAsync(); + + // Create a reading list named Fables and add 2 chapters to it + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.ReadingLists); + var readingList = await _readingListService.CreateReadingListForUser(user, "Fables"); + Assert.True(await _readingListService.AddChaptersToReadingList(1, new List() {1, 3}, readingList)); + Assert.Equal(2, readingList.Items.Count); + + // Attempt to import a Cbl with same reading list name + var importSummary = await _readingListService.CreateReadingListFromCbl(1, cblReadingList); + + Assert.Equal(CblImportResult.Partial, importSummary.Success); + Assert.NotEmpty(importSummary.Results); + + var createdList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(1); + + Assert.NotNull(createdList); + Assert.Equal("Fables", createdList.Title); + + Assert.Equal(4, createdList.Items.Count); + Assert.Equal(4, importSummary.SuccessfulInserts.Count); + + Assert.Equal(1, createdList.Items.First(item => item.Order == 0).ChapterId); + Assert.Equal(3, createdList.Items.First(item => item.Order == 1).ChapterId); // we inserted 3 first + Assert.Equal(2, createdList.Items.First(item => item.Order == 2).ChapterId); + Assert.Equal(4, createdList.Items.First(item => item.Order == 3).ChapterId); + } + #endregion + } diff --git a/API/Controllers/CBLController.cs b/API/Controllers/CBLController.cs new file mode 100644 index 000000000..f897866f1 --- /dev/null +++ b/API/Controllers/CBLController.cs @@ -0,0 +1,68 @@ +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs.ReadingLists; +using API.DTOs.ReadingLists.CBL; +using API.Extensions; +using API.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace API.Controllers; + +/// +/// Responsible for the CBL import flow +/// +public class CblController : BaseApiController +{ + private readonly IReadingListService _readingListService; + private readonly IDirectoryService _directoryService; + + public CblController(IReadingListService readingListService, IDirectoryService directoryService) + { + _readingListService = readingListService; + _directoryService = directoryService; + } + + /// + /// The first step in a cbl import. This validates the cbl file that if an import occured, would it be successful. + /// If this returns errors, the cbl will always be rejected by Kavita. + /// + /// FormBody with parameter name of cbl + /// + [HttpPost("validate")] + public async Task> ValidateCbl([FromForm(Name = "cbl")] IFormFile file) + { + var userId = User.GetUserId(); + var cbl = await SaveAndLoadCblFile(userId, file); + + var importSummary = await _readingListService.ValidateCblFile(userId, cbl); + return Ok(importSummary); + } + + + /// + /// Performs the actual import (assuming dryRun = false) + /// + /// FormBody with parameter name of cbl + /// If true, will only emulate the import but not perform. This should be done to preview what will happen + /// + [HttpPost("import")] + public async Task> ImportCbl([FromForm(Name = "cbl")] IFormFile file, [FromForm(Name = "dryRun")] bool dryRun = false) + { + var userId = User.GetUserId(); + var cbl = await SaveAndLoadCblFile(userId, file); + + return Ok(await _readingListService.CreateReadingListFromCbl(userId, cbl, dryRun)); + } + + private async Task SaveAndLoadCblFile(int userId, IFormFile file) + { + var filename = Path.GetRandomFileName(); + var outputFile = Path.Join(_directoryService.TempDirectory, filename); + await using var stream = System.IO.File.Create(outputFile); + await file.CopyToAsync(stream); + stream.Close(); + return ReadingListService.LoadCblFromPath(outputFile); + } +} diff --git a/API/Controllers/ReadingListController.cs b/API/Controllers/ReadingListController.cs index 8809ed43f..34e9b7b1f 100644 --- a/API/Controllers/ReadingListController.cs +++ b/API/Controllers/ReadingListController.cs @@ -484,22 +484,4 @@ public class ReadingListController : BaseApiController if (string.IsNullOrEmpty(name)) return true; return Ok(await _unitOfWork.ReadingListRepository.ReadingListExists(name)); } - - // [HttpPost("import-cbl")] - // public async Task> ImportCbl([FromForm(Name = "cbl")] IFormFile file, [FromForm(Name = "dryRun")] bool dryRun = false) - // { - // var userId = User.GetUserId(); - // var filename = Path.GetRandomFileName(); - // var outputFile = Path.Join(_directoryService.TempDirectory, filename); - // - // await using var stream = System.IO.File.Create(outputFile); - // await file.CopyToAsync(stream); - // stream.Close(); - // var cbl = ReadingListService.LoadCblFromPath(outputFile); - // - // // We need to pass the temp file back - // - // var importSummary = await _readingListService.ValidateCblFile(userId, cbl); - // return importSummary.Results.Any() ? Ok(importSummary) : Ok(await _readingListService.CreateReadingListFromCbl(userId, cbl, dryRun)); - // } } diff --git a/API/DTOs/ReadingLists/CBL/CblImportSummary.cs b/API/DTOs/ReadingLists/CBL/CblImportSummary.cs index de5deb0c2..5e83c7e49 100644 --- a/API/DTOs/ReadingLists/CBL/CblImportSummary.cs +++ b/API/DTOs/ReadingLists/CBL/CblImportSummary.cs @@ -1,8 +1,7 @@ using System.Collections.Generic; using System.ComponentModel; -using API.DTOs.ReadingLists.CBL; -namespace API.DTOs.ReadingLists; +namespace API.DTOs.ReadingLists.CBL; public enum CblImportResult { /// @@ -64,10 +63,19 @@ public enum CblImportReason /// [Description("All Chapters Missing")] AllChapterMissing = 7, + /// + /// The Chapter was imported + /// + [Description("Success")] + Success = 8, } public class CblBookResult { + /// + /// Order in the CBL + /// + public int Order { get; set; } public string Series { get; set; } public string Volume { get; set; } public string Number { get; set; } @@ -95,10 +103,5 @@ public class CblImportSummaryDto public ICollection Results { get; set; } public CblImportResult Success { get; set; } public ICollection SuccessfulInserts { get; set; } - /// - /// A list of Series that are within the CBL but map to multiple libraries within Kavita - /// - public IList Conflicts { get; set; } - public IList Conflicts2 { get; set; } } diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index 6a4b1d55f..e485acd54 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -117,7 +117,7 @@ public interface ISeriesRepository Task IsSeriesInWantToRead(int userId, int seriesId); Task GetSeriesByFolderPath(string folder, SeriesIncludes includes = SeriesIncludes.None); - Task> GetAllSeriesByNameAsync(IEnumerable normalizedNames, + Task> GetAllSeriesByNameAsync(IList normalizedNames, int userId, SeriesIncludes includes = SeriesIncludes.None); Task> GetAllSeriesDtosByNameAsync(IEnumerable normalizedNames, int userId, SeriesIncludes includes = SeriesIncludes.None); @@ -1213,14 +1213,14 @@ public class SeriesRepository : ISeriesRepository .SingleOrDefaultAsync(); } - public async Task> GetAllSeriesByNameAsync(IEnumerable normalizedNames, + public async Task> GetAllSeriesByNameAsync(IList normalizedNames, int userId, SeriesIncludes includes = SeriesIncludes.None) { var libraryIds = _context.Library.GetUserLibraries(userId); var userRating = await _context.AppUser.GetUserAgeRestriction(userId); return await _context.Series - .Where(s => normalizedNames.Contains(s.NormalizedName)) + .Where(s => normalizedNames.Contains(s.NormalizedName) || normalizedNames.Contains(s.NormalizedLocalizedName)) .Where(s => libraryIds.Contains(s.LibraryId)) .RestrictAgainstAgeRestriction(userRating) .Includes(includes) diff --git a/API/Services/ReadingListService.cs b/API/Services/ReadingListService.cs index 746bfdd5f..2ba5da693 100644 --- a/API/Services/ReadingListService.cs +++ b/API/Services/ReadingListService.cs @@ -355,13 +355,20 @@ public class ReadingListService : IReadingListService CblName = cblReading.Name, Success = CblImportResult.Success, Results = new List(), - SuccessfulInserts = new List(), - Conflicts = new List(), - Conflicts2 = new List() + SuccessfulInserts = new List() }; if (IsCblEmpty(cblReading, importSummary, out var readingListFromCbl)) return readingListFromCbl; - var uniqueSeries = cblReading.Books.Book.Select(b => Tasks.Scanner.Parser.Parser.Normalize(b.Series)).Distinct(); + // Is there another reading list with the same name? + if (await _unitOfWork.ReadingListRepository.ReadingListExists(cblReading.Name)) + { + importSummary.Results.Add(new CblBookResult() + { + Reason = CblImportReason.NameConflict + }); + } + + var uniqueSeries = cblReading.Books.Book.Select(b => Tasks.Scanner.Parser.Parser.Normalize(b.Series)).Distinct().ToList(); var userSeries = (await _unitOfWork.SeriesRepository.GetAllSeriesByNameAsync(uniqueSeries, userId, SeriesIncludes.Chapters)).ToList(); if (!userSeries.Any()) @@ -421,10 +428,11 @@ public class ReadingListService : IReadingListService SuccessfulInserts = new List() }; - var uniqueSeries = cblReading.Books.Book.Select(b => Tasks.Scanner.Parser.Parser.Normalize(b.Series)).Distinct(); + var uniqueSeries = cblReading.Books.Book.Select(b => Tasks.Scanner.Parser.Parser.Normalize(b.Series)).Distinct().ToList(); var userSeries = (await _unitOfWork.SeriesRepository.GetAllSeriesByNameAsync(uniqueSeries, userId, SeriesIncludes.Chapters)).ToList(); var allSeries = userSeries.ToDictionary(s => Tasks.Scanner.Parser.Parser.Normalize(s.Name)); + var allSeriesLocalized = userSeries.ToDictionary(s => Tasks.Scanner.Parser.Parser.Normalize(s.LocalizedName)); var readingListNameNormalized = Tasks.Scanner.Parser.Parser.Normalize(cblReading.Name); // Get all the user's reading lists @@ -452,38 +460,52 @@ public class ReadingListService : IReadingListService foreach (var (book, i) in cblReading.Books.Book.Select((value, i) => ( value, i ))) { var normalizedSeries = Tasks.Scanner.Parser.Parser.Normalize(book.Series); - if (!allSeries.TryGetValue(normalizedSeries, out var bookSeries)) + if (!allSeries.TryGetValue(normalizedSeries, out var bookSeries) && !allSeriesLocalized.TryGetValue(normalizedSeries, out bookSeries)) { importSummary.Results.Add(new CblBookResult(book) { - Reason = CblImportReason.SeriesMissing + Reason = CblImportReason.SeriesMissing, + Order = i }); continue; } // Prioritize lookup by Volume then Chapter, but allow fallback to just Chapter - var matchingVolume = bookSeries.Volumes.FirstOrDefault(v => book.Volume == v.Name) ?? bookSeries.Volumes.FirstOrDefault(v => v.Number == 0); + var bookVolume = string.IsNullOrEmpty(book.Volume) + ? Tasks.Scanner.Parser.Parser.DefaultVolume + : book.Volume; + var matchingVolume = bookSeries.Volumes.FirstOrDefault(v => bookVolume == v.Name) ?? bookSeries.Volumes.FirstOrDefault(v => v.Number == 0); if (matchingVolume == null) { importSummary.Results.Add(new CblBookResult(book) { - Reason = CblImportReason.VolumeMissing + Reason = CblImportReason.VolumeMissing, + Order = i }); continue; } - var chapter = matchingVolume.Chapters.FirstOrDefault(c => c.Number == book.Number); + // We need to handle chapter 0 or empty string when it's just a volume + var bookNumber = string.IsNullOrEmpty(book.Number) + ? Tasks.Scanner.Parser.Parser.DefaultChapter + : book.Number; + var chapter = matchingVolume.Chapters.FirstOrDefault(c => c.Number == bookNumber); if (chapter == null) { importSummary.Results.Add(new CblBookResult(book) { - Reason = CblImportReason.ChapterMissing + Reason = CblImportReason.ChapterMissing, + Order = i }); continue; } // See if a matching item already exists ExistsOrAddReadingListItem(readingList, bookSeries.Id, matchingVolume.Id, chapter.Id); - importSummary.SuccessfulInserts.Add(new CblBookResult(book)); + importSummary.SuccessfulInserts.Add(new CblBookResult(book) + { + Reason = CblImportReason.Success, + Order = i + }); } if (importSummary.SuccessfulInserts.Count != cblReading.Books.Book.Count || importSummary.Results.Count > 0) @@ -491,9 +513,14 @@ public class ReadingListService : IReadingListService importSummary.Success = CblImportResult.Partial; } + if (importSummary.SuccessfulInserts.Count == 0 && importSummary.Results.Count == cblReading.Books.Book.Count) + { + importSummary.Success = CblImportResult.Fail; + } + await CalculateReadingListAgeRating(readingList); - if (!dryRun) return importSummary; + if (dryRun) return importSummary; if (!_unitOfWork.HasChanges()) return importSummary; await _unitOfWork.CommitAsync(); diff --git a/UI/Web/src/_manga-reader-common.scss b/UI/Web/src/_manga-reader-common.scss index b734e1d6d..631310e54 100644 --- a/UI/Web/src/_manga-reader-common.scss +++ b/UI/Web/src/_manga-reader-common.scss @@ -90,4 +90,4 @@ img { 0px 0px calc(0.5px*3.14) 0.3px rgb(0 0 0 / 43%), 0px 0px 1px 0.5px rgb(0 0 0 / 43%); } -} \ No newline at end of file +} diff --git a/UI/Web/src/app/_models/reading-list/cbl/cbl-book-result.ts b/UI/Web/src/app/_models/reading-list/cbl/cbl-book-result.ts index 64396558b..37af21194 100644 --- a/UI/Web/src/app/_models/reading-list/cbl/cbl-book-result.ts +++ b/UI/Web/src/app/_models/reading-list/cbl/cbl-book-result.ts @@ -1,6 +1,7 @@ import { CblImportReason } from "./cbl-import-reason.enum"; export interface CblBookResult { + order: number; series: string; volume: string; number: string; diff --git a/UI/Web/src/app/_models/reading-list/cbl/cbl-import-reason.enum.ts b/UI/Web/src/app/_models/reading-list/cbl/cbl-import-reason.enum.ts index fd49469a7..6a05154aa 100644 --- a/UI/Web/src/app/_models/reading-list/cbl/cbl-import-reason.enum.ts +++ b/UI/Web/src/app/_models/reading-list/cbl/cbl-import-reason.enum.ts @@ -7,4 +7,5 @@ export enum CblImportReason { EmptyFile = 5, SeriesCollision = 6, AllChapterMissing = 7, + Success = 8 } \ No newline at end of file diff --git a/UI/Web/src/app/_models/reading-list/cbl/cbl-import-summary.ts b/UI/Web/src/app/_models/reading-list/cbl/cbl-import-summary.ts index 5ab7b1b5f..8459e4eed 100644 --- a/UI/Web/src/app/_models/reading-list/cbl/cbl-import-summary.ts +++ b/UI/Web/src/app/_models/reading-list/cbl/cbl-import-summary.ts @@ -12,7 +12,4 @@ export interface CblImportSummary { results: Array; success: CblImportResult; successfulInserts: Array; - conflicts: Array; - conflicts2: Array; - } \ No newline at end of file diff --git a/UI/Web/src/app/_services/reading-list.service.ts b/UI/Web/src/app/_services/reading-list.service.ts index 1cbcd5a96..bc680d68f 100644 --- a/UI/Web/src/app/_services/reading-list.service.ts +++ b/UI/Web/src/app/_services/reading-list.service.ts @@ -95,7 +95,11 @@ export class ReadingListService { return this.httpClient.get(this.baseUrl + 'readinglist/name-exists?name=' + name); } + validateCbl(form: FormData) { + return this.httpClient.post(this.baseUrl + 'cbl/validate', form); + } + importCbl(form: FormData) { - return this.httpClient.post(this.baseUrl + 'readinglist/import-cbl', form); + return this.httpClient.post(this.baseUrl + 'cbl/import', form); } } diff --git a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss index 991d903bf..3b2d1184b 100644 --- a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss +++ b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss @@ -198,10 +198,10 @@ $action-bar-height: 38px; height: calc((var(--vh) * 100) - calc($action-bar-height)); // * 2 } - &.immersive { - // Note: I removed this for bug: https://github.com/Kareadita/Kavita/issues/1726 - //height: calc((var(--vh, 1vh) * 100) - $action-bar-height); - } + // &.immersive { + // // Note: I removed this for bug: https://github.com/Kareadita/Kavita/issues/1726 + // //height: calc((var(--vh, 1vh) * 100) - $action-bar-height); + // } a, :link { color: var(--brtheme-link-text-color); diff --git a/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.ts b/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.ts index c105121fc..b931bb754 100644 --- a/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.ts +++ b/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.ts @@ -28,7 +28,7 @@ export class ReadingListsComponent implements OnInit { isAdmin: boolean = false; jumpbarKeys: Array = []; actions: {[key: number]: Array>} = {}; - globalActions: Array> = []; //[{action: Action.Import, title: 'Import CBL', children: [], requiresAdmin: true, callback: this.importCbl.bind(this)}] + globalActions: Array> = [{action: Action.Import, title: 'Import CBL', children: [], requiresAdmin: true, callback: this.importCbl.bind(this)}]; constructor(private readingListService: ReadingListService, public imageService: ImageService, private actionFactoryService: ActionFactoryService, private accountService: AccountService, private toastr: ToastrService, private router: Router, private actionService: ActionService, @@ -63,6 +63,7 @@ export class ReadingListsComponent implements OnInit { importCbl() { const ref = this.ngbModal.open(ImportCblModalComponent, {size: 'xl'}); ref.closed.subscribe(result => this.loadPage()); + ref.dismissed.subscribe(_ => this.loadPage()); } handleReadingListActionCallback(action: ActionItem, readingList: ReadingList) { diff --git a/UI/Web/src/app/reading-list/_components/step-tracker/step-tracker.component.html b/UI/Web/src/app/reading-list/_components/step-tracker/step-tracker.component.html new file mode 100644 index 000000000..8f3f10754 --- /dev/null +++ b/UI/Web/src/app/reading-list/_components/step-tracker/step-tracker.component.html @@ -0,0 +1,11 @@ + +
+
    + +
  • +
    + {{step.title}} +
  • +
    +
+
\ No newline at end of file diff --git a/UI/Web/src/app/reading-list/_components/step-tracker/step-tracker.component.scss b/UI/Web/src/app/reading-list/_components/step-tracker/step-tracker.component.scss new file mode 100644 index 000000000..6849760a6 --- /dev/null +++ b/UI/Web/src/app/reading-list/_components/step-tracker/step-tracker.component.scss @@ -0,0 +1,80 @@ + +.bs4-order-tracking { + overflow: hidden; + color: #878788; + padding-left: 0px; + margin-top: 30px + } + + .bs4-order-tracking li { + list-style-type: none; + font-size: 17px; + width: 25%; + float: left; + position: relative; + font-weight: 400; + color: #878788; + text-align: center + } + + .bs4-order-tracking li:first-child:before { + margin-left: 15px !important; + padding-left: 11px !important; + text-align: left !important + } + + .bs4-order-tracking li:last-child:before { + margin-right: 5px !important; + padding-right: 11px !important; + text-align: right !important + } + + .bs4-order-tracking li>div { + color: #fff; + width: 50px; + text-align: center; + line-height: 50px; + display: block; + font-size: 20px; + background: #878788; + border-radius: 50%; + margin: auto + } + + .bs4-order-tracking li:after { + content: ''; + width: 150%; + height: 2px; + background: #878788; + position: absolute; + left: 0%; + right: 0%; + top: 25px; + z-index: -1 + } + + .bs4-order-tracking li:first-child:after { + left: 50% + } + + .bs4-order-tracking li:last-child:after { + left: 0% !important; + width: 0% !important + } + + .bs4-order-tracking li.active { + font-weight: bold; + color: var(--primary-color); + } + + .bs4-order-tracking li.active>div { + background: var(--primary-color); + } + + .bs4-order-tracking li.active:after { + background: var(--primary-color); + } + + .card-timeline { + z-index: 0 + } diff --git a/UI/Web/src/app/reading-list/_components/step-tracker/step-tracker.component.ts b/UI/Web/src/app/reading-list/_components/step-tracker/step-tracker.component.ts new file mode 100644 index 000000000..2581cc565 --- /dev/null +++ b/UI/Web/src/app/reading-list/_components/step-tracker/step-tracker.component.ts @@ -0,0 +1,26 @@ +import { Component, Input, ChangeDetectionStrategy, OnInit, ChangeDetectorRef } from '@angular/core'; +import { BehaviorSubject, ReplaySubject } from 'rxjs'; + + +export interface TimelineStep { + title: string; + active: boolean; + icon: string; + index: number; +} + + +@Component({ + selector: 'app-step-tracker', + templateUrl: './step-tracker.component.html', + styleUrls: ['./step-tracker.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class StepTrackerComponent { + @Input() steps: Array = []; + @Input() currentStep: number = 0; + + + constructor(private readonly cdRef: ChangeDetectorRef) {} + +} diff --git a/UI/Web/src/app/reading-list/_modals/import-cbl-modal/import-cbl-modal.component.html b/UI/Web/src/app/reading-list/_modals/import-cbl-modal/import-cbl-modal.component.html index 9599def22..4093d4e4f 100644 --- a/UI/Web/src/app/reading-list/_modals/import-cbl-modal/import-cbl-modal.component.html +++ b/UI/Web/src/app/reading-list/_modals/import-cbl-modal/import-cbl-modal.component.html @@ -1,47 +1,62 @@ -