mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
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:
parent
c846b36047
commit
b55d9e3994
@ -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);
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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())
|
||||
|
@ -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; }
|
||||
|
@ -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; }
|
||||
}
|
||||
|
@ -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;
|
||||
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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; }
|
||||
|
@ -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; });
|
||||
}
|
||||
|
@ -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)
|
||||
{
|
||||
|
@ -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();
|
||||
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
{
|
||||
"TokenKey": "super secret unguessable key",
|
||||
"Port": 5000,
|
||||
"IpAddresses": "0.0.0.0,::"
|
||||
"IpAddresses": ""
|
||||
}
|
||||
|
@ -8,8 +8,8 @@ namespace Kavita.Common;
|
||||
|
||||
public static class Configuration
|
||||
{
|
||||
public const string DefaultIPAddresses = "0.0.0.0,::";
|
||||
public static readonly string AppSettingsFilename = Path.Join("config", GetAppSettingFilename());
|
||||
public const string DefaultIpAddresses = "0.0.0.0,::";
|
||||
private static readonly string AppSettingsFilename = Path.Join("config", GetAppSettingFilename());
|
||||
|
||||
public static int Port
|
||||
{
|
||||
|
@ -5,5 +5,14 @@ export interface CblBookResult {
|
||||
series: string;
|
||||
volume: string;
|
||||
number: string;
|
||||
/**
|
||||
* For SeriesCollision
|
||||
*/
|
||||
libraryId: number;
|
||||
/**
|
||||
* For SeriesCollision
|
||||
*/
|
||||
seriesId: number;
|
||||
readingListName: string;
|
||||
reason: CblImportReason;
|
||||
}
|
@ -7,5 +7,6 @@ export enum CblImportReason {
|
||||
EmptyFile = 5,
|
||||
SeriesCollision = 6,
|
||||
AllChapterMissing = 7,
|
||||
Success = 8
|
||||
Success = 8,
|
||||
InvalidFile = 9
|
||||
}
|
@ -9,6 +9,7 @@ export interface CblConflictQuestion {
|
||||
|
||||
export interface CblImportSummary {
|
||||
cblName: string;
|
||||
fileName: string;
|
||||
results: Array<CblBookResult>;
|
||||
success: CblImportResult;
|
||||
successfulInserts: Array<CblBookResult>;
|
||||
|
@ -3,6 +3,7 @@ import { Injectable } from '@angular/core';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { UtilityService } from '../shared/_services/utility.service';
|
||||
import { Person } from '../_models/metadata/person';
|
||||
import { PaginatedResult } from '../_models/pagination';
|
||||
import { ReadingList, ReadingListItem } from '../_models/reading-list';
|
||||
import { CblImportResult } from '../_models/reading-list/cbl/cbl-import-result.enum';
|
||||
@ -102,4 +103,8 @@ export class ReadingListService {
|
||||
importCbl(form: FormData) {
|
||||
return this.httpClient.post<CblImportSummary>(this.baseUrl + 'cbl/import', form);
|
||||
}
|
||||
|
||||
getCharacters(readingListId: number) {
|
||||
return this.httpClient.get<Array<Person>>(this.baseUrl + 'readinglist/characters?readingListId=' + readingListId);
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
<app-side-nav-companion-bar>
|
||||
<app-side-nav-companion-bar [hasExtras]="readingList !== undefined" [extraDrawer]="extrasDrawer">
|
||||
<h2 title>
|
||||
<span *ngIf="actions.length > 0">
|
||||
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [attr.labelBy]="readingList?.title"></app-card-actionables>
|
||||
@ -7,6 +7,34 @@
|
||||
<span *ngIf="readingList?.promoted" class="ms-1">(<i class="fa fa-angle-double-up" aria-hidden="true"></i>)</span>
|
||||
</h2>
|
||||
<h6 subtitle class="subtitle-with-actionables">{{items.length}} Items</h6>
|
||||
|
||||
<ng-template #extrasDrawer let-offcanvas>
|
||||
<div style="margin-top: 56px" *ngIf="readingList">
|
||||
<div class="offcanvas-header">
|
||||
<h4 class="offcanvas-title" id="offcanvas-basic-title">Page Settings</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="offcanvas.dismiss()"></button>
|
||||
</div>
|
||||
<div class="offcanvas-body">
|
||||
<div class="row g-0">
|
||||
<div class="col-md-12 col-sm-12 pe-2 mb-3">
|
||||
<button class="btn btn-danger" (click)="removeRead()" [disabled]="readingList.promoted && !this.isAdmin">
|
||||
<span>
|
||||
<i class="fa fa-check"></i>
|
||||
</span>
|
||||
<span class="read-btn--text"> Remove Read</span>
|
||||
</button>
|
||||
|
||||
<div class="col-auto ms-2 mt-2" *ngIf="!(readingList?.promoted && !this.isAdmin)">
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="accessibility-mode" [value]="accessibilityMode" (change)="updateAccesibilityMode()">
|
||||
<label class="form-check-label" for="accessibility-mode">Order Numbers</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-side-nav-companion-bar>
|
||||
<div class="container-fluid mt-2" *ngIf="readingList">
|
||||
|
||||
@ -18,32 +46,40 @@
|
||||
<div class="row g-0 mb-3">
|
||||
<div class="col-auto me-2">
|
||||
<!-- Action row-->
|
||||
<button class="btn btn-primary" title="Read from beginning" (click)="read()">
|
||||
<div class="btn-group me-3">
|
||||
<button type="button" class="btn btn-primary" (click)="continue()">
|
||||
<span>
|
||||
<i class="fa fa-book" aria-hidden="true"></i>
|
||||
<span class="read-btn--text"> Read</span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="btn btn-primary ms-2" title="Continue from last reading position" (click)="continue()">
|
||||
<span>
|
||||
<i class="fa fa-book-open" aria-hidden="true"></i>
|
||||
<span class="read-btn--text"> Continue</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-secondary" (click)="removeRead()" [disabled]="readingList.promoted && !this.isAdmin">
|
||||
<span>
|
||||
<i class="fa fa-check"></i>
|
||||
</span>
|
||||
<span class="read-btn--text"> Remove Read</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- TODO: Move this in companion bar's page actions -->
|
||||
<div class="col-auto ms-2 mt-2" *ngIf="!(readingList?.promoted && !this.isAdmin)">
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="accessibilit-mode" [value]="accessibilityMode" (change)="accessibilityMode = !accessibilityMode">
|
||||
<label class="form-check-label" for="accessibilit-mode">Order Numbers</label>
|
||||
<div class="btn-group" ngbDropdown role="group" aria-label="Read options">
|
||||
<button type="button" class="btn btn-primary dropdown-toggle-split" ngbDropdownToggle></button>
|
||||
<div class="dropdown-menu" ngbDropdownMenu>
|
||||
<button ngbDropdownItem (click)="read()">
|
||||
<span>
|
||||
<i class="fa fa-book" aria-hidden="true"></i>
|
||||
<span class="read-btn--text"> Read</span>
|
||||
</span>
|
||||
</button>
|
||||
<button ngbDropdownItem (click)="continue(true)">
|
||||
<span>
|
||||
<i class="fa fa-book-open" aria-hidden="true"></i>
|
||||
<span class="read-btn--text"> Continue</span>
|
||||
(<i class="fa fa-glasses ms-1" aria-hidden="true"></i>)
|
||||
<span class="visually-hidden">(Incognito)</span>
|
||||
</span>
|
||||
</button>
|
||||
<button ngbDropdownItem (click)="read(true)">
|
||||
<span>
|
||||
<i class="fa fa-book" aria-hidden="true"></i>
|
||||
<span class="read-btn--text"> Read</span>
|
||||
(<i class="fa fa-glasses ms-1" aria-hidden="true"></i>)
|
||||
<span class="visually-hidden">(Incognito)</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -54,6 +90,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<ng-container *ngIf="characters$ | async as characters">
|
||||
<div class="row g-0" *ngIf="characters && characters.length > 0">
|
||||
<div class="col-md-4">
|
||||
<h5>Characters</h5>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<app-badge-expander [items]="characters">
|
||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||
<app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge>
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3" cdkScrollable>
|
||||
<div class="mx-auto" style="width: 200px;">
|
||||
<ng-container *ngIf="items.length === 0 && !isLoading">
|
||||
|
@ -1,3 +1,10 @@
|
||||
.content-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dropdown-toggle-split {
|
||||
border-top-right-radius: 6px !important;
|
||||
border-bottom-right-radius: 6px !important;
|
||||
border-top-left-radius: 0px !important;
|
||||
border-bottom-left-radius: 0px !important;
|
||||
}
|
@ -13,9 +13,10 @@ import { ActionService } from 'src/app/_services/action.service';
|
||||
import { ImageService } from 'src/app/_services/image.service';
|
||||
import { ReadingListService } from 'src/app/_services/reading-list.service';
|
||||
import { IndexUpdateEvent } from '../draggable-ordered-list/draggable-ordered-list.component';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { forkJoin, Observable } from 'rxjs';
|
||||
import { ReaderService } from 'src/app/_services/reader.service';
|
||||
import { LibraryService } from 'src/app/_services/library.service';
|
||||
import { Person } from 'src/app/_models/metadata/person';
|
||||
|
||||
@Component({
|
||||
selector: 'app-reading-list-detail',
|
||||
@ -40,6 +41,7 @@ export class ReadingListDetailComponent implements OnInit {
|
||||
readingListImage: string = '';
|
||||
|
||||
libraryTypes: {[key: number]: LibraryType} = {};
|
||||
characters$!: Observable<Person[]>;
|
||||
|
||||
get MangaFormat(): typeof MangaFormat {
|
||||
return MangaFormat;
|
||||
@ -59,6 +61,7 @@ export class ReadingListDetailComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
this.listId = parseInt(listId, 10);
|
||||
this.characters$ = this.readingListService.getCharacters(this.listId);
|
||||
this.readingListImage = this.imageService.randomize(this.imageService.getReadingListCoverImage(this.listId));
|
||||
|
||||
forkJoin([
|
||||
@ -115,11 +118,7 @@ export class ReadingListDetailComponent implements OnInit {
|
||||
}
|
||||
|
||||
readChapter(item: ReadingListItem) {
|
||||
let reader = 'manga';
|
||||
if (!this.readingList) return;
|
||||
if (item.seriesFormat === MangaFormat.EPUB) {
|
||||
reader = 'book;'
|
||||
}
|
||||
const params = this.readerService.getQueryParamsObject(false, true, this.readingList.id);
|
||||
this.router.navigate(this.readerService.getNavigationArray(item.libraryId, item.seriesId, item.chapterId, item.seriesFormat), {queryParams: params});
|
||||
}
|
||||
@ -178,13 +177,15 @@ export class ReadingListDetailComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
read() {
|
||||
read(inconitoMode: boolean = false) {
|
||||
if (!this.readingList) return;
|
||||
const firstItem = this.items[0];
|
||||
this.router.navigate(this.readerService.getNavigationArray(firstItem.libraryId, firstItem.seriesId, firstItem.chapterId, firstItem.seriesFormat), {queryParams: {readingListId: this.readingList.id}});
|
||||
this.router.navigate(
|
||||
this.readerService.getNavigationArray(firstItem.libraryId, firstItem.seriesId, firstItem.chapterId, firstItem.seriesFormat),
|
||||
{queryParams: {readingListId: this.readingList.id, inconitoMode: inconitoMode}});
|
||||
}
|
||||
|
||||
continue() {
|
||||
continue(inconitoMode: boolean = false) {
|
||||
// TODO: Can I do this in the backend?
|
||||
if (!this.readingList) return;
|
||||
let currentlyReadingChapter = this.items[0];
|
||||
@ -196,6 +197,13 @@ export class ReadingListDetailComponent implements OnInit {
|
||||
break;
|
||||
}
|
||||
|
||||
this.router.navigate(this.readerService.getNavigationArray(currentlyReadingChapter.libraryId, currentlyReadingChapter.seriesId, currentlyReadingChapter.chapterId, currentlyReadingChapter.seriesFormat), {queryParams: {readingListId: this.readingList.id}});
|
||||
this.router.navigate(
|
||||
this.readerService.getNavigationArray(currentlyReadingChapter.libraryId, currentlyReadingChapter.seriesId, currentlyReadingChapter.chapterId, currentlyReadingChapter.seriesFormat),
|
||||
{queryParams: {readingListId: this.readingList.id, inconitoMode: inconitoMode}});
|
||||
}
|
||||
|
||||
updateAccesibilityMode() {
|
||||
this.accessibilityMode = !this.accessibilityMode;
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
}
|
||||
|
@ -12,18 +12,18 @@
|
||||
<h5 class="mb-1 pb-0" id="item.id--{{position}}">
|
||||
{{item.title}}
|
||||
<div class="float-end">
|
||||
<button class="btn btn-primary" (click)="readChapter(item)">
|
||||
<button class="btn btn-danger" (click)="remove.emit(item)">
|
||||
<span>
|
||||
<i class="fa fa-trash me-1" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span class="d-none d-sm-inline-block">Remove</span>
|
||||
</button>
|
||||
<button class="btn btn-primary ms-2" (click)="readChapter(item)">
|
||||
<span>
|
||||
<i class="fa fa-book me-1" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span class="d-none d-sm-inline-block">Read</span>
|
||||
</button>
|
||||
<button class="btn btn-danger ms-2" (click)="remove.emit(item)">
|
||||
<span>
|
||||
<i class="fa fa-trash me-1" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span class="d-none d-sm-inline-block">Remove</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</h5>
|
||||
|
@ -1,6 +1,6 @@
|
||||
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">Edit {{readingList.title}} Reading List</h4>
|
||||
<h4 class="modal-title" id="modal-basic-title">Edit Reading List: {{readingList.title}}</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'}}">
|
||||
|
@ -68,6 +68,13 @@ export class EditReadingListModalComponent implements OnInit, OnDestroy {
|
||||
).subscribe();
|
||||
|
||||
this.imageUrls.push(this.imageService.randomize(this.imageService.getReadingListCoverImage(this.readingList.id)));
|
||||
if (!this.readingList.items || this.readingList.items.length === 0) {
|
||||
this.readingListService.getListItems(this.readingList.id).subscribe(items => {
|
||||
this.imageUrls.push(...(items).map(rli => this.imageService.getChapterCoverImage(rli.chapterId)));
|
||||
});
|
||||
} else {
|
||||
this.imageUrls.push(...(this.readingList.items).map(rli => this.imageService.getChapterCoverImage(rli.chapterId)));
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
|
@ -18,46 +18,101 @@
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="currentStepIndex === Step.Validate">
|
||||
<p>All files have been validated to see if there are any operations to do on the list. Any lists have have failed will not move to the next step. Fix the CBL files and retry.</p>
|
||||
<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>
|
||||
|
||||
<ngb-accordion #a="ngbAccordion">
|
||||
<ngb-panel *ngFor="let fileToProcess of filesToProcess">
|
||||
<ng-container *ngIf="fileToProcess.validateSummary as summary">
|
||||
<ng-template ngbPanelTitle>
|
||||
<ng-container [ngTemplateOutlet]="heading" [ngTemplateOutletContext]="{ summary: summary, filename: fileToProcess.fileName }"></ng-container>
|
||||
</ng-template>
|
||||
|
||||
<ng-template ngbPanelContent>
|
||||
<ng-container *ngIf="summary.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 summary.results"
|
||||
[innerHTML]="result | cblConflictReason | safeHtml">
|
||||
</li>
|
||||
</ol>
|
||||
</ng-container>
|
||||
<ng-template #noValidateIssues>
|
||||
<div class="justify-content-center col">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fa-solid fa-circle-check" style="font-size: 24px" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1 ms-3">
|
||||
Looks good
|
||||
</div>
|
||||
</div>
|
||||
No issues found with CBL, press next.
|
||||
</div>
|
||||
</ng-template>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
</ngb-panel>
|
||||
</ngb-accordion>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="currentStepIndex === Step.DryRun && dryRunSummary">
|
||||
<ng-container *ngIf="currentStepIndex === Step.DryRun">
|
||||
<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>
|
||||
<p>This is a dry run and shows what will happen if you press Next and perform the import. All Failures will not be imported.</p>
|
||||
|
||||
<ngb-accordion #a="ngbAccordion">
|
||||
<ngb-panel *ngFor="let fileToProcess of filesToProcess">
|
||||
<ng-container *ngIf="fileToProcess.dryRunSummary as summary">
|
||||
<ng-template ngbPanelTitle>
|
||||
<ng-container [ngTemplateOutlet]="heading" [ngTemplateOutletContext]="{ summary: summary, filename: fileToProcess.fileName }"></ng-container>
|
||||
</ng-template>
|
||||
|
||||
<ng-template ngbPanelContent>
|
||||
<ng-container [ngTemplateOutlet]="resultsList" [ngTemplateOutletContext]="{ summary: summary }"></ng-container>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
</ngb-panel>
|
||||
</ngb-accordion>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="currentStepIndex === Step.Finalize && finalizeSummary && dryRunSummary">
|
||||
<ng-container *ngIf="currentStepIndex === Step.Finalize">
|
||||
<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>
|
||||
<ngb-accordion #a="ngbAccordion">
|
||||
<ngb-panel *ngFor="let fileToProcess of filesToProcess">
|
||||
<ng-container *ngIf="fileToProcess.finalizeSummary as summary">
|
||||
<ng-template ngbPanelTitle>
|
||||
<ng-container [ngTemplateOutlet]="heading" [ngTemplateOutletContext]="{ summary: summary, filename: fileToProcess.fileName }"></ng-container>
|
||||
</ng-template>
|
||||
|
||||
<ng-template ngbPanelContent>
|
||||
<ng-container [ngTemplateOutlet]="resultsList" [ngTemplateOutletContext]="{ summary: summary }"></ng-container>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
</ngb-panel>
|
||||
</ngb-accordion>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<ng-template #resultsList let-summary="summary">
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item no-hover" *ngFor="let result of summary.results"
|
||||
innerHTML="{{result.order + 1}}. {{result | cblConflictReason | safeHtml}}"></li>
|
||||
</ul>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #heading let-filename="filename" let-summary="summary">
|
||||
<ng-container *ngIf="summary.success | cblImportResult as success">
|
||||
<ng-container [ngSwitch]="summary.success">
|
||||
<span *ngSwitchCase="CblImportResult.Success" class="badge bg-primary me-1">{{success}}</span>
|
||||
<span *ngSwitchCase="CblImportResult.Fail" class="badge bg-danger me-1">{{success}}</span>
|
||||
<span *ngSwitchCase="CblImportResult.Partial" class="badge bg-warning me-1">{{success}}</span>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
<span>{{filename}}<span *ngIf="summary.cblName">: ({{summary.cblName}})</span></span>
|
||||
</ng-template>
|
||||
|
||||
|
||||
</div>
|
||||
|
@ -3,13 +3,20 @@ 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 { forkJoin } from 'rxjs';
|
||||
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';
|
||||
|
||||
interface FileStep {
|
||||
fileName: string;
|
||||
validateSummary: CblImportSummary | undefined;
|
||||
dryRunSummary: CblImportSummary | undefined;
|
||||
finalizeSummary: CblImportSummary | undefined;
|
||||
}
|
||||
|
||||
enum Step {
|
||||
Import = 0,
|
||||
Validate = 1,
|
||||
@ -28,7 +35,6 @@ export class ImportCblModalComponent {
|
||||
@ViewChild('fileUpload') fileUpload!: ElementRef<HTMLInputElement>;
|
||||
|
||||
fileUploadControl = new FormControl<undefined | Array<File>>(undefined, [
|
||||
FileUploadValidators.filesLimit(1),
|
||||
FileUploadValidators.accept(['.cbl']),
|
||||
]);
|
||||
|
||||
@ -36,25 +42,28 @@ export class ImportCblModalComponent {
|
||||
files: this.fileUploadControl
|
||||
});
|
||||
|
||||
importSummaries: Array<CblImportSummary> = [];
|
||||
validateSummary: CblImportSummary | undefined;
|
||||
dryRunSummary: CblImportSummary | undefined;
|
||||
dryRunResults: Array<CblBookResult> = [];
|
||||
finalizeSummary: CblImportSummary | undefined;
|
||||
finalizeResults: Array<CblBookResult> = [];
|
||||
|
||||
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: 'Import CBLs', index: Step.Import, active: true, icon: 'fa-solid fa-file-arrow-up'},
|
||||
{title: 'Validate CBL', 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'},
|
||||
];
|
||||
currentStepIndex = this.steps[0].index;
|
||||
|
||||
filesToProcess: Array<FileStep> = [];
|
||||
failedFiles: Array<FileStep> = [];
|
||||
|
||||
get Breakpoint() { return Breakpoint; }
|
||||
get Step() { return Step; }
|
||||
get CblImportResult() { return CblImportResult; }
|
||||
|
||||
get FileCount() {
|
||||
const files = this.uploadForm.get('files')?.value;
|
||||
if (!files) return 0;
|
||||
return files.length;
|
||||
}
|
||||
|
||||
get NextButtonLabel() {
|
||||
switch(this.currentStepIndex) {
|
||||
@ -77,29 +86,59 @@ export class ImportCblModalComponent {
|
||||
|
||||
nextStep() {
|
||||
if (this.currentStepIndex === Step.Import && !this.isFileSelected()) return;
|
||||
if (this.currentStepIndex === Step.Validate && this.validateSummary && this.validateSummary.results.length > 0) return;
|
||||
//if (this.currentStepIndex === Step.Validate && this.validateSummary && this.validateSummary.results.length > 0) return;
|
||||
|
||||
this.isLoading = true;
|
||||
switch (this.currentStepIndex) {
|
||||
case Step.Import:
|
||||
this.importFile();
|
||||
const files = this.uploadForm.get('files')?.value;
|
||||
if (!files) {
|
||||
this.toastr.error('You need to select files to move forward');
|
||||
return;
|
||||
}
|
||||
// Load each file into filesToProcess and group their data
|
||||
let pages = [];
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const formData = new FormData();
|
||||
formData.append('cbl', files[i]);
|
||||
formData.append('dryRun', true + '');
|
||||
pages.push(this.readingListService.validateCbl(formData));
|
||||
}
|
||||
forkJoin(pages).subscribe(results => {
|
||||
this.filesToProcess = [];
|
||||
results.forEach(cblImport => {
|
||||
this.filesToProcess.push({
|
||||
fileName: cblImport.fileName,
|
||||
validateSummary: cblImport,
|
||||
dryRunSummary: undefined,
|
||||
finalizeSummary: undefined
|
||||
});
|
||||
});
|
||||
|
||||
this.filesToProcess = this.filesToProcess.sort((a, b) => b.validateSummary!.success - a.validateSummary!.success);
|
||||
|
||||
this.currentStepIndex++;
|
||||
this.isLoading = false;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
break;
|
||||
case Step.Validate:
|
||||
this.import(true);
|
||||
this.failedFiles = this.filesToProcess.filter(item => item.validateSummary?.success === CblImportResult.Fail);
|
||||
this.filesToProcess = this.filesToProcess.filter(item => item.validateSummary?.success != CblImportResult.Fail);
|
||||
this.dryRun();
|
||||
break;
|
||||
case Step.DryRun:
|
||||
this.import(false);
|
||||
this.failedFiles.push(...this.filesToProcess.filter(item => item.dryRunSummary?.success === CblImportResult.Fail));
|
||||
this.filesToProcess = this.filesToProcess.filter(item => item.dryRunSummary?.success != CblImportResult.Fail);
|
||||
this.import();
|
||||
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.filesToProcess = [];
|
||||
this.failedFiles = [];
|
||||
this.cdRef.markForCheck();
|
||||
break;
|
||||
|
||||
@ -116,9 +155,9 @@ export class ImportCblModalComponent {
|
||||
case Step.Import:
|
||||
return this.isFileSelected();
|
||||
case Step.Validate:
|
||||
return this.validateSummary && this.validateSummary.results.length === 0;
|
||||
return this.filesToProcess.filter(item => item.validateSummary?.success != CblImportResult.Fail).length > 0;
|
||||
case Step.DryRun:
|
||||
return this.dryRunSummary?.success != CblImportResult.Fail;
|
||||
return this.filesToProcess.filter(item => item.dryRunSummary?.success != CblImportResult.Fail).length > 0;
|
||||
case Step.Finalize:
|
||||
return true;
|
||||
default:
|
||||
@ -129,6 +168,7 @@ export class ImportCblModalComponent {
|
||||
canMoveToPrevStep() {
|
||||
switch (this.currentStepIndex) {
|
||||
case Step.Import:
|
||||
case Step.Finalize:
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
@ -142,45 +182,52 @@ export class ImportCblModalComponent {
|
||||
return false;
|
||||
}
|
||||
|
||||
importFile() {
|
||||
const files = this.uploadForm.get('files')?.value;
|
||||
if (!files) return;
|
||||
|
||||
this.cdRef.markForCheck();
|
||||
dryRun() {
|
||||
|
||||
const filenamesAllowedToProcess = this.filesToProcess.map(p => p.fileName);
|
||||
const files = (this.uploadForm.get('files')?.value || []).filter(f => filenamesAllowedToProcess.includes(f.name));
|
||||
|
||||
let pages = [];
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const formData = new FormData();
|
||||
formData.append('cbl', files[i]);
|
||||
formData.append('dryRun', 'true');
|
||||
pages.push(this.readingListService.importCbl(formData));
|
||||
}
|
||||
forkJoin(pages).subscribe(results => {
|
||||
results.forEach(cblImport => {
|
||||
const index = this.filesToProcess.findIndex(p => p.fileName === cblImport.fileName);
|
||||
this.filesToProcess[index].dryRunSummary = cblImport;
|
||||
});
|
||||
this.filesToProcess = this.filesToProcess.sort((a, b) => b.dryRunSummary!.success - a.dryRunSummary!.success);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('cbl', files[0]);
|
||||
this.readingListService.validateCbl(formData).subscribe(res => {
|
||||
if (this.currentStepIndex === Step.Import) {
|
||||
this.validateSummary = res;
|
||||
}
|
||||
this.importSummaries.push(res);
|
||||
this.currentStepIndex++;
|
||||
this.isLoading = false;
|
||||
this.currentStepIndex++;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
import(dryRun: boolean = false) {
|
||||
const files = this.uploadForm.get('files')?.value;
|
||||
if (!files) return;
|
||||
import() {
|
||||
const filenamesAllowedToProcess = this.filesToProcess.map(p => p.fileName);
|
||||
const files = (this.uploadForm.get('files')?.value || []).filter(f => filenamesAllowedToProcess.includes(f.name));
|
||||
|
||||
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');
|
||||
}
|
||||
let pages = [];
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const formData = new FormData();
|
||||
formData.append('cbl', files[i]);
|
||||
formData.append('dryRun', 'false');
|
||||
pages.push(this.readingListService.importCbl(formData));
|
||||
}
|
||||
forkJoin(pages).subscribe(results => {
|
||||
results.forEach(cblImport => {
|
||||
const index = this.filesToProcess.findIndex(p => p.fileName === cblImport.fileName);
|
||||
this.filesToProcess[index].finalizeSummary = cblImport;
|
||||
});
|
||||
|
||||
this.isLoading = false;
|
||||
this.currentStepIndex++;
|
||||
this.toastr.success('Reading List imported');
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
@ -19,9 +19,9 @@ export class CblConflictReasonPipe implements PipeTransform {
|
||||
case CblImportReason.EmptyFile:
|
||||
return failIcon + 'The cbl file is empty, nothing to be done.';
|
||||
case CblImportReason.NameConflict:
|
||||
return failIcon + 'A reading list already exists on your account that matches the cbl file.';
|
||||
return failIcon + 'A reading list (' + result.readingListName + ') already exists on your account that matches the cbl file.';
|
||||
case CblImportReason.SeriesCollision:
|
||||
return failIcon + 'The series, ' + result.series + ', collides with another series of the same name in another library.';
|
||||
return failIcon + 'The series, ' + `<a href="/library/${result.libraryId}/series/${result.seriesId}" target="_blank">${result.series}</a>` + ', collides with another series of the same name in another library.';
|
||||
case CblImportReason.SeriesMissing:
|
||||
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:
|
||||
@ -29,7 +29,9 @@ export class CblConflictReasonPipe implements PipeTransform {
|
||||
case CblImportReason.AllChapterMissing:
|
||||
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';
|
||||
return successIcon + result.series + ' volume ' + result.volume + ' number ' + result.number + ' mapped successfully.';
|
||||
case CblImportReason.InvalidFile:
|
||||
return failIcon + 'The file is corrupted or not matching the expected tags/spec.';
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -11,7 +11,7 @@ export class CblImportResultPipe implements PipeTransform {
|
||||
case CblImportResult.Success:
|
||||
return 'Success';
|
||||
case CblImportResult.Partial:
|
||||
return 'Partial Success';
|
||||
return 'Partial';
|
||||
case CblImportResult.Fail:
|
||||
return 'Failure';
|
||||
}
|
||||
|
@ -8,7 +8,7 @@ import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { EditReadingListModalComponent } from './_modals/edit-reading-list-modal/edit-reading-list-modal.component';
|
||||
import { PipeModule } from '../pipe/pipe.module';
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
import { NgbAccordionModule, NgbNavModule, NgbProgressbarModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { NgbAccordionModule, NgbDropdownModule, NgbNavModule, NgbProgressbarModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { SharedSideNavCardsModule } from '../shared-side-nav-cards/shared-side-nav-cards.module';
|
||||
import { ReadingListDetailComponent } from './_components/reading-list-detail/reading-list-detail.component';
|
||||
import { ReadingListItemComponent } from './_components/reading-list-item/reading-list-item.component';
|
||||
@ -39,6 +39,7 @@ import { CblImportResultPipe } from './_pipes/cbl-import-result.pipe';
|
||||
NgbNavModule,
|
||||
NgbProgressbarModule,
|
||||
NgbTooltipModule,
|
||||
NgbDropdownModule,
|
||||
|
||||
PipeModule,
|
||||
SharedModule,
|
||||
|
141
openapi.json
141
openapi.json
@ -7,7 +7,7 @@
|
||||
"name": "GPL-3.0",
|
||||
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
||||
},
|
||||
"version": "0.7.1.6"
|
||||
"version": "0.7.1.12"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
@ -4245,56 +4245,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Reader/mark-chapter-until-as-read": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Reader"
|
||||
],
|
||||
"summary": "Marks every chapter that is sorted below the passed number as Read. This will not mark any specials as read.",
|
||||
"description": "This is built for Tachiyomi and is not expected to be called by any other place",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "seriesId",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "chapterNumber",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "number",
|
||||
"format": "float"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"deprecated": true
|
||||
}
|
||||
},
|
||||
"/api/Reader/chapter-bookmarks": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@ -5377,6 +5327,56 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/ReadingList/characters": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"ReadingList"
|
||||
],
|
||||
"summary": "Returns a list of characters associated with the reading list",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "readingListId",
|
||||
"in": "query",
|
||||
"description": "",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PersonDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PersonDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PersonDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/ReadingList/next-chapter": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@ -9771,6 +9771,9 @@
|
||||
"bookReaderReadingDirection": {
|
||||
"$ref": "#/components/schemas/ReadingDirection"
|
||||
},
|
||||
"bookReaderWritingStyle": {
|
||||
"$ref": "#/components/schemas/WritingStyle"
|
||||
},
|
||||
"theme": {
|
||||
"$ref": "#/components/schemas/SiteTheme"
|
||||
},
|
||||
@ -10109,6 +10112,21 @@
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"libraryId": {
|
||||
"type": "integer",
|
||||
"description": "Used on Series conflict",
|
||||
"format": "int32"
|
||||
},
|
||||
"seriesId": {
|
||||
"type": "integer",
|
||||
"description": "Used on Series conflict",
|
||||
"format": "int32"
|
||||
},
|
||||
"readingListName": {
|
||||
"type": "string",
|
||||
"description": "The name of the reading list",
|
||||
"nullable": true
|
||||
},
|
||||
"reason": {
|
||||
"$ref": "#/components/schemas/CblImportReason"
|
||||
}
|
||||
@ -10125,7 +10143,8 @@
|
||||
5,
|
||||
6,
|
||||
7,
|
||||
8
|
||||
8,
|
||||
9
|
||||
],
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
@ -10146,6 +10165,11 @@
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"fileName": {
|
||||
"type": "string",
|
||||
"description": "Used only for Kavita's UI, the filename of the cbl",
|
||||
"nullable": true
|
||||
},
|
||||
"results": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@ -14928,6 +14952,7 @@
|
||||
"bookReaderReadingDirection",
|
||||
"bookReaderTapToPaginate",
|
||||
"bookReaderThemeName",
|
||||
"bookReaderWritingStyle",
|
||||
"emulateBook",
|
||||
"globalPageLayoutMode",
|
||||
"layoutMode",
|
||||
@ -15005,6 +15030,9 @@
|
||||
"bookReaderReadingDirection": {
|
||||
"$ref": "#/components/schemas/ReadingDirection"
|
||||
},
|
||||
"bookReaderWritingStyle": {
|
||||
"$ref": "#/components/schemas/WritingStyle"
|
||||
},
|
||||
"theme": {
|
||||
"$ref": "#/components/schemas/SiteTheme"
|
||||
},
|
||||
@ -15210,6 +15238,15 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"WritingStyle": {
|
||||
"enum": [
|
||||
0,
|
||||
1
|
||||
],
|
||||
"type": "integer",
|
||||
"description": "Represents the writing styles for the book-reader",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
"securitySchemes": {
|
||||
|
Loading…
x
Reference in New Issue
Block a user