mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
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:
parent
57de661d71
commit
d88a4d5d0c
@ -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
|
||||
|
||||
}
|
||||
|
68
API/Controllers/CBLController.cs
Normal file
68
API/Controllers/CBLController.cs
Normal 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);
|
||||
}
|
||||
}
|
@ -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));
|
||||
// }
|
||||
}
|
||||
|
@ -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; }
|
||||
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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();
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { CblImportReason } from "./cbl-import-reason.enum";
|
||||
|
||||
export interface CblBookResult {
|
||||
order: number;
|
||||
series: string;
|
||||
volume: string;
|
||||
number: string;
|
||||
|
@ -7,4 +7,5 @@ export enum CblImportReason {
|
||||
EmptyFile = 5,
|
||||
SeriesCollision = 6,
|
||||
AllChapterMissing = 7,
|
||||
Success = 8
|
||||
}
|
@ -12,7 +12,4 @@ export interface CblImportSummary {
|
||||
results: Array<CblBookResult>;
|
||||
success: CblImportResult;
|
||||
successfulInserts: Array<CblBookResult>;
|
||||
conflicts: Array<Series>;
|
||||
conflicts2: Array<CblConflictQuestion>;
|
||||
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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) {
|
||||
|
@ -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>
|
@ -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
|
||||
}
|
@ -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) {}
|
||||
|
||||
}
|
@ -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="row g-0" *ngIf="currentStep.index === 0">
|
||||
<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 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>
|
||||
|
||||
<ng-container *ngIf="currentStep.index === 1">
|
||||
<div class="row g-0">
|
||||
<ng-container *ngIf="validateSummary; 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}}
|
||||
</li>
|
||||
</ol>
|
||||
</ng-container>
|
||||
<ng-template #noValidateIssues>No issues found with CBL, press next.</ng-template>
|
||||
<!-- 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>
|
||||
|
||||
<ng-container *ngIf="currentStep.index === 2 && 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}}
|
||||
<ng-container *ngIf="currentStepIndex === Step.Validate">
|
||||
<div class="row g-0">
|
||||
<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-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-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>
|
||||
</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}}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</ng-container>
|
||||
</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>
|
||||
|
||||
|
||||
|
@ -1,3 +1,41 @@
|
||||
.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);
|
||||
}
|
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
|
19
UI/Web/src/app/reading-list/_pipes/cbl-import-result.pipe.ts
Normal file
19
UI/Web/src/app/reading-list/_pipes/cbl-import-result.pipe.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
@ -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,
|
||||
|
@ -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">
|
||||
|
@ -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') {
|
||||
|
@ -17,7 +17,7 @@
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "es2020",
|
||||
"module": "ES2022",
|
||||
"useDefineForClassFields": false,
|
||||
"lib": [
|
||||
"ES2022",
|
||||
|
263
openapi.json
263
openapi.json
@ -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"
|
||||
|
Loading…
x
Reference in New Issue
Block a user