CBL Import (#1834)

* Wrote my own step tracker and added a prev button. Works up to first conflict flow.

* Everything but final import is hooked up in the UI. Polish still needed, but getting there.

* Making more progress in the CBL import flow.

* Ready for the last step

* Cleaned up some logic to prepare for the last step and reset

* Users like order to be starting at 1

* Fixed a few bugs around cbl import

* CBL import is ready for some basic testing

* Added a reading list hook on side nav

* Fixed up unit tests

* Added icons and color to the import flow

* Tweaked some phrasing

* Hooked up a loading variable but disabled the component as it didn't look good.

* Styling it up

* changed an icon to better fit

---------

Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
Joe Milazzo 2023-03-03 16:51:11 -06:00 committed by GitHub
parent 57de661d71
commit d88a4d5d0c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1125 additions and 466 deletions

View File

@ -961,322 +961,382 @@ public class ReadingListServiceTests
Assert.Single(userWithList.ReadingLists); Assert.Single(userWithList.ReadingLists);
} }
#endregion #endregion
//
// #region CreateReadingListFromCBL #region ValidateCBL
//
// private static CblReadingList LoadCblFromPath(string path) [Fact]
// { public async Task ValidateCblFile_ShouldFail_UserHasAccessToNoSeries()
// var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ReadingListService/"); {
// await ResetDb();
// var reader = new System.Xml.Serialization.XmlSerializer(typeof(CblReadingList)); var cblReadingList = LoadCblFromPath("Fables.cbl");
// using var file = new StreamReader(Path.Join(testDirectory, path));
// var cblReadingList = (CblReadingList) reader.Deserialize(file); // Mock up our series
// file.Close(); var fablesSeries = DbFactory.Series("Fables");
// return cblReadingList; var fables2Series = DbFactory.Series("Fables: The Last Castle");
// }
// fablesSeries.Volumes.Add(new Volume()
// [Fact] {
// public async Task CreateReadingListFromCBL_ShouldCreateList() Number = 1,
// { Name = "2002",
// await ResetDb(); Chapters = new List<Chapter>()
// var cblReadingList = LoadCblFromPath("Fables.cbl"); {
// EntityFactory.CreateChapter("1", false),
// // Mock up our series EntityFactory.CreateChapter("2", false),
// var fablesSeries = DbFactory.Series("Fables"); EntityFactory.CreateChapter("3", false),
// var fables2Series = DbFactory.Series("Fables: The Last Castle");
// }
// fablesSeries.Volumes.Add(new Volume() });
// { fables2Series.Volumes.Add(new Volume()
// Number = 1, {
// Name = "2002", Number = 1,
// Chapters = new List<Chapter>() Name = "2003",
// { Chapters = new List<Chapter>()
// EntityFactory.CreateChapter("1", false), {
// EntityFactory.CreateChapter("2", false), EntityFactory.CreateChapter("1", false),
// EntityFactory.CreateChapter("3", false), EntityFactory.CreateChapter("2", false),
// EntityFactory.CreateChapter("3", false),
// }
// }); }
// fables2Series.Volumes.Add(new Volume() });
// {
// Number = 1, _context.AppUser.Add(new AppUser()
// Name = "2003", {
// Chapters = new List<Chapter>() UserName = "majora2007",
// { ReadingLists = new List<ReadingList>(),
// EntityFactory.CreateChapter("1", false), Libraries = new List<Library>(),
// EntityFactory.CreateChapter("2", false), });
// EntityFactory.CreateChapter("3", false),
// _context.Library.Add(new Library()
// } {
// }); Name = "Test Lib 2",
// Type = LibraryType.Book,
// _context.AppUser.Add(new AppUser() Series = new List<Series>()
// { {
// UserName = "majora2007", fablesSeries,
// ReadingLists = new List<ReadingList>(), fables2Series,
// Libraries = new List<Library>() },
// { });
// new Library()
// { await _unitOfWork.CommitAsync();
// Name = "Test LIb",
// Type = LibraryType.Book, var importSummary = await _readingListService.ValidateCblFile(1, cblReadingList);
// Series = new List<Series>()
// { Assert.Equal(CblImportResult.Fail, importSummary.Success);
// fablesSeries, Assert.NotEmpty(importSummary.Results);
// fables2Series }
// },
// }, [Fact]
// }, public async Task ValidateCblFile_ShouldFail_ServerHasNoSeries()
// }); {
// await _unitOfWork.CommitAsync(); await ResetDb();
// var cblReadingList = LoadCblFromPath("Fables.cbl");
// var importSummary = await _readingListService.CreateReadingListFromCbl(1, cblReadingList);
// // Mock up our series
// Assert.Equal(CblImportResult.Partial, importSummary.Success); var fablesSeries = DbFactory.Series("Fablesa");
// Assert.NotEmpty(importSummary.Results); var fables2Series = DbFactory.Series("Fablesa: The Last Castle");
//
// var createdList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(1); fablesSeries.Volumes.Add(new Volume()
// {
// Assert.NotNull(createdList); Number = 1,
// Assert.Equal("Fables", createdList.Title); Name = "2002",
// Chapters = new List<Chapter>()
// Assert.Equal(4, createdList.Items.Count); {
// Assert.Equal(1, createdList.Items.First(item => item.Order == 0).ChapterId); EntityFactory.CreateChapter("1", false),
// Assert.Equal(2, createdList.Items.First(item => item.Order == 1).ChapterId); EntityFactory.CreateChapter("2", false),
// Assert.Equal(3, createdList.Items.First(item => item.Order == 2).ChapterId); EntityFactory.CreateChapter("3", false),
// Assert.Equal(4, createdList.Items.First(item => item.Order == 3).ChapterId);
// } }
// });
// [Fact] fables2Series.Volumes.Add(new Volume()
// public async Task CreateReadingListFromCBL_ShouldCreateList_ButOnlyIncludeSeriesThatUserHasAccessTo() {
// { Number = 1,
// await ResetDb(); Name = "2003",
// var cblReadingList = LoadCblFromPath("Fables.cbl"); Chapters = new List<Chapter>()
// {
// // Mock up our series EntityFactory.CreateChapter("1", false),
// var fablesSeries = DbFactory.Series("Fables"); EntityFactory.CreateChapter("2", false),
// var fables2Series = DbFactory.Series("Fables: The Last Castle"); EntityFactory.CreateChapter("3", false),
//
// fablesSeries.Volumes.Add(new Volume() }
// { });
// Number = 1,
// Name = "2002", _context.AppUser.Add(new AppUser()
// Chapters = new List<Chapter>() {
// { UserName = "majora2007",
// EntityFactory.CreateChapter("1", false), ReadingLists = new List<ReadingList>(),
// EntityFactory.CreateChapter("2", false), Libraries = new List<Library>(),
// EntityFactory.CreateChapter("3", false), });
//
// } _context.Library.Add(new Library()
// }); {
// fables2Series.Volumes.Add(new Volume() Name = "Test Lib 2",
// { Type = LibraryType.Book,
// Number = 1, Series = new List<Series>()
// Name = "2003", {
// Chapters = new List<Chapter>() fablesSeries,
// { fables2Series,
// EntityFactory.CreateChapter("1", false), },
// EntityFactory.CreateChapter("2", false), });
// EntityFactory.CreateChapter("3", false),
// await _unitOfWork.CommitAsync();
// }
// }); var importSummary = await _readingListService.ValidateCblFile(1, cblReadingList);
//
// _context.AppUser.Add(new AppUser() Assert.Equal(CblImportResult.Fail, importSummary.Success);
// { Assert.NotEmpty(importSummary.Results);
// UserName = "majora2007", }
// ReadingLists = new List<ReadingList>(),
// Libraries = new List<Library>() #endregion
// {
// new Library() #region CreateReadingListFromCBL
// {
// Name = "Test LIb", private static CblReadingList LoadCblFromPath(string path)
// Type = LibraryType.Book, {
// Series = new List<Series>() var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ReadingListService/");
// {
// fablesSeries, 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;
// }
// _context.Library.Add(new Library()
// { [Fact]
// Name = "Test Lib 2", public async Task CreateReadingListFromCBL_ShouldCreateList()
// Type = LibraryType.Book, {
// Series = new List<Series>() await ResetDb();
// { var cblReadingList = LoadCblFromPath("Fables.cbl");
// fables2Series,
// }, // Mock up our series
// }); var fablesSeries = DbFactory.Series("Fables");
// var fables2Series = DbFactory.Series("Fables: The Last Castle");
// await _unitOfWork.CommitAsync();
// fablesSeries.Volumes.Add(new Volume()
// var importSummary = await _readingListService.CreateReadingListFromCbl(1, cblReadingList); {
// Number = 1,
// Assert.Equal(CblImportResult.Partial, importSummary.Success); Name = "2002",
// Assert.NotEmpty(importSummary.Results); Chapters = new List<Chapter>()
// {
// var createdList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(1); EntityFactory.CreateChapter("1", false),
// EntityFactory.CreateChapter("2", false),
// Assert.NotNull(createdList); EntityFactory.CreateChapter("3", false),
// Assert.Equal("Fables", createdList.Title);
// }
// Assert.Equal(3, createdList.Items.Count); });
// Assert.Equal(1, createdList.Items.First(item => item.Order == 0).ChapterId); fables2Series.Volumes.Add(new Volume()
// Assert.Equal(2, createdList.Items.First(item => item.Order == 1).ChapterId); {
// Assert.Equal(3, createdList.Items.First(item => item.Order == 2).ChapterId); Number = 1,
// Assert.NotNull(importSummary.Results.SingleOrDefault(r => r.Series == "Fables: The Last Castle" Name = "2003",
// && r.Reason == CblImportReason.SeriesMissing)); Chapters = new List<Chapter>()
// } {
// EntityFactory.CreateChapter("1", false),
// [Fact] EntityFactory.CreateChapter("2", false),
// public async Task CreateReadingListFromCBL_ShouldFail_UserHasAccessToNoSeries() EntityFactory.CreateChapter("3", false),
// {
// await ResetDb(); }
// var cblReadingList = LoadCblFromPath("Fables.cbl"); });
//
// // Mock up our series _context.AppUser.Add(new AppUser()
// var fablesSeries = DbFactory.Series("Fables"); {
// var fables2Series = DbFactory.Series("Fables: The Last Castle"); UserName = "majora2007",
// ReadingLists = new List<ReadingList>(),
// fablesSeries.Volumes.Add(new Volume() Libraries = new List<Library>()
// { {
// Number = 1, new Library()
// Name = "2002", {
// Chapters = new List<Chapter>() Name = "Test LIb",
// { Type = LibraryType.Book,
// EntityFactory.CreateChapter("1", false), Series = new List<Series>()
// EntityFactory.CreateChapter("2", false), {
// EntityFactory.CreateChapter("3", false), fablesSeries,
// fables2Series
// } },
// }); },
// fables2Series.Volumes.Add(new Volume() },
// { });
// Number = 1, await _unitOfWork.CommitAsync();
// Name = "2003",
// Chapters = new List<Chapter>() var importSummary = await _readingListService.CreateReadingListFromCbl(1, cblReadingList);
// {
// EntityFactory.CreateChapter("1", false), Assert.Equal(CblImportResult.Partial, importSummary.Success);
// EntityFactory.CreateChapter("2", false), Assert.NotEmpty(importSummary.Results);
// EntityFactory.CreateChapter("3", false),
// var createdList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(1);
// }
// }); Assert.NotNull(createdList);
// Assert.Equal("Fables", createdList.Title);
// _context.AppUser.Add(new AppUser()
// { Assert.Equal(4, createdList.Items.Count);
// UserName = "majora2007", Assert.Equal(1, createdList.Items.First(item => item.Order == 0).ChapterId);
// ReadingLists = new List<ReadingList>(), Assert.Equal(2, createdList.Items.First(item => item.Order == 1).ChapterId);
// Libraries = new List<Library>(), Assert.Equal(3, createdList.Items.First(item => item.Order == 2).ChapterId);
// }); Assert.Equal(4, createdList.Items.First(item => item.Order == 3).ChapterId);
// }
// _context.Library.Add(new Library()
// { [Fact]
// Name = "Test Lib 2", public async Task CreateReadingListFromCBL_ShouldCreateList_ButOnlyIncludeSeriesThatUserHasAccessTo()
// Type = LibraryType.Book, {
// Series = new List<Series>() await ResetDb();
// { var cblReadingList = LoadCblFromPath("Fables.cbl");
// fablesSeries,
// fables2Series, // Mock up our series
// }, var fablesSeries = DbFactory.Series("Fables");
// }); var fables2Series = DbFactory.Series("Fables: The Last Castle");
//
// await _unitOfWork.CommitAsync(); fablesSeries.Volumes.Add(new Volume()
// {
// var importSummary = await _readingListService.CreateReadingListFromCbl(1, cblReadingList); Number = 1,
// Name = "2002",
// Assert.Equal(CblImportResult.Fail, importSummary.Success); Chapters = new List<Chapter>()
// Assert.NotEmpty(importSummary.Results); {
// EntityFactory.CreateChapter("1", false),
// var createdList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(1); EntityFactory.CreateChapter("2", false),
// EntityFactory.CreateChapter("3", false),
// Assert.Null(createdList);
// } }
// });
// fables2Series.Volumes.Add(new Volume()
// [Fact] {
// public async Task CreateReadingListFromCBL_ShouldUpdateAnExistingList() Number = 1,
// { Name = "2003",
// await ResetDb(); Chapters = new List<Chapter>()
// var cblReadingList = LoadCblFromPath("Fables.cbl"); {
// EntityFactory.CreateChapter("1", false),
// // Mock up our series EntityFactory.CreateChapter("2", false),
// var fablesSeries = DbFactory.Series("Fables"); EntityFactory.CreateChapter("3", false),
// var fables2Series = DbFactory.Series("Fables: The Last Castle");
// }
// fablesSeries.Volumes.Add(new Volume() });
// {
// Number = 1, _context.AppUser.Add(new AppUser()
// Name = "2002", {
// Chapters = new List<Chapter>() UserName = "majora2007",
// { ReadingLists = new List<ReadingList>(),
// EntityFactory.CreateChapter("1", false), Libraries = new List<Library>()
// EntityFactory.CreateChapter("2", false), {
// EntityFactory.CreateChapter("3", false), new Library()
// {
// } Name = "Test LIb",
// }); Type = LibraryType.Book,
// fables2Series.Volumes.Add(new Volume() Series = new List<Series>()
// { {
// Number = 1, fablesSeries,
// Name = "2003", },
// Chapters = new List<Chapter>() },
// { },
// EntityFactory.CreateChapter("1", false), });
// EntityFactory.CreateChapter("2", false),
// EntityFactory.CreateChapter("3", false), _context.Library.Add(new Library()
// {
// } Name = "Test Lib 2",
// }); Type = LibraryType.Book,
// Series = new List<Series>()
// _context.AppUser.Add(new AppUser() {
// { fables2Series,
// UserName = "majora2007", },
// ReadingLists = new List<ReadingList>(), });
// Libraries = new List<Library>()
// { await _unitOfWork.CommitAsync();
// new Library()
// { var importSummary = await _readingListService.CreateReadingListFromCbl(1, cblReadingList);
// Name = "Test LIb",
// Type = LibraryType.Book, Assert.Equal(CblImportResult.Partial, importSummary.Success);
// Series = new List<Series>() Assert.NotEmpty(importSummary.Results);
// {
// fablesSeries, var createdList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(1);
// fables2Series
// }, 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);
// await _unitOfWork.CommitAsync(); Assert.Equal(2, createdList.Items.First(item => item.Order == 1).ChapterId);
// Assert.Equal(3, createdList.Items.First(item => item.Order == 2).ChapterId);
// // Create a reading list named Fables and add 2 chapters to it Assert.NotNull(importSummary.Results.SingleOrDefault(r => r.Series == "Fables: The Last Castle"
// var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.ReadingLists); && r.Reason == CblImportReason.SeriesMissing));
// var readingList = await _readingListService.CreateReadingListForUser(user, "Fables"); }
// Assert.True(await _readingListService.AddChaptersToReadingList(1, new List<int>() {1, 3}, readingList));
// Assert.Equal(2, readingList.Items.Count); [Fact]
// public async Task CreateReadingListFromCBL_ShouldUpdateAnExistingList()
// // Attempt to import a Cbl with same reading list name {
// var importSummary = await _readingListService.CreateReadingListFromCbl(1, cblReadingList); await ResetDb();
// var cblReadingList = LoadCblFromPath("Fables.cbl");
// Assert.Equal(CblImportResult.Partial, importSummary.Success);
// Assert.NotEmpty(importSummary.Results); // Mock up our series
// var fablesSeries = DbFactory.Series("Fables");
// var createdList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(1); var fables2Series = DbFactory.Series("Fables: The Last Castle");
//
// Assert.NotNull(createdList); fablesSeries.Volumes.Add(new Volume()
// Assert.Equal("Fables", createdList.Title); {
// Number = 1,
// Assert.Equal(4, createdList.Items.Count); Name = "2002",
// Assert.Equal(4, importSummary.SuccessfulInserts.Count); Chapters = new List<Chapter>()
// {
// Assert.Equal(1, createdList.Items.First(item => item.Order == 0).ChapterId); EntityFactory.CreateChapter("1", false),
// Assert.Equal(3, createdList.Items.First(item => item.Order == 1).ChapterId); // we inserted 3 first EntityFactory.CreateChapter("2", false),
// Assert.Equal(2, createdList.Items.First(item => item.Order == 2).ChapterId); EntityFactory.CreateChapter("3", false),
// Assert.Equal(4, createdList.Items.First(item => item.Order == 3).ChapterId);
// } }
// #endregion });
// fables2Series.Volumes.Add(new Volume()
{
Number = 1,
Name = "2003",
Chapters = new List<Chapter>()
{
EntityFactory.CreateChapter("1", false),
EntityFactory.CreateChapter("2", false),
EntityFactory.CreateChapter("3", false),
}
});
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007",
ReadingLists = new List<ReadingList>(),
Libraries = new List<Library>()
{
new Library()
{
Name = "Test LIb",
Type = LibraryType.Book,
Series = new List<Series>()
{
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<int>() {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
} }

View File

@ -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;
/// <summary>
/// Responsible for the CBL import flow
/// </summary>
public class CblController : BaseApiController
{
private readonly IReadingListService _readingListService;
private readonly IDirectoryService _directoryService;
public CblController(IReadingListService readingListService, IDirectoryService directoryService)
{
_readingListService = readingListService;
_directoryService = directoryService;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="file">FormBody with parameter name of cbl</param>
/// <returns></returns>
[HttpPost("validate")]
public async Task<ActionResult<CblImportSummaryDto>> 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);
}
/// <summary>
/// Performs the actual import (assuming dryRun = false)
/// </summary>
/// <param name="file">FormBody with parameter name of cbl</param>
/// <param name="dryRun">If true, will only emulate the import but not perform. This should be done to preview what will happen</param>
/// <returns></returns>
[HttpPost("import")]
public async Task<ActionResult<CblImportSummaryDto>> 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<CblReadingList> 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);
}
}

View File

@ -484,22 +484,4 @@ public class ReadingListController : BaseApiController
if (string.IsNullOrEmpty(name)) return true; if (string.IsNullOrEmpty(name)) return true;
return Ok(await _unitOfWork.ReadingListRepository.ReadingListExists(name)); return Ok(await _unitOfWork.ReadingListRepository.ReadingListExists(name));
} }
// [HttpPost("import-cbl")]
// public async Task<ActionResult<CblImportSummaryDto>> 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));
// }
} }

View File

@ -1,8 +1,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel; using System.ComponentModel;
using API.DTOs.ReadingLists.CBL;
namespace API.DTOs.ReadingLists; namespace API.DTOs.ReadingLists.CBL;
public enum CblImportResult { public enum CblImportResult {
/// <summary> /// <summary>
@ -64,10 +63,19 @@ public enum CblImportReason
/// </summary> /// </summary>
[Description("All Chapters Missing")] [Description("All Chapters Missing")]
AllChapterMissing = 7, AllChapterMissing = 7,
/// <summary>
/// The Chapter was imported
/// </summary>
[Description("Success")]
Success = 8,
} }
public class CblBookResult public class CblBookResult
{ {
/// <summary>
/// Order in the CBL
/// </summary>
public int Order { get; set; }
public string Series { get; set; } public string Series { get; set; }
public string Volume { get; set; } public string Volume { get; set; }
public string Number { get; set; } public string Number { get; set; }
@ -95,10 +103,5 @@ public class CblImportSummaryDto
public ICollection<CblBookResult> Results { get; set; } public ICollection<CblBookResult> Results { get; set; }
public CblImportResult Success { get; set; } public CblImportResult Success { get; set; }
public ICollection<CblBookResult> SuccessfulInserts { get; set; } public ICollection<CblBookResult> SuccessfulInserts { get; set; }
/// <summary>
/// A list of Series that are within the CBL but map to multiple libraries within Kavita
/// </summary>
public IList<SeriesDto> Conflicts { get; set; }
public IList<CblConflictQuestion> Conflicts2 { get; set; }
} }

View File

@ -117,7 +117,7 @@ public interface ISeriesRepository
Task<bool> IsSeriesInWantToRead(int userId, int seriesId); Task<bool> IsSeriesInWantToRead(int userId, int seriesId);
Task<Series> GetSeriesByFolderPath(string folder, SeriesIncludes includes = SeriesIncludes.None); Task<Series> GetSeriesByFolderPath(string folder, SeriesIncludes includes = SeriesIncludes.None);
Task<IEnumerable<Series>> GetAllSeriesByNameAsync(IEnumerable<string> normalizedNames, Task<IEnumerable<Series>> GetAllSeriesByNameAsync(IList<string> normalizedNames,
int userId, SeriesIncludes includes = SeriesIncludes.None); int userId, SeriesIncludes includes = SeriesIncludes.None);
Task<IEnumerable<SeriesDto>> GetAllSeriesDtosByNameAsync(IEnumerable<string> normalizedNames, Task<IEnumerable<SeriesDto>> GetAllSeriesDtosByNameAsync(IEnumerable<string> normalizedNames,
int userId, SeriesIncludes includes = SeriesIncludes.None); int userId, SeriesIncludes includes = SeriesIncludes.None);
@ -1213,14 +1213,14 @@ public class SeriesRepository : ISeriesRepository
.SingleOrDefaultAsync(); .SingleOrDefaultAsync();
} }
public async Task<IEnumerable<Series>> GetAllSeriesByNameAsync(IEnumerable<string> normalizedNames, public async Task<IEnumerable<Series>> GetAllSeriesByNameAsync(IList<string> normalizedNames,
int userId, SeriesIncludes includes = SeriesIncludes.None) int userId, SeriesIncludes includes = SeriesIncludes.None)
{ {
var libraryIds = _context.Library.GetUserLibraries(userId); var libraryIds = _context.Library.GetUserLibraries(userId);
var userRating = await _context.AppUser.GetUserAgeRestriction(userId); var userRating = await _context.AppUser.GetUserAgeRestriction(userId);
return await _context.Series 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)) .Where(s => libraryIds.Contains(s.LibraryId))
.RestrictAgainstAgeRestriction(userRating) .RestrictAgainstAgeRestriction(userRating)
.Includes(includes) .Includes(includes)

View File

@ -355,13 +355,20 @@ public class ReadingListService : IReadingListService
CblName = cblReading.Name, CblName = cblReading.Name,
Success = CblImportResult.Success, Success = CblImportResult.Success,
Results = new List<CblBookResult>(), Results = new List<CblBookResult>(),
SuccessfulInserts = new List<CblBookResult>(), SuccessfulInserts = new List<CblBookResult>()
Conflicts = new List<SeriesDto>(),
Conflicts2 = new List<CblConflictQuestion>()
}; };
if (IsCblEmpty(cblReading, importSummary, out var readingListFromCbl)) return readingListFromCbl; 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 = var userSeries =
(await _unitOfWork.SeriesRepository.GetAllSeriesByNameAsync(uniqueSeries, userId, SeriesIncludes.Chapters)).ToList(); (await _unitOfWork.SeriesRepository.GetAllSeriesByNameAsync(uniqueSeries, userId, SeriesIncludes.Chapters)).ToList();
if (!userSeries.Any()) if (!userSeries.Any())
@ -421,10 +428,11 @@ public class ReadingListService : IReadingListService
SuccessfulInserts = new List<CblBookResult>() SuccessfulInserts = new List<CblBookResult>()
}; };
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 = var userSeries =
(await _unitOfWork.SeriesRepository.GetAllSeriesByNameAsync(uniqueSeries, userId, SeriesIncludes.Chapters)).ToList(); (await _unitOfWork.SeriesRepository.GetAllSeriesByNameAsync(uniqueSeries, userId, SeriesIncludes.Chapters)).ToList();
var allSeries = userSeries.ToDictionary(s => Tasks.Scanner.Parser.Parser.Normalize(s.Name)); 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); var readingListNameNormalized = Tasks.Scanner.Parser.Parser.Normalize(cblReading.Name);
// Get all the user's reading lists // 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 ))) foreach (var (book, i) in cblReading.Books.Book.Select((value, i) => ( value, i )))
{ {
var normalizedSeries = Tasks.Scanner.Parser.Parser.Normalize(book.Series); 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) importSummary.Results.Add(new CblBookResult(book)
{ {
Reason = CblImportReason.SeriesMissing Reason = CblImportReason.SeriesMissing,
Order = i
}); });
continue; continue;
} }
// Prioritize lookup by Volume then Chapter, but allow fallback to just Chapter // 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) if (matchingVolume == null)
{ {
importSummary.Results.Add(new CblBookResult(book) importSummary.Results.Add(new CblBookResult(book)
{ {
Reason = CblImportReason.VolumeMissing Reason = CblImportReason.VolumeMissing,
Order = i
}); });
continue; 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) if (chapter == null)
{ {
importSummary.Results.Add(new CblBookResult(book) importSummary.Results.Add(new CblBookResult(book)
{ {
Reason = CblImportReason.ChapterMissing Reason = CblImportReason.ChapterMissing,
Order = i
}); });
continue; continue;
} }
// See if a matching item already exists // See if a matching item already exists
ExistsOrAddReadingListItem(readingList, bookSeries.Id, matchingVolume.Id, chapter.Id); 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) if (importSummary.SuccessfulInserts.Count != cblReading.Books.Book.Count || importSummary.Results.Count > 0)
@ -491,9 +513,14 @@ public class ReadingListService : IReadingListService
importSummary.Success = CblImportResult.Partial; importSummary.Success = CblImportResult.Partial;
} }
if (importSummary.SuccessfulInserts.Count == 0 && importSummary.Results.Count == cblReading.Books.Book.Count)
{
importSummary.Success = CblImportResult.Fail;
}
await CalculateReadingListAgeRating(readingList); await CalculateReadingListAgeRating(readingList);
if (!dryRun) return importSummary; if (dryRun) return importSummary;
if (!_unitOfWork.HasChanges()) return importSummary; if (!_unitOfWork.HasChanges()) return importSummary;
await _unitOfWork.CommitAsync(); await _unitOfWork.CommitAsync();

View File

@ -1,6 +1,7 @@
import { CblImportReason } from "./cbl-import-reason.enum"; import { CblImportReason } from "./cbl-import-reason.enum";
export interface CblBookResult { export interface CblBookResult {
order: number;
series: string; series: string;
volume: string; volume: string;
number: string; number: string;

View File

@ -7,4 +7,5 @@ export enum CblImportReason {
EmptyFile = 5, EmptyFile = 5,
SeriesCollision = 6, SeriesCollision = 6,
AllChapterMissing = 7, AllChapterMissing = 7,
Success = 8
} }

View File

@ -12,7 +12,4 @@ export interface CblImportSummary {
results: Array<CblBookResult>; results: Array<CblBookResult>;
success: CblImportResult; success: CblImportResult;
successfulInserts: Array<CblBookResult>; successfulInserts: Array<CblBookResult>;
conflicts: Array<Series>;
conflicts2: Array<CblConflictQuestion>;
} }

View File

@ -95,7 +95,11 @@ export class ReadingListService {
return this.httpClient.get<boolean>(this.baseUrl + 'readinglist/name-exists?name=' + name); return this.httpClient.get<boolean>(this.baseUrl + 'readinglist/name-exists?name=' + name);
} }
validateCbl(form: FormData) {
return this.httpClient.post<CblImportSummary>(this.baseUrl + 'cbl/validate', form);
}
importCbl(form: FormData) { importCbl(form: FormData) {
return this.httpClient.post<CblImportSummary>(this.baseUrl + 'readinglist/import-cbl', form); return this.httpClient.post<CblImportSummary>(this.baseUrl + 'cbl/import', form);
} }
} }

View File

@ -198,10 +198,10 @@ $action-bar-height: 38px;
height: calc((var(--vh) * 100) - calc($action-bar-height)); // * 2 height: calc((var(--vh) * 100) - calc($action-bar-height)); // * 2
} }
&.immersive { // &.immersive {
// Note: I removed this for bug: https://github.com/Kareadita/Kavita/issues/1726 // // Note: I removed this for bug: https://github.com/Kareadita/Kavita/issues/1726
//height: calc((var(--vh, 1vh) * 100) - $action-bar-height); // //height: calc((var(--vh, 1vh) * 100) - $action-bar-height);
} // }
a, :link { a, :link {
color: var(--brtheme-link-text-color); color: var(--brtheme-link-text-color);

View File

@ -28,7 +28,7 @@ export class ReadingListsComponent implements OnInit {
isAdmin: boolean = false; isAdmin: boolean = false;
jumpbarKeys: Array<JumpKey> = []; jumpbarKeys: Array<JumpKey> = [];
actions: {[key: number]: Array<ActionItem<ReadingList>>} = {}; actions: {[key: number]: Array<ActionItem<ReadingList>>} = {};
globalActions: Array<ActionItem<any>> = []; //[{action: Action.Import, title: 'Import CBL', children: [], requiresAdmin: true, callback: this.importCbl.bind(this)}] globalActions: Array<ActionItem<any>> = [{action: Action.Import, title: 'Import CBL', children: [], requiresAdmin: true, callback: this.importCbl.bind(this)}];
constructor(private readingListService: ReadingListService, public imageService: ImageService, private actionFactoryService: ActionFactoryService, constructor(private readingListService: ReadingListService, public imageService: ImageService, private actionFactoryService: ActionFactoryService,
private accountService: AccountService, private toastr: ToastrService, private router: Router, private actionService: ActionService, private accountService: AccountService, private toastr: ToastrService, private router: Router, private actionService: ActionService,
@ -63,6 +63,7 @@ export class ReadingListsComponent implements OnInit {
importCbl() { importCbl() {
const ref = this.ngbModal.open(ImportCblModalComponent, {size: 'xl'}); const ref = this.ngbModal.open(ImportCblModalComponent, {size: 'xl'});
ref.closed.subscribe(result => this.loadPage()); ref.closed.subscribe(result => this.loadPage());
ref.dismissed.subscribe(_ => this.loadPage());
} }
handleReadingListActionCallback(action: ActionItem<ReadingList>, readingList: ReadingList) { handleReadingListActionCallback(action: ActionItem<ReadingList>, readingList: ReadingList) {

View File

@ -0,0 +1,11 @@
<div class="card card-timeline px-2 mb-3 border-none">
<ul class="bs4-order-tracking">
<ng-container *ngFor="let step of steps">
<li class="step" [ngClass]="{'active': step.index === currentStep}">
<div><i class="{{step.icon}}"></i></div>
{{step.title}}
</li>
</ng-container>
</ul>
</div>

View File

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

View File

@ -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<TimelineStep> = [];
@Input() currentStep: number = 0;
constructor(private readonly cdRef: ChangeDetectorRef) {}
}

View File

@ -1,47 +1,62 @@
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">CBL Import: {{currentStep.title}}</h4> <h4 class="modal-title" id="modal-basic-title">CBL Import</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button> <button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div> </div>
<div class="modal-body scrollable-modal {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? '' : 'd-flex'}}"> <div class="modal-body scrollable-modal">
<div class="row g-0" style="min-width: 135px;">
<div class="row g-0" *ngIf="currentStep.index === 0"> <app-step-tracker [steps]="steps" [currentStep]="currentStepIndex"></app-step-tracker>
<p>Import a .cbl file as a reading list</p>
<form [formGroup]="uploadForm" enctype="multipart/form-data">
<file-upload formControlName="files"></file-upload>
</form>
</div> </div>
<ng-container *ngIf="currentStep.index === 1"> <!-- This is going to need to have a fixed height with a scrollbar-->
<div class="row g-0"> <div>
<ng-container *ngIf="validateSummary; else noValidateIssues"> <div class="row g-0" *ngIf="currentStepIndex === Step.Import">
<h5>There are issues with the CBL that will prevent an import. Correct these issues then try again.</h5> <p>To get started, import a .cbl file. Kavita will perform multiple checks before importing. Some steps will block moving forward due to issues with the file.</p>
<ol class="list-group list-group-flush" > <form [formGroup]="uploadForm" enctype="multipart/form-data">
<li class="list-group-item no-hover" *ngFor="let result of validateSummary.results"> <file-upload formControlName="files"></file-upload>
{{result | cblConflictReason}} </form>
</li>
</ol>
</ng-container>
<ng-template #noValidateIssues>No issues found with CBL, press next.</ng-template>
</div> </div>
</ng-container>
<ng-container *ngIf="currentStep.index === 2 && dryRunSummary"> <ng-container *ngIf="currentStepIndex === Step.Validate">
<div class="row g-0"> <div class="row g-0">
<h5>This is a dry run and shows what will happen if you press Next</h5> <ng-container *ngIf="validateSummary">
<h6>The import was a {{dryRunSummary.success}}</h6> <ng-container *ngIf="validateSummary.results.length > 0; else noValidateIssues">
<ul class="list-group list-group-flush" *ngIf="dryRunSummary"> <h5>There are issues with the CBL that will prevent an import. Correct these issues then try again.</h5>
<li class="list-group-item no-hover" *ngFor="let result of dryRunSummary.results"> <ol class="list-group list-group-numbered list-group-flush" >
{{result | cblConflictReason}} <li class="list-group-item no-hover" *ngFor="let result of validateSummary.results"
[innerHTML]="result | cblConflictReason | safeHtml">
</li>
</ol>
</ng-container>
<ng-template #noValidateIssues>
No issues found with CBL, press next.
</ng-template>
</ng-container>
</div>
</ng-container>
<ng-container *ngIf="currentStepIndex === Step.DryRun && dryRunSummary">
<div class="row g-0">
<h5>This is a dry run and shows what will happen if you press Next</h5>
<h6>The import was a {{dryRunSummary.success | cblImportResult}}</h6>
<ul class="list-group list-group-flush">
<li class="list-group-item no-hover" *ngFor="let result of dryRunResults"
innerHTML="{{result.order + 1}}. {{result | cblConflictReason | safeHtml}}"></li>
</ul>
</div>
</ng-container>
<ng-container *ngIf="currentStepIndex === Step.Finalize && finalizeSummary && dryRunSummary">
<div class="row g-0">
<h5>{{finalizeSummary.success | cblImportResult }} on {{dryRunSummary.cblName}} Import</h5>
<ul class="list-group list-group-flush">
<li class="list-group-item no-hover" *ngFor="let result of finalizeResults"
innerHTML="{{result.order + 1}}. {{result | cblConflictReason | safeHtml}}">
</li> </li>
</ul> </ul>
<ul class="list-group list-group-flush" *ngIf="dryRunSummary"> </div>
<li class="list-group-item no-hover" *ngFor="let result of dryRunSummary.successfulInserts"> </ng-container>
{{result | cblConflictReason}} </div>
</li>
</ul>
</div>
</ng-container>
@ -49,7 +64,8 @@
<div class="modal-footer"> <div class="modal-footer">
<a class="btn btn-icon" href="https://wiki.kavitareader.com/en/guides/get-started-using-your-library/reading-lists#creating-a-reading-list-via-cbl" target="_blank" rel="noopener noreferrer">Help</a> <a class="btn btn-icon" href="https://wiki.kavitareader.com/en/guides/get-started-using-your-library/reading-lists#creating-a-reading-list-via-cbl" target="_blank" rel="noopener noreferrer">Help</a>
<button type="button" class="btn btn-secondary" (click)="close()">Close</button> <button type="button" class="btn btn-secondary" (click)="close()">Close</button>
<button type="button" class="btn btn-primary" (click)="nextStep()" [disabled]="!canMoveToNextStep()">Next</button> <button type="button" class="btn btn-primary" (click)="prevStep()" [disabled]="!canMoveToPrevStep()">Prev</button>
<button type="button" class="btn btn-primary" (click)="nextStep()" [disabled]="!canMoveToNextStep()">{{NextButtonLabel}}</button>
</div> </div>

View File

@ -1,3 +1,41 @@
.file-input { .file-input {
display: none; display: none;
} }
::ng-deep .file-info {
width: 83%;
float: left;
}
::ng-deep .file-buttons {
float: right;
}
file-upload {
background: none;
height: auto;
}
::ng-deep .upload-input {
color: var(--input-text-color) !important;
}
::ng-deep file-upload-list-item {
color: var(--input-text-color) !important;
}
::ng-deep .remove-btn {
background: #C0392B;
border-radius: 3px;
color: var(--input-text-color) !important;
font-weight: bold;
padding: 3px 5px;
}
::ng-deep .reading-list-success--item {
color: var(--primary-color);
}
::ng-deep .reading-list-fail--item {
color: var(--error-color);
}

View File

@ -2,9 +2,13 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, View
import { FormControl, FormGroup } from '@angular/forms'; import { FormControl, FormGroup } from '@angular/forms';
import { FileUploadValidators } from '@iplab/ngx-file-upload'; import { FileUploadValidators } from '@iplab/ngx-file-upload';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service'; import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service';
import { CblBookResult } from 'src/app/_models/reading-list/cbl/cbl-book-result';
import { CblImportResult } from 'src/app/_models/reading-list/cbl/cbl-import-result.enum';
import { CblImportSummary } from 'src/app/_models/reading-list/cbl/cbl-import-summary'; import { CblImportSummary } from 'src/app/_models/reading-list/cbl/cbl-import-summary';
import { ReadingListService } from 'src/app/_services/reading-list.service'; import { ReadingListService } from 'src/app/_services/reading-list.service';
import { TimelineStep } from '../../_components/step-tracker/step-tracker.component';
enum Step { enum Step {
Import = 0, Import = 0,
@ -35,59 +39,103 @@ export class ImportCblModalComponent {
importSummaries: Array<CblImportSummary> = []; importSummaries: Array<CblImportSummary> = [];
validateSummary: CblImportSummary | undefined; validateSummary: CblImportSummary | undefined;
dryRunSummary: CblImportSummary | undefined; dryRunSummary: CblImportSummary | undefined;
dryRunResults: Array<CblBookResult> = [];
finalizeSummary: CblImportSummary | undefined;
finalizeResults: Array<CblBookResult> = [];
steps = [ isLoading: boolean = false;
{title: 'Import CBL', index: Step.Import},
{title: 'Validate File', index: Step.Validate}, steps: Array<TimelineStep> = [
{title: 'Dry Run', index: Step.DryRun}, {title: 'Import CBL', index: Step.Import, active: true, icon: 'fa-solid fa-file-arrow-up'},
{title: 'Final Import', index: Step.Finalize}, {title: 'Validate File', index: Step.Validate, active: false, icon: 'fa-solid fa-spell-check'},
{title: 'Dry Run', index: Step.DryRun, active: false, icon: 'fa-solid fa-gears'},
{title: 'Final Import', index: Step.Finalize, active: false, icon: 'fa-solid fa-floppy-disk'},
]; ];
currentStep = this.steps[0]; currentStepIndex = this.steps[0].index;
get Breakpoint() { return Breakpoint; } get Breakpoint() { return Breakpoint; }
get Step() { return Step; } get Step() { return Step; }
get NextButtonLabel() {
switch(this.currentStepIndex) {
case Step.DryRun:
return 'Import';
case Step.Finalize:
return 'Restart'
default:
return 'Next';
}
}
constructor(private ngModal: NgbActiveModal, private readingListService: ReadingListService, constructor(private ngModal: NgbActiveModal, private readingListService: ReadingListService,
public utilityService: UtilityService, private readonly cdRef: ChangeDetectorRef) {} public utilityService: UtilityService, private readonly cdRef: ChangeDetectorRef,
private toastr: ToastrService) {}
close() { close() {
this.ngModal.close(); this.ngModal.close();
} }
nextStep() { nextStep() {
if (this.currentStepIndex === Step.Import && !this.isFileSelected()) return;
if (this.currentStepIndex === Step.Validate && this.validateSummary && this.validateSummary.results.length > 0) return;
if (this.currentStep.index >= Step.Finalize) return; this.isLoading = true;
if (this.currentStep.index === Step.Import && !this.isFileSelected()) return; switch (this.currentStepIndex) {
if (this.currentStep.index === Step.Validate && this.validateSummary && this.validateSummary.results.length > 0) return;
switch (this.currentStep.index) {
case Step.Import: case Step.Import:
this.importFile(); this.importFile();
break; break;
case Step.Validate: case Step.Validate:
this.import(true);
break; break;
case Step.DryRun: case Step.DryRun:
this.import(false);
break; break;
case Step.Finalize: case Step.Finalize:
// Clear the models and allow user to do another import // Clear the models and allow user to do another import
this.uploadForm.get('files')?.setValue(undefined);
this.currentStepIndex = Step.Import;
this.validateSummary = undefined;
this.dryRunSummary = undefined;
this.dryRunResults = [];
this.finalizeSummary = undefined;
this.finalizeResults = [];
this.isLoading = false;
this.cdRef.markForCheck();
break; break;
} }
} }
prevStep() {
if (this.currentStepIndex === Step.Import) return;
this.currentStepIndex--;
}
canMoveToNextStep() { canMoveToNextStep() {
switch (this.currentStep.index) { switch (this.currentStepIndex) {
case Step.Import: case Step.Import:
return this.isFileSelected(); return this.isFileSelected();
case Step.Validate: case Step.Validate:
return this.validateSummary && this.validateSummary.results.length > 0; return this.validateSummary && this.validateSummary.results.length === 0;
case Step.DryRun: case Step.DryRun:
return true; return this.dryRunSummary?.success != CblImportResult.Fail;
case Step.Finalize: case Step.Finalize:
return true; return true;
default:
return false;
}
}
canMoveToPrevStep() {
switch (this.currentStepIndex) {
case Step.Import:
return false;
default:
return true;
} }
} }
isFileSelected() { isFileSelected() {
const files = this.uploadForm.get('files')?.value; const files = this.uploadForm.get('files')?.value;
if (files) return files.length > 0; if (files) return files.length > 0;
@ -98,42 +146,42 @@ export class ImportCblModalComponent {
const files = this.uploadForm.get('files')?.value; const files = this.uploadForm.get('files')?.value;
if (!files) return; if (!files) return;
this.cdRef.markForCheck();
const formData = new FormData(); const formData = new FormData();
formData.append('cbl', files[0]); formData.append('cbl', files[0]);
formData.append('dryRun', (this.currentStep.index !== Step.Finalize) + ''); this.readingListService.validateCbl(formData).subscribe(res => {
this.readingListService.importCbl(formData).subscribe(res => { if (this.currentStepIndex === Step.Import) {
console.log('Result: ', res);
if (this.currentStep.index === Step.Import) {
this.validateSummary = res; this.validateSummary = res;
} }
if (this.currentStep.index === Step.DryRun) {
this.dryRunSummary = res;
}
this.importSummaries.push(res); this.importSummaries.push(res);
this.currentStep.index++; this.currentStepIndex++;
this.isLoading = false;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
}); });
} }
// onFileSelected(event: any) { import(dryRun: boolean = false) {
// console.log('event: ', event); const files = this.uploadForm.get('files')?.value;
// if (!(event.target as HTMLInputElement).files === null || (event.target as HTMLInputElement).files?.length === 0) return; if (!files) return;
// const file = (event.target as HTMLInputElement).files![0]; const formData = new FormData();
formData.append('cbl', files[0]);
formData.append('dryRun', dryRun + '');
this.readingListService.importCbl(formData).subscribe(res => {
// Our step when calling is always one behind
if (dryRun) {
this.dryRunSummary = res;
this.dryRunResults = [...res.successfulInserts, ...res.results].sort((a, b) => a.order - b.order);
} else {
this.finalizeSummary = res;
this.finalizeResults = [...res.successfulInserts, ...res.results].sort((a, b) => a.order - b.order);
this.toastr.success('Reading List imported');
}
// if (file) { this.isLoading = false;
this.currentStepIndex++;
// //this.fileName = file.name; this.cdRef.markForCheck();
});
// const formData = new FormData(); }
// formData.append("cbl", file);
// this.readingListService.importCbl(formData).subscribe(res => {
// this.importSummaries.push(res);
// this.cdRef.markForCheck();
// });
// this.fileUpload.value = '';
// }
// }
} }

View File

@ -2,31 +2,34 @@ import { Pipe, PipeTransform } from '@angular/core';
import { CblBookResult } from 'src/app/_models/reading-list/cbl/cbl-book-result'; import { CblBookResult } from 'src/app/_models/reading-list/cbl/cbl-book-result';
import { CblImportReason } from 'src/app/_models/reading-list/cbl/cbl-import-reason.enum'; import { CblImportReason } from 'src/app/_models/reading-list/cbl/cbl-import-reason.enum';
const failIcon = '<i aria-hidden="true" class="reading-list-fail--item fa-solid fa-circle-xmark me-1"></i>';
const successIcon = '<i aria-hidden="true" class="reading-list-success--item fa-solid fa-circle-check me-1"></i>';
@Pipe({ @Pipe({
name: 'cblConflictReason' name: 'cblConflictReason'
}) })
export class CblConflictReasonPipe implements PipeTransform { export class CblConflictReasonPipe implements PipeTransform {
transform(result: CblBookResult): string { transform(result: CblBookResult): string {
if (result.reason === undefined)
return result.series + ' volume ' + result.volume + ' number ' + result.number + ' mapped successfully';
switch (result.reason) { switch (result.reason) {
case CblImportReason.AllSeriesMissing: case CblImportReason.AllSeriesMissing:
return 'Your account is missing access to all series in the list or Kavita does not have anything present in the list.'; return failIcon + 'Your account is missing access to all series in the list or Kavita does not have anything present in the list.';
case CblImportReason.ChapterMissing: case CblImportReason.ChapterMissing:
return 'Chapter ' + result.number + ' is missing from Kavita. This item will be skipped.'; return failIcon + result.series + ': ' + 'Chapter ' + result.number + ' is missing from Kavita. This item will be skipped.';
case CblImportReason.EmptyFile: case CblImportReason.EmptyFile:
return 'The Cbl file is empty, nothing to be done.'; return failIcon + 'The cbl file is empty, nothing to be done.';
case CblImportReason.NameConflict: case CblImportReason.NameConflict:
return 'A reading list already exists on your account that matches the Cbl file.'; return failIcon + 'A reading list already exists on your account that matches the cbl file.';
case CblImportReason.SeriesCollision: case CblImportReason.SeriesCollision:
return 'The series, ' + result.series + ', collides with another series of the same name in another library.'; return failIcon + 'The series, ' + result.series + ', collides with another series of the same name in another library.';
case CblImportReason.SeriesMissing: case CblImportReason.SeriesMissing:
return 'The series, ' + result.series + ', is missing from Kavita or your account does not have permission. All items with this series will be skipped from import.'; return failIcon + 'The series, ' + result.series + ', is missing from Kavita or your account does not have permission. All items with this series will be skipped from import.';
case CblImportReason.VolumeMissing: case CblImportReason.VolumeMissing:
return 'Volume ' + result.volume + ' is missing from Kavita. All items with this volume number will be skipped.'; return failIcon + result.series + ': ' + 'Volume ' + result.volume + ' is missing from Kavita. All items with this volume number will be skipped.';
case CblImportReason.AllChapterMissing: case CblImportReason.AllChapterMissing:
return 'All chapters cannot be matched to Chapters in Kavita.'; return failIcon + 'All chapters cannot be matched to Chapters in Kavita.';
case CblImportReason.Success:
return successIcon + result.series + ' volume ' + result.volume + ' number ' + result.number + ' mapped successfully';
} }
} }

View File

@ -0,0 +1,19 @@
import { Pipe, PipeTransform } from '@angular/core';
import { CblImportResult } from 'src/app/_models/reading-list/cbl/cbl-import-result.enum';
@Pipe({
name: 'cblImportResult'
})
export class CblImportResultPipe implements PipeTransform {
transform(result: CblImportResult): string {
switch (result) {
case CblImportResult.Success:
return 'Success';
case CblImportResult.Partial:
return 'Partial Success';
case CblImportResult.Fail:
return 'Failure';
}
}
}

View File

@ -16,7 +16,8 @@ import { ReadingListsComponent } from './_components/reading-lists/reading-lists
import { ImportCblModalComponent } from './_modals/import-cbl-modal/import-cbl-modal.component'; import { ImportCblModalComponent } from './_modals/import-cbl-modal/import-cbl-modal.component';
import { FileUploadModule } from '@iplab/ngx-file-upload'; import { FileUploadModule } from '@iplab/ngx-file-upload';
import { CblConflictReasonPipe } from './_pipes/cbl-conflict-reason.pipe'; import { CblConflictReasonPipe } from './_pipes/cbl-conflict-reason.pipe';
import { StepTrackerComponent } from './_components/step-tracker/step-tracker.component';
import { CblImportResultPipe } from './_pipes/cbl-import-result.pipe';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -28,6 +29,8 @@ import { CblConflictReasonPipe } from './_pipes/cbl-conflict-reason.pipe';
ReadingListItemComponent, ReadingListItemComponent,
ImportCblModalComponent, ImportCblModalComponent,
CblConflictReasonPipe, CblConflictReasonPipe,
StepTrackerComponent,
CblImportResultPipe,
], ],
imports: [ imports: [
CommonModule, CommonModule,

View File

@ -10,7 +10,11 @@
<app-side-nav-item icon="fa-home" title="Home" link="/libraries/"></app-side-nav-item> <app-side-nav-item icon="fa-home" title="Home" link="/libraries/"></app-side-nav-item>
<app-side-nav-item icon="fa-star" title="Want To Read" link="/want-to-read/"></app-side-nav-item> <app-side-nav-item icon="fa-star" title="Want To Read" link="/want-to-read/"></app-side-nav-item>
<app-side-nav-item icon="fa-list" title="Collections" link="/collections/"></app-side-nav-item> <app-side-nav-item icon="fa-list" title="Collections" link="/collections/"></app-side-nav-item>
<app-side-nav-item icon="fa-list-ol" title="Reading Lists" link="/lists/"></app-side-nav-item> <app-side-nav-item icon="fa-list-ol" title="Reading Lists" link="/lists/">
<ng-container actions>
<app-card-actionables [actions]="readingListActions" labelBy="Reading Lists" iconClass="fa-ellipsis-v" (actionHandler)="importCbl()"></app-card-actionables>
</ng-container>
</app-side-nav-item>
<app-side-nav-item icon="fa-bookmark" title="Bookmarks" link="/bookmarks/"></app-side-nav-item> <app-side-nav-item icon="fa-bookmark" title="Bookmarks" link="/bookmarks/"></app-side-nav-item>
<app-side-nav-item icon="fa-regular fa-rectangle-list" title="All Series" link="/all-series/" *ngIf="libraries.length > 0"></app-side-nav-item> <app-side-nav-item icon="fa-regular fa-rectangle-list" title="All Series" link="/all-series/" *ngIf="libraries.length > 0"></app-side-nav-item>
<div class="mb-2 mt-3 ms-2 me-2" *ngIf="libraries.length > 10 && (navService?.sideNavCollapsed$ | async) === false"> <div class="mb-2 mt-3 ms-2 me-2" *ngIf="libraries.length > 10 && (navService?.sideNavCollapsed$ | async) === false">

View File

@ -3,6 +3,8 @@ import { NavigationEnd, Router } from '@angular/router';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { filter, map, shareReplay, take, takeUntil } from 'rxjs/operators'; import { filter, map, shareReplay, take, takeUntil } from 'rxjs/operators';
import { ImportCblModalComponent } from 'src/app/reading-list/_modals/import-cbl-modal/import-cbl-modal.component';
import { ReadingList } from 'src/app/_models/reading-list';
import { ImageService } from 'src/app/_services/image.service'; import { ImageService } from 'src/app/_services/image.service';
import { EVENTS, MessageHubService } from 'src/app/_services/message-hub.service'; import { EVENTS, MessageHubService } from 'src/app/_services/message-hub.service';
import { Breakpoint, UtilityService } from '../../../shared/_services/utility.service'; import { Breakpoint, UtilityService } from '../../../shared/_services/utility.service';
@ -23,6 +25,7 @@ export class SideNavComponent implements OnInit, OnDestroy {
libraries: Library[] = []; libraries: Library[] = [];
actions: ActionItem<Library>[] = []; actions: ActionItem<Library>[] = [];
readingListActions = [{action: Action.Import, title: 'Import CBL', children: [], requiresAdmin: true, callback: this.importCbl.bind(this)}];
filterQuery: string = ''; filterQuery: string = '';
filterLibrary = (library: Library) => { filterLibrary = (library: Library) => {
@ -36,7 +39,7 @@ export class SideNavComponent implements OnInit, OnDestroy {
public utilityService: UtilityService, private messageHub: MessageHubService, public utilityService: UtilityService, private messageHub: MessageHubService,
private actionFactoryService: ActionFactoryService, private actionService: ActionService, private actionFactoryService: ActionFactoryService, private actionService: ActionService,
public navService: NavService, private router: Router, private readonly cdRef: ChangeDetectorRef, public navService: NavService, private router: Router, private readonly cdRef: ChangeDetectorRef,
private modalService: NgbModal, private imageService: ImageService) { private ngbModal: NgbModal, private imageService: ImageService) {
this.router.events.pipe( this.router.events.pipe(
filter(event => event instanceof NavigationEnd), filter(event => event instanceof NavigationEnd),
@ -97,6 +100,9 @@ export class SideNavComponent implements OnInit, OnDestroy {
} }
} }
importCbl() {
const ref = this.ngbModal.open(ImportCblModalComponent, {size: 'xl'});
}
performAction(action: ActionItem<Library>, library: Library) { performAction(action: ActionItem<Library>, library: Library) {
if (typeof action.callback === 'function') { if (typeof action.callback === 'function') {

View File

@ -17,7 +17,7 @@
"moduleResolution": "node", "moduleResolution": "node",
"importHelpers": true, "importHelpers": true,
"target": "ES2022", "target": "ES2022",
"module": "es2020", "module": "ES2022",
"useDefineForClassFields": false, "useDefineForClassFields": false,
"lib": [ "lib": [
"ES2022", "ES2022",

View File

@ -7,7 +7,7 @@
"name": "GPL-3.0", "name": "GPL-3.0",
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
}, },
"version": "0.7.1.2" "version": "0.7.1.4"
}, },
"servers": [ "servers": [
{ {
@ -1024,6 +1024,185 @@
} }
} }
}, },
"/api/Cbl/validate": {
"post": {
"tags": [
"Cbl"
],
"summary": "The first step in a cbl import. This validates the cbl file that if an import occured, would it be successful.\r\nIf this returns errors, the cbl will always be rejected by Kavita.",
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"properties": {
"ContentType": {
"type": "string"
},
"ContentDisposition": {
"type": "string"
},
"Headers": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
}
},
"Length": {
"type": "integer",
"format": "int64"
},
"Name": {
"type": "string"
},
"FileName": {
"type": "string"
}
}
},
"encoding": {
"ContentType": {
"style": "form"
},
"ContentDisposition": {
"style": "form"
},
"Headers": {
"style": "form"
},
"Length": {
"style": "form"
},
"Name": {
"style": "form"
},
"FileName": {
"style": "form"
}
}
}
}
},
"responses": {
"200": {
"description": "Success",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/CblImportSummaryDto"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/CblImportSummaryDto"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/CblImportSummaryDto"
}
}
}
}
}
}
},
"/api/Cbl/import": {
"post": {
"tags": [
"Cbl"
],
"summary": "Performs the actual import (assuming dryRun = false)",
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"properties": {
"ContentType": {
"type": "string"
},
"ContentDisposition": {
"type": "string"
},
"Headers": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
}
},
"Length": {
"type": "integer",
"format": "int64"
},
"Name": {
"type": "string"
},
"FileName": {
"type": "string"
},
"dryRun": {
"type": "boolean",
"default": false
}
}
},
"encoding": {
"ContentType": {
"style": "form"
},
"ContentDisposition": {
"style": "form"
},
"Headers": {
"style": "form"
},
"Length": {
"style": "form"
},
"Name": {
"style": "form"
},
"FileName": {
"style": "form"
},
"dryRun": {
"style": "form"
}
}
}
}
},
"responses": {
"200": {
"description": "Success",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/CblImportSummaryDto"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/CblImportSummaryDto"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/CblImportSummaryDto"
}
}
}
}
}
}
},
"/api/Collection": { "/api/Collection": {
"get": { "get": {
"tags": [ "tags": [
@ -9910,6 +10089,84 @@
}, },
"additionalProperties": false "additionalProperties": false
}, },
"CblBookResult": {
"type": "object",
"properties": {
"order": {
"type": "integer",
"description": "Order in the CBL",
"format": "int32"
},
"series": {
"type": "string",
"nullable": true
},
"volume": {
"type": "string",
"nullable": true
},
"number": {
"type": "string",
"nullable": true
},
"reason": {
"$ref": "#/components/schemas/CblImportReason"
}
},
"additionalProperties": false
},
"CblImportReason": {
"enum": [
0,
1,
2,
3,
4,
5,
6,
7,
8
],
"type": "integer",
"format": "int32"
},
"CblImportResult": {
"enum": [
0,
1,
2
],
"type": "integer",
"format": "int32"
},
"CblImportSummaryDto": {
"type": "object",
"properties": {
"cblName": {
"type": "string",
"nullable": true
},
"results": {
"type": "array",
"items": {
"$ref": "#/components/schemas/CblBookResult"
},
"nullable": true
},
"success": {
"$ref": "#/components/schemas/CblImportResult"
},
"successfulInserts": {
"type": "array",
"items": {
"$ref": "#/components/schemas/CblBookResult"
},
"nullable": true
}
},
"additionalProperties": false,
"description": "Represents the summary from the Import of a given CBL"
},
"Chapter": { "Chapter": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -14973,6 +15230,10 @@
"name": "Account", "name": "Account",
"description": "All Account matters" "description": "All Account matters"
}, },
{
"name": "Cbl",
"description": "Responsible for the CBL import flow"
},
{ {
"name": "Collection", "name": "Collection",
"description": "APIs for Collections" "description": "APIs for Collections"