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);
}
#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<Chapter>()
// {
// EntityFactory.CreateChapter("1", false),
// EntityFactory.CreateChapter("2", false),
// EntityFactory.CreateChapter("3", false),
//
// }
// });
// 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();
//
// 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<Chapter>()
// {
// EntityFactory.CreateChapter("1", false),
// EntityFactory.CreateChapter("2", false),
// EntityFactory.CreateChapter("3", false),
//
// }
// });
// 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,
// },
// },
// },
// });
//
// _context.Library.Add(new Library()
// {
// Name = "Test Lib 2",
// Type = LibraryType.Book,
// Series = new List<Series>()
// {
// 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<Chapter>()
// {
// EntityFactory.CreateChapter("1", false),
// EntityFactory.CreateChapter("2", false),
// EntityFactory.CreateChapter("3", false),
//
// }
// });
// 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>(),
// });
//
// _context.Library.Add(new Library()
// {
// Name = "Test Lib 2",
// Type = LibraryType.Book,
// Series = new List<Series>()
// {
// 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<Chapter>()
// {
// EntityFactory.CreateChapter("1", false),
// EntityFactory.CreateChapter("2", false),
// EntityFactory.CreateChapter("3", false),
//
// }
// });
// 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
//
#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<Chapter>()
{
EntityFactory.CreateChapter("1", false),
EntityFactory.CreateChapter("2", false),
EntityFactory.CreateChapter("3", false),
}
});
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>(),
});
_context.Library.Add(new Library()
{
Name = "Test Lib 2",
Type = LibraryType.Book,
Series = new List<Series>()
{
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<Chapter>()
{
EntityFactory.CreateChapter("1", false),
EntityFactory.CreateChapter("2", false),
EntityFactory.CreateChapter("3", false),
}
});
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>(),
});
_context.Library.Add(new Library()
{
Name = "Test Lib 2",
Type = LibraryType.Book,
Series = new List<Series>()
{
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<Chapter>()
{
EntityFactory.CreateChapter("1", false),
EntityFactory.CreateChapter("2", false),
EntityFactory.CreateChapter("3", false),
}
});
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();
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<Chapter>()
{
EntityFactory.CreateChapter("1", false),
EntityFactory.CreateChapter("2", false),
EntityFactory.CreateChapter("3", false),
}
});
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,
},
},
},
});
_context.Library.Add(new Library()
{
Name = "Test Lib 2",
Type = LibraryType.Book,
Series = new List<Series>()
{
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<Chapter>()
{
EntityFactory.CreateChapter("1", false),
EntityFactory.CreateChapter("2", false),
EntityFactory.CreateChapter("3", false),
}
});
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;
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.ComponentModel;
using API.DTOs.ReadingLists.CBL;
namespace API.DTOs.ReadingLists;
namespace API.DTOs.ReadingLists.CBL;
public enum CblImportResult {
/// <summary>
@ -64,10 +63,19 @@ public enum CblImportReason
/// </summary>
[Description("All Chapters Missing")]
AllChapterMissing = 7,
/// <summary>
/// The Chapter was imported
/// </summary>
[Description("Success")]
Success = 8,
}
public class CblBookResult
{
/// <summary>
/// Order in the CBL
/// </summary>
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<CblBookResult> Results { get; set; }
public CblImportResult Success { 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<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);
Task<IEnumerable<SeriesDto>> GetAllSeriesDtosByNameAsync(IEnumerable<string> normalizedNames,
int userId, SeriesIncludes includes = SeriesIncludes.None);
@ -1213,14 +1213,14 @@ public class SeriesRepository : ISeriesRepository
.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)
{
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)

View File

@ -355,13 +355,20 @@ public class ReadingListService : IReadingListService
CblName = cblReading.Name,
Success = CblImportResult.Success,
Results = new List<CblBookResult>(),
SuccessfulInserts = new List<CblBookResult>(),
Conflicts = new List<SeriesDto>(),
Conflicts2 = new List<CblConflictQuestion>()
SuccessfulInserts = new List<CblBookResult>()
};
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<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 =
(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();

View File

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

View File

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

View File

@ -12,7 +12,4 @@ export interface CblImportSummary {
results: Array<CblBookResult>;
success: CblImportResult;
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);
}
validateCbl(form: FormData) {
return this.httpClient.post<CblImportSummary>(this.baseUrl + 'cbl/validate', form);
}
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
}
&.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);

View File

@ -28,7 +28,7 @@ export class ReadingListsComponent implements OnInit {
isAdmin: boolean = false;
jumpbarKeys: Array<JumpKey> = [];
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,
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: 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">
<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>
</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;">
<app-step-tracker [steps]="steps" [currentStep]="currentStepIndex"></app-step-tracker>
</div>
<div class="row g-0" *ngIf="currentStep.index === 0">
<p>Import a .cbl file as a reading list</p>
<!-- This is going to need to have a fixed height with a scrollbar-->
<div>
<div class="row g-0" *ngIf="currentStepIndex === Step.Import">
<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>
<form [formGroup]="uploadForm" enctype="multipart/form-data">
<file-upload formControlName="files"></file-upload>
</form>
</div>
<ng-container *ngIf="currentStep.index === 1">
<ng-container *ngIf="currentStepIndex === Step.Validate">
<div class="row g-0">
<ng-container *ngIf="validateSummary; else noValidateIssues">
<ng-container *ngIf="validateSummary">
<ng-container *ngIf="validateSummary.results.length > 0; else noValidateIssues">
<h5>There are issues with the CBL that will prevent an import. Correct these issues then try again.</h5>
<ol class="list-group list-group-flush" >
<li class="list-group-item no-hover" *ngFor="let result of validateSummary.results">
{{result | cblConflictReason}}
<ol class="list-group list-group-numbered list-group-flush" >
<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-template #noValidateIssues>
No issues found with CBL, press next.
</ng-template>
</ng-container>
</div>
</ng-container>
<ng-container *ngIf="currentStep.index === 2 && dryRunSummary">
<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}}</h6>
<ul class="list-group list-group-flush" *ngIf="dryRunSummary">
<li class="list-group-item no-hover" *ngFor="let result of dryRunSummary.results">
{{result | cblConflictReason}}
</li>
<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>
<ul class="list-group list-group-flush" *ngIf="dryRunSummary">
<li class="list-group-item no-hover" *ngFor="let result of dryRunSummary.successfulInserts">
{{result | cblConflictReason}}
</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>
</ul>
</div>
</ng-container>
</div>
@ -49,7 +64,8 @@
<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>
<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>

View File

@ -1,3 +1,41 @@
.file-input {
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 { FileUploadValidators } from '@iplab/ngx-file-upload';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
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 { ReadingListService } from 'src/app/_services/reading-list.service';
import { TimelineStep } from '../../_components/step-tracker/step-tracker.component';
enum Step {
Import = 0,
@ -35,59 +39,103 @@ export class ImportCblModalComponent {
importSummaries: Array<CblImportSummary> = [];
validateSummary: CblImportSummary | undefined;
dryRunSummary: CblImportSummary | undefined;
dryRunResults: Array<CblBookResult> = [];
finalizeSummary: CblImportSummary | undefined;
finalizeResults: Array<CblBookResult> = [];
steps = [
{title: 'Import CBL', index: Step.Import},
{title: 'Validate File', index: Step.Validate},
{title: 'Dry Run', index: Step.DryRun},
{title: 'Final Import', index: Step.Finalize},
isLoading: boolean = false;
steps: Array<TimelineStep> = [
{title: 'Import CBL', index: Step.Import, active: true, icon: 'fa-solid fa-file-arrow-up'},
{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 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,
public utilityService: UtilityService, private readonly cdRef: ChangeDetectorRef) {}
public utilityService: UtilityService, private readonly cdRef: ChangeDetectorRef,
private toastr: ToastrService) {}
close() {
this.ngModal.close();
}
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;
if (this.currentStep.index === Step.Import && !this.isFileSelected()) return;
if (this.currentStep.index === Step.Validate && this.validateSummary && this.validateSummary.results.length > 0) return;
switch (this.currentStep.index) {
this.isLoading = true;
switch (this.currentStepIndex) {
case Step.Import:
this.importFile();
break;
case Step.Validate:
this.import(true);
break;
case Step.DryRun:
this.import(false);
break;
case Step.Finalize:
// 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;
}
}
prevStep() {
if (this.currentStepIndex === Step.Import) return;
this.currentStepIndex--;
}
canMoveToNextStep() {
switch (this.currentStep.index) {
switch (this.currentStepIndex) {
case Step.Import:
return this.isFileSelected();
case Step.Validate:
return this.validateSummary && this.validateSummary.results.length > 0;
return this.validateSummary && this.validateSummary.results.length === 0;
case Step.DryRun:
return true;
return this.dryRunSummary?.success != CblImportResult.Fail;
case Step.Finalize:
return true;
default:
return false;
}
}
canMoveToPrevStep() {
switch (this.currentStepIndex) {
case Step.Import:
return false;
default:
return true;
}
}
isFileSelected() {
const files = this.uploadForm.get('files')?.value;
if (files) return files.length > 0;
@ -98,42 +146,42 @@ export class ImportCblModalComponent {
const files = this.uploadForm.get('files')?.value;
if (!files) return;
this.cdRef.markForCheck();
const formData = new FormData();
formData.append('cbl', files[0]);
formData.append('dryRun', (this.currentStep.index !== Step.Finalize) + '');
this.readingListService.importCbl(formData).subscribe(res => {
console.log('Result: ', res);
if (this.currentStep.index === Step.Import) {
this.readingListService.validateCbl(formData).subscribe(res => {
if (this.currentStepIndex === Step.Import) {
this.validateSummary = res;
}
if (this.currentStep.index === Step.DryRun) {
this.dryRunSummary = res;
}
this.importSummaries.push(res);
this.currentStep.index++;
this.currentStepIndex++;
this.isLoading = false;
this.cdRef.markForCheck();
});
}
// onFileSelected(event: any) {
// console.log('event: ', event);
// if (!(event.target as HTMLInputElement).files === null || (event.target as HTMLInputElement).files?.length === 0) return;
import(dryRun: boolean = false) {
const files = this.uploadForm.get('files')?.value;
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.fileName = file.name;
// const formData = new FormData();
// formData.append("cbl", file);
// this.readingListService.importCbl(formData).subscribe(res => {
// this.importSummaries.push(res);
// this.cdRef.markForCheck();
// });
// this.fileUpload.value = '';
// }
// }
this.isLoading = false;
this.currentStepIndex++;
this.cdRef.markForCheck();
});
}
}

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 { 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({
name: 'cblConflictReason'
})
export class CblConflictReasonPipe implements PipeTransform {
transform(result: CblBookResult): string {
if (result.reason === undefined)
return result.series + ' volume ' + result.volume + ' number ' + result.number + ' mapped successfully';
switch (result.reason) {
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:
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:
return 'The Cbl file is empty, nothing to be done.';
return failIcon + 'The cbl file is empty, nothing to be done.';
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:
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:
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:
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:
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 { FileUploadModule } from '@iplab/ngx-file-upload';
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({
declarations: [
@ -28,6 +29,8 @@ import { CblConflictReasonPipe } from './_pipes/cbl-conflict-reason.pipe';
ReadingListItemComponent,
ImportCblModalComponent,
CblConflictReasonPipe,
StepTrackerComponent,
CblImportResultPipe,
],
imports: [
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-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-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-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">

View File

@ -3,6 +3,8 @@ import { NavigationEnd, Router } from '@angular/router';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { Subject } from 'rxjs';
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 { EVENTS, MessageHubService } from 'src/app/_services/message-hub.service';
import { Breakpoint, UtilityService } from '../../../shared/_services/utility.service';
@ -23,6 +25,7 @@ export class SideNavComponent implements OnInit, OnDestroy {
libraries: Library[] = [];
actions: ActionItem<Library>[] = [];
readingListActions = [{action: Action.Import, title: 'Import CBL', children: [], requiresAdmin: true, callback: this.importCbl.bind(this)}];
filterQuery: string = '';
filterLibrary = (library: Library) => {
@ -36,7 +39,7 @@ export class SideNavComponent implements OnInit, OnDestroy {
public utilityService: UtilityService, private messageHub: MessageHubService,
private actionFactoryService: ActionFactoryService, private actionService: ActionService,
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(
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) {
if (typeof action.callback === 'function') {

View File

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

View File

@ -7,7 +7,7 @@
"name": "GPL-3.0",
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
},
"version": "0.7.1.2"
"version": "0.7.1.4"
},
"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": {
"get": {
"tags": [
@ -9910,6 +10089,84 @@
},
"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": {
"type": "object",
"properties": {
@ -14973,6 +15230,10 @@
"name": "Account",
"description": "All Account matters"
},
{
"name": "Cbl",
"description": "Responsible for the CBL import flow"
},
{
"name": "Collection",
"description": "APIs for Collections"