CBL Import Rework (#1862)

* Fixed a typo in a log

* Invalid XML files now "validate" correctly by sending back a failure.

* Cleaned up messaging on backend and frontend to provide some linking on series name when collision, handle corrupt xml files, etc.

* When reading list conflict occurs, show the reading list name that's conflicting. Started refactoring the code to allow multiple files to be imported at once.

* Started adding new CBL elements for some enhancements I have planned with maintainers.

* Default to empty string for IpAddress to allow to fallback into existing experience

* Tweaked the layout of reading list page (not complete), moved some not used much controls to page extras and reordered the buttons for reading list

* Edit Reading Lists now allows selection of cover image from existing items

* Fixed a bug where cover chooser base64 to image would fail to write webp files.

* Refactored the validate step to now handle multiple files in one go.

* Clean up code

* Don't show CBL name if there were xml errors that prevented showing it

* Don't allow user to go prev step after they perform the import.

* Cleaned up the heading code for accordions

* Fixed a bug with import keeping failed items

* Sort the failures to the bottom of result windows

* CBL import is pretty solid. Need one pass from Robbie on Reading List Page
This commit is contained in:
Joe Milazzo 2023-03-07 15:18:26 -06:00 committed by GitHub
parent c846b36047
commit b55d9e3994
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 609 additions and 249 deletions

View file

@ -1,4 +1,6 @@
using System.IO;
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using API.DTOs.ReadingLists.CBL;
using API.Extensions;
@ -32,10 +34,43 @@ public class CblController : BaseApiController
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);
try
{
var cbl = await SaveAndLoadCblFile(file);
var importSummary = await _readingListService.ValidateCblFile(userId, cbl);
importSummary.FileName = file.FileName;
return Ok(importSummary);
}
catch (ArgumentNullException)
{
return Ok(new CblImportSummaryDto()
{
FileName = file.FileName,
Success = CblImportResult.Fail,
Results = new List<CblBookResult>()
{
new CblBookResult()
{
Reason = CblImportReason.InvalidFile
}
}
});
}
catch (InvalidOperationException)
{
return Ok(new CblImportSummaryDto()
{
FileName = file.FileName,
Success = CblImportResult.Fail,
Results = new List<CblBookResult>()
{
new CblBookResult()
{
Reason = CblImportReason.InvalidFile
}
}
});
}
}
@ -48,13 +83,47 @@ public class CblController : BaseApiController
[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);
try
{
var userId = User.GetUserId();
var cbl = await SaveAndLoadCblFile(file);
var importSummary = await _readingListService.CreateReadingListFromCbl(userId, cbl, dryRun);
importSummary.FileName = file.FileName;
return Ok(importSummary);
} catch (ArgumentNullException)
{
return Ok(new CblImportSummaryDto()
{
FileName = file.FileName,
Success = CblImportResult.Fail,
Results = new List<CblBookResult>()
{
new CblBookResult()
{
Reason = CblImportReason.InvalidFile
}
}
});
}
catch (InvalidOperationException)
{
return Ok(new CblImportSummaryDto()
{
FileName = file.FileName,
Success = CblImportResult.Fail,
Results = new List<CblBookResult>()
{
new CblBookResult()
{
Reason = CblImportReason.InvalidFile
}
}
});
}
return Ok(await _readingListService.CreateReadingListFromCbl(userId, cbl, dryRun));
}
private async Task<CblReadingList> SaveAndLoadCblFile(int userId, IFormFile file)
private async Task<CblReadingList> SaveAndLoadCblFile(IFormFile file)
{
var filename = Path.GetRandomFileName();
var outputFile = Path.Join(_directoryService.TempDirectory, filename);

View file

@ -506,45 +506,6 @@ public class ReaderController : BaseApiController
return Ok(await _unitOfWork.AppUserProgressRepository.HasAnyProgressOnSeriesAsync(seriesId, userId));
}
/// <summary>
/// Marks every chapter that is sorted below the passed number as Read. This will not mark any specials as read.
/// </summary>
/// <remarks>This is built for Tachiyomi and is not expected to be called by any other place</remarks>
/// <returns></returns>
[Obsolete("Deprecated. Use 'Tachiyomi/mark-chapter-until-as-read'")]
[HttpPost("mark-chapter-until-as-read")]
public async Task<ActionResult<bool>> MarkChaptersUntilAsRead(int seriesId, float chapterNumber)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress);
if (user == null) return Unauthorized();
user.Progresses ??= new List<AppUserProgress>();
// Tachiyomi sends chapter 0.0f when there's no chapters read.
// Due to the encoding for volumes this marks all chapters in volume 0 (loose chapters) as read so we ignore it
if (chapterNumber == 0.0f) return true;
if (chapterNumber < 1.0f)
{
// This is a hack to track volume number. We need to map it back by x100
var volumeNumber = int.Parse($"{chapterNumber * 100f}");
await _readerService.MarkVolumesUntilAsRead(user, seriesId, volumeNumber);
}
else
{
await _readerService.MarkChaptersUntilAsRead(user, seriesId, chapterNumber);
}
_unitOfWork.UserRepository.Update(user);
if (!_unitOfWork.HasChanges()) return Ok(true);
if (await _unitOfWork.CommitAsync()) return Ok(true);
await _unitOfWork.RollbackAsync();
return Ok(false);
}
/// <summary>
/// Returns a list of bookmarked pages for a given Chapter
/// </summary>

View file

@ -1,8 +1,10 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.ReadingLists;
using API.Extensions;
using API.Helpers;
@ -421,6 +423,18 @@ public class ReadingListController : BaseApiController
return Ok("Nothing to do");
}
/// <summary>
/// Returns a list of characters associated with the reading list
/// </summary>
/// <param name="readingListId"></param>
/// <returns></returns>
[HttpGet("characters")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.TenMinute)]
public ActionResult<IEnumerable<PersonDto>> GetCharactersForList(int readingListId)
{
return Ok(_unitOfWork.ReadingListRepository.GetReadingListCharactersAsync(readingListId));
}
/// <summary>

View file

@ -80,7 +80,7 @@ public class SettingsController : BaseApiController
{
_logger.LogInformation("{UserName} is resetting IP Addresses Setting", User.GetUsername());
var ipAddresses = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.IpAddresses);
ipAddresses.Value = Configuration.DefaultIPAddresses;
ipAddresses.Value = Configuration.DefaultIpAddresses;
_unitOfWork.SettingsRepository.Update(ipAddresses);
if (!await _unitOfWork.CommitAsync())

View file

@ -68,6 +68,11 @@ public enum CblImportReason
/// </summary>
[Description("Success")]
Success = 8,
/// <summary>
/// The file does not match the XML spec
/// </summary>
[Description("Invalid File")]
InvalidFile = 9,
}
public class CblBookResult
@ -79,6 +84,18 @@ public class CblBookResult
public string Series { get; set; }
public string Volume { get; set; }
public string Number { get; set; }
/// <summary>
/// Used on Series conflict
/// </summary>
public int LibraryId { get; set; }
/// <summary>
/// Used on Series conflict
/// </summary>
public int SeriesId { get; set; }
/// <summary>
/// The name of the reading list
/// </summary>
public string ReadingListName { get; set; }
public CblImportReason Reason { get; set; }
public CblBookResult(CblBook book)
@ -100,6 +117,10 @@ public class CblBookResult
public class CblImportSummaryDto
{
public string CblName { get; set; }
/// <summary>
/// Used only for Kavita's UI, the filename of the cbl
/// </summary>
public string FileName { get; set; }
public ICollection<CblBookResult> Results { get; set; }
public CblImportResult Success { get; set; }
public ICollection<CblBookResult> SuccessfulInserts { get; set; }

View file

@ -21,6 +21,44 @@ public class CblReadingList
[XmlElement(ElementName="Name")]
public string Name { get; set; }
/// <summary>
/// Summary of the Reading List
/// </summary>
/// <remarks>This is not a standard, adding based on discussion with CBL Maintainers</remarks>
[XmlElement(ElementName="Summary")]
public string Summary { get; set; }
/// <summary>
/// Start Year of the Reading List. Overrides calculation
/// </summary>
/// <remarks>This is not a standard, adding based on discussion with CBL Maintainers</remarks>
[XmlElement(ElementName="StartYear")]
public int StartYear { get; set; }
/// <summary>
/// Start Year of the Reading List. Overrides calculation
/// </summary>
/// <remarks>This is not a standard, adding based on discussion with CBL Maintainers</remarks>
[XmlElement(ElementName="StartMonth")]
public int StartMonth { get; set; }
/// <summary>
/// End Year of the Reading List. Overrides calculation
/// </summary>
/// <remarks>This is not a standard, adding based on discussion with CBL Maintainers</remarks>
[XmlElement(ElementName="EndYear")]
public int EndYear { get; set; }
/// <summary>
/// End Year of the Reading List. Overrides calculation
/// </summary>
/// <remarks>This is not a standard, adding based on discussion with CBL Maintainers</remarks>
[XmlElement(ElementName="EndMonth")]
public int EndMonth { get; set; }
/// <summary>
/// Issues of the Reading List
/// </summary>
[XmlElement(ElementName="Books")]
public CblBooks Books { get; set; }
}

View file

@ -14,4 +14,5 @@ public class ReadingListDto
/// This is used to tell the UI if it should request a Cover Image or not. If null or empty, it has not been set.
/// </summary>
public string CoverImage { get; set; } = string.Empty;
}

View file

@ -1,13 +1,16 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.DTOs;
using API.DTOs.ReadingLists;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Helpers;
using API.Services;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
namespace API.Data.Repositories;
@ -32,6 +35,7 @@ public interface IReadingListRepository
Task<IList<string>> GetAllCoverImagesAsync();
Task<bool> ReadingListExists(string name);
Task<List<ReadingList>> GetAllReadingListsAsync();
IEnumerable<PersonDto> GetReadingListCharactersAsync(int readingListId);
}
public class ReadingListRepository : IReadingListRepository
@ -92,6 +96,16 @@ public class ReadingListRepository : IReadingListRepository
.ToListAsync();
}
public IEnumerable<PersonDto> GetReadingListCharactersAsync(int readingListId)
{
return _context.ReadingListItem
.Where(item => item.ReadingListId == readingListId)
.SelectMany(item => item.Chapter.People.Where(p => p.Role == PersonRole.Character))
.OrderBy(p => p.NormalizedName)
.ProjectTo<PersonDto>(_mapper.ConfigurationProvider)
.AsEnumerable();
}
public void Remove(ReadingListItem item)
{
_context.ReadingListItem.Remove(item);

View file

@ -15,7 +15,7 @@ public class ReadingList : IEntityDate
/// <summary>
/// A normalized string used to check if the reading list already exists in the DB
/// </summary>
public string? NormalizedTitle { get; set; }
public required string NormalizedTitle { get; set; }
public string? Summary { get; set; }
/// <summary>
/// Reading lists that are promoted are only done by admins
@ -39,6 +39,14 @@ public class ReadingList : IEntityDate
public DateTime LastModified { get; set; }
public DateTime CreatedUtc { get; set; }
public DateTime LastModifiedUtc { get; set; }
// /// <summary>
// /// Minimum Year and Month the Reading List starts
// /// </summary>
// public DateOnly StartingYear { get; set; }
// /// <summary>
// /// Maximum Year and Month the Reading List starts
// /// </summary>
// public DateOnly EndingYear { get; set; }
// Relationships
public int AppUserId { get; set; }

View file

@ -173,7 +173,7 @@ public class Program
webBuilder.UseKestrel((opts) =>
{
var ipAddresses = Configuration.IpAddresses;
if (new OsInfo(Array.Empty<IOsVersionAdapter>()).IsDocker || string.IsNullOrEmpty(ipAddresses))
if (new OsInfo(Array.Empty<IOsVersionAdapter>()).IsDocker || string.IsNullOrEmpty(ipAddresses) || ipAddresses.Equals(Configuration.DefaultIpAddresses))
{
opts.ListenAnyIP(HttpPort, options => { options.Protocols = HttpProtocols.Http1AndHttp2; });
}

View file

@ -149,9 +149,9 @@ public class ImageService : IImageService
try
{
using var thumbnail = Image.ThumbnailBuffer(Convert.FromBase64String(encodedImage), thumbnailWidth);
var filename = fileName + (saveAsWebP ? ".webp" : ".png");
thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, fileName + ".png"));
return filename;
fileName += (saveAsWebP ? ".webp" : ".png");
thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, fileName));
return fileName;
}
catch (Exception e)
{

View file

@ -367,9 +367,11 @@ public class ReadingListService : IReadingListService
// Is there another reading list with the same name?
if (await _unitOfWork.ReadingListRepository.ReadingListExists(cblReading.Name))
{
importSummary.Success = CblImportResult.Fail;
importSummary.Results.Add(new CblBookResult()
{
Reason = CblImportReason.NameConflict
Reason = CblImportReason.NameConflict,
ReadingListName = cblReading.Name
});
}
@ -391,24 +393,16 @@ public class ReadingListService : IReadingListService
if (!conflicts.Any()) return importSummary;
importSummary.Success = CblImportResult.Fail;
if (conflicts.Count == cblReading.Books.Book.Count)
foreach (var conflict in conflicts)
{
importSummary.Results.Add(new CblBookResult()
{
Reason = CblImportReason.AllChapterMissing,
Reason = CblImportReason.SeriesCollision,
Series = conflict.Name,
LibraryId = conflict.LibraryId,
SeriesId = conflict.Id,
});
}
else
{
foreach (var conflict in conflicts)
{
importSummary.Results.Add(new CblBookResult()
{
Reason = CblImportReason.SeriesCollision,
Series = conflict.Name
});
}
}
return importSummary;
}
@ -484,6 +478,7 @@ public class ReadingListService : IReadingListService
importSummary.Results.Add(new CblBookResult(book)
{
Reason = CblImportReason.VolumeMissing,
LibraryId = bookSeries.LibraryId,
Order = i
});
continue;
@ -499,6 +494,7 @@ public class ReadingListService : IReadingListService
importSummary.Results.Add(new CblBookResult(book)
{
Reason = CblImportReason.ChapterMissing,
LibraryId = bookSeries.LibraryId,
Order = i
});
continue;
@ -523,11 +519,16 @@ public class ReadingListService : IReadingListService
importSummary.Success = CblImportResult.Fail;
}
await CalculateReadingListAgeRating(readingList);
if (dryRun) return importSummary;
if (!_unitOfWork.HasChanges()) return importSummary;
await CalculateReadingListAgeRating(readingList);
if (!string.IsNullOrEmpty(readingList.Summary?.Trim()))
{
readingList.Summary = readingList.Summary?.Trim();
}
// If there are no items, don't create a blank list
if (!_unitOfWork.HasChanges() || !readingList.Items.Any()) return importSummary;
await _unitOfWork.CommitAsync();

View file

@ -1,5 +1,5 @@
{
"TokenKey": "super secret unguessable key",
"Port": 5000,
"IpAddresses": "0.0.0.0,::"
"IpAddresses": ""
}