diff --git a/API/Controllers/CBLController.cs b/API/Controllers/CBLController.cs index 6f46df2de..5ff82edb7 100644 --- a/API/Controllers/CBLController.cs +++ b/API/Controllers/CBLController.cs @@ -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> 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() + { + new CblBookResult() + { + Reason = CblImportReason.InvalidFile + } + } + }); + } + catch (InvalidOperationException) + { + return Ok(new CblImportSummaryDto() + { + FileName = file.FileName, + Success = CblImportResult.Fail, + Results = new List() + { + new CblBookResult() + { + Reason = CblImportReason.InvalidFile + } + } + }); + } } @@ -48,13 +83,47 @@ public class CblController : BaseApiController [HttpPost("import")] public async Task> 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() + { + new CblBookResult() + { + Reason = CblImportReason.InvalidFile + } + } + }); + } + catch (InvalidOperationException) + { + return Ok(new CblImportSummaryDto() + { + FileName = file.FileName, + Success = CblImportResult.Fail, + Results = new List() + { + new CblBookResult() + { + Reason = CblImportReason.InvalidFile + } + } + }); + } - return Ok(await _readingListService.CreateReadingListFromCbl(userId, cbl, dryRun)); } - private async Task SaveAndLoadCblFile(int userId, IFormFile file) + private async Task SaveAndLoadCblFile(IFormFile file) { var filename = Path.GetRandomFileName(); var outputFile = Path.Join(_directoryService.TempDirectory, filename); diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index 842aa75db..80429eaf3 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -506,45 +506,6 @@ public class ReaderController : BaseApiController return Ok(await _unitOfWork.AppUserProgressRepository.HasAnyProgressOnSeriesAsync(seriesId, userId)); } - /// - /// Marks every chapter that is sorted below the passed number as Read. This will not mark any specials as read. - /// - /// This is built for Tachiyomi and is not expected to be called by any other place - /// - [Obsolete("Deprecated. Use 'Tachiyomi/mark-chapter-until-as-read'")] - [HttpPost("mark-chapter-until-as-read")] - public async Task> MarkChaptersUntilAsRead(int seriesId, float chapterNumber) - { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); - if (user == null) return Unauthorized(); - user.Progresses ??= new List(); - - // 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); - } - - /// /// Returns a list of bookmarked pages for a given Chapter /// diff --git a/API/Controllers/ReadingListController.cs b/API/Controllers/ReadingListController.cs index e94223f43..e0e2fadb4 100644 --- a/API/Controllers/ReadingListController.cs +++ b/API/Controllers/ReadingListController.cs @@ -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"); } + /// + /// Returns a list of characters associated with the reading list + /// + /// + /// + [HttpGet("characters")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.TenMinute)] + public ActionResult> GetCharactersForList(int readingListId) + { + return Ok(_unitOfWork.ReadingListRepository.GetReadingListCharactersAsync(readingListId)); + } + /// diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs index 9d80248dc..e8d7156e2 100644 --- a/API/Controllers/SettingsController.cs +++ b/API/Controllers/SettingsController.cs @@ -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()) diff --git a/API/DTOs/ReadingLists/CBL/CblImportSummary.cs b/API/DTOs/ReadingLists/CBL/CblImportSummary.cs index 5e83c7e49..136a31aa8 100644 --- a/API/DTOs/ReadingLists/CBL/CblImportSummary.cs +++ b/API/DTOs/ReadingLists/CBL/CblImportSummary.cs @@ -68,6 +68,11 @@ public enum CblImportReason /// [Description("Success")] Success = 8, + /// + /// The file does not match the XML spec + /// + [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; } + /// + /// Used on Series conflict + /// + public int LibraryId { get; set; } + /// + /// Used on Series conflict + /// + public int SeriesId { get; set; } + /// + /// The name of the reading list + /// + 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; } + /// + /// Used only for Kavita's UI, the filename of the cbl + /// + public string FileName { get; set; } public ICollection Results { get; set; } public CblImportResult Success { get; set; } public ICollection SuccessfulInserts { get; set; } diff --git a/API/DTOs/ReadingLists/CBL/CblReadingList.cs b/API/DTOs/ReadingLists/CBL/CblReadingList.cs index e0dcc460d..b15b2a532 100644 --- a/API/DTOs/ReadingLists/CBL/CblReadingList.cs +++ b/API/DTOs/ReadingLists/CBL/CblReadingList.cs @@ -21,6 +21,44 @@ public class CblReadingList [XmlElement(ElementName="Name")] public string Name { get; set; } + /// + /// Summary of the Reading List + /// + /// This is not a standard, adding based on discussion with CBL Maintainers + [XmlElement(ElementName="Summary")] + public string Summary { get; set; } + + /// + /// Start Year of the Reading List. Overrides calculation + /// + /// This is not a standard, adding based on discussion with CBL Maintainers + [XmlElement(ElementName="StartYear")] + public int StartYear { get; set; } + + /// + /// Start Year of the Reading List. Overrides calculation + /// + /// This is not a standard, adding based on discussion with CBL Maintainers + [XmlElement(ElementName="StartMonth")] + public int StartMonth { get; set; } + + /// + /// End Year of the Reading List. Overrides calculation + /// + /// This is not a standard, adding based on discussion with CBL Maintainers + [XmlElement(ElementName="EndYear")] + public int EndYear { get; set; } + + /// + /// End Year of the Reading List. Overrides calculation + /// + /// This is not a standard, adding based on discussion with CBL Maintainers + [XmlElement(ElementName="EndMonth")] + public int EndMonth { get; set; } + + /// + /// Issues of the Reading List + /// [XmlElement(ElementName="Books")] public CblBooks Books { get; set; } } diff --git a/API/DTOs/ReadingLists/ReadingListDto.cs b/API/DTOs/ReadingLists/ReadingListDto.cs index 9e51c4310..b1ceb10ed 100644 --- a/API/DTOs/ReadingLists/ReadingListDto.cs +++ b/API/DTOs/ReadingLists/ReadingListDto.cs @@ -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. /// public string CoverImage { get; set; } = string.Empty; + } diff --git a/API/Data/Repositories/ReadingListRepository.cs b/API/Data/Repositories/ReadingListRepository.cs index 3064af422..e5ab8e227 100644 --- a/API/Data/Repositories/ReadingListRepository.cs +++ b/API/Data/Repositories/ReadingListRepository.cs @@ -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> GetAllCoverImagesAsync(); Task ReadingListExists(string name); Task> GetAllReadingListsAsync(); + IEnumerable GetReadingListCharactersAsync(int readingListId); } public class ReadingListRepository : IReadingListRepository @@ -92,6 +96,16 @@ public class ReadingListRepository : IReadingListRepository .ToListAsync(); } + public IEnumerable 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(_mapper.ConfigurationProvider) + .AsEnumerable(); + } + public void Remove(ReadingListItem item) { _context.ReadingListItem.Remove(item); diff --git a/API/Entities/ReadingList.cs b/API/Entities/ReadingList.cs index cebd2f4f3..94e67963c 100644 --- a/API/Entities/ReadingList.cs +++ b/API/Entities/ReadingList.cs @@ -15,7 +15,7 @@ public class ReadingList : IEntityDate /// /// A normalized string used to check if the reading list already exists in the DB /// - public string? NormalizedTitle { get; set; } + public required string NormalizedTitle { get; set; } public string? Summary { get; set; } /// /// 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; } + // /// + // /// Minimum Year and Month the Reading List starts + // /// + // public DateOnly StartingYear { get; set; } + // /// + // /// Maximum Year and Month the Reading List starts + // /// + // public DateOnly EndingYear { get; set; } // Relationships public int AppUserId { get; set; } diff --git a/API/Program.cs b/API/Program.cs index 02001eee0..446f04fd0 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -173,7 +173,7 @@ public class Program webBuilder.UseKestrel((opts) => { var ipAddresses = Configuration.IpAddresses; - if (new OsInfo(Array.Empty()).IsDocker || string.IsNullOrEmpty(ipAddresses)) + if (new OsInfo(Array.Empty()).IsDocker || string.IsNullOrEmpty(ipAddresses) || ipAddresses.Equals(Configuration.DefaultIpAddresses)) { opts.ListenAnyIP(HttpPort, options => { options.Protocols = HttpProtocols.Http1AndHttp2; }); } diff --git a/API/Services/ImageService.cs b/API/Services/ImageService.cs index 8eb42100b..b51d1d502 100644 --- a/API/Services/ImageService.cs +++ b/API/Services/ImageService.cs @@ -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) { diff --git a/API/Services/ReadingListService.cs b/API/Services/ReadingListService.cs index b8271f166..a87136d1b 100644 --- a/API/Services/ReadingListService.cs +++ b/API/Services/ReadingListService.cs @@ -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(); diff --git a/API/config/appsettings.Development.json b/API/config/appsettings.Development.json index b449ae2a0..b7ed0b29a 100644 --- a/API/config/appsettings.Development.json +++ b/API/config/appsettings.Development.json @@ -1,5 +1,5 @@ { "TokenKey": "super secret unguessable key", "Port": 5000, - "IpAddresses": "0.0.0.0,::" + "IpAddresses": "" } diff --git a/Kavita.Common/Configuration.cs b/Kavita.Common/Configuration.cs index 96a15fb0f..3fa425919 100644 --- a/Kavita.Common/Configuration.cs +++ b/Kavita.Common/Configuration.cs @@ -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 { diff --git a/UI/Web/src/app/_models/reading-list/cbl/cbl-book-result.ts b/UI/Web/src/app/_models/reading-list/cbl/cbl-book-result.ts index 37af21194..e5ac4b298 100644 --- a/UI/Web/src/app/_models/reading-list/cbl/cbl-book-result.ts +++ b/UI/Web/src/app/_models/reading-list/cbl/cbl-book-result.ts @@ -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; } \ No newline at end of file diff --git a/UI/Web/src/app/_models/reading-list/cbl/cbl-import-reason.enum.ts b/UI/Web/src/app/_models/reading-list/cbl/cbl-import-reason.enum.ts index 6a05154aa..a9a985804 100644 --- a/UI/Web/src/app/_models/reading-list/cbl/cbl-import-reason.enum.ts +++ b/UI/Web/src/app/_models/reading-list/cbl/cbl-import-reason.enum.ts @@ -7,5 +7,6 @@ export enum CblImportReason { EmptyFile = 5, SeriesCollision = 6, AllChapterMissing = 7, - Success = 8 + Success = 8, + InvalidFile = 9 } \ No newline at end of file diff --git a/UI/Web/src/app/_models/reading-list/cbl/cbl-import-summary.ts b/UI/Web/src/app/_models/reading-list/cbl/cbl-import-summary.ts index 8459e4eed..424de0a63 100644 --- a/UI/Web/src/app/_models/reading-list/cbl/cbl-import-summary.ts +++ b/UI/Web/src/app/_models/reading-list/cbl/cbl-import-summary.ts @@ -9,6 +9,7 @@ export interface CblConflictQuestion { export interface CblImportSummary { cblName: string; + fileName: string; results: Array; success: CblImportResult; successfulInserts: Array; diff --git a/UI/Web/src/app/_services/reading-list.service.ts b/UI/Web/src/app/_services/reading-list.service.ts index bc680d68f..b7d9761dc 100644 --- a/UI/Web/src/app/_services/reading-list.service.ts +++ b/UI/Web/src/app/_services/reading-list.service.ts @@ -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(this.baseUrl + 'cbl/import', form); } + + getCharacters(readingListId: number) { + return this.httpClient.get>(this.baseUrl + 'readinglist/characters?readingListId=' + readingListId); + } } diff --git a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html index 98753546c..cc43e1ab6 100644 --- a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html +++ b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html @@ -1,4 +1,4 @@ - +

@@ -7,6 +7,34 @@ ()

{{items.length}} Items
+ + +
+
+

Page Settings

+ +
+
+
+
+ + +
+
+ + +
+
+
+
+
+
+
@@ -18,32 +46,40 @@
- - -
-
- -
- -
-
- - +
+ + +
@@ -54,6 +90,23 @@
+
+ +
+
+
Characters
+
+
+ + + + + +
+
+
+
+
diff --git a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.scss b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.scss index 9129ac012..af64858dd 100644 --- a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.scss +++ b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.scss @@ -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; } \ No newline at end of file diff --git a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.ts b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.ts index bd6255945..7e50fcf1a 100644 --- a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.ts +++ b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.ts @@ -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; 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(); } } diff --git a/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.html b/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.html index f6393b0f4..5160582b3 100644 --- a/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.html +++ b/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.html @@ -12,18 +12,18 @@
{{item.title}}
- + -
diff --git a/UI/Web/src/app/reading-list/_modals/edit-reading-list-modal/edit-reading-list-modal.component.html b/UI/Web/src/app/reading-list/_modals/edit-reading-list-modal/edit-reading-list-modal.component.html index 7148693fa..751183998 100644 --- a/UI/Web/src/app/reading-list/_modals/edit-reading-list-modal/edit-reading-list-modal.component.html +++ b/UI/Web/src/app/reading-list/_modals/edit-reading-list-modal/edit-reading-list-modal.component.html @@ -1,6 +1,6 @@ +

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.

- - -
There are issues with the CBL that will prevent an import. Correct these issues then try again.
-
    -
  1. -
  2. -
-
- - No issues found with CBL, press next. - -
+ + + + + + + + + + +
There are issues with the CBL that will prevent an import. Correct these issues then try again.
+
    +
  1. +
  2. +
+
+ +
+
+
+ +
+
+ Looks good +
+
+ No issues found with CBL, press next. +
+
+
+
+
+
- +
-
This is a dry run and shows what will happen if you press Next
-
The import was a {{dryRunSummary.success | cblImportResult}}
-
    -
  • -
+

This is a dry run and shows what will happen if you press Next and perform the import. All Failures will not be imported.

+ + + + + + + + + + + + + +
- +
-
{{finalizeSummary.success | cblImportResult }} on {{dryRunSummary.cblName}} Import
-
    -
  • -
  • -
+ + + + + + + + + + + + +
+ +
    +
  • +
+
+ + + + + {{success}} + {{success}} + {{success}} + + + {{filename}}: ({{summary.cblName}}) +
diff --git a/UI/Web/src/app/reading-list/_modals/import-cbl-modal/import-cbl-modal.component.ts b/UI/Web/src/app/reading-list/_modals/import-cbl-modal/import-cbl-modal.component.ts index 51547b6b8..1777f4373 100644 --- a/UI/Web/src/app/reading-list/_modals/import-cbl-modal/import-cbl-modal.component.ts +++ b/UI/Web/src/app/reading-list/_modals/import-cbl-modal/import-cbl-modal.component.ts @@ -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; fileUploadControl = new FormControl>(undefined, [ - FileUploadValidators.filesLimit(1), FileUploadValidators.accept(['.cbl']), ]); @@ -36,25 +42,28 @@ export class ImportCblModalComponent { files: this.fileUploadControl }); - importSummaries: Array = []; - validateSummary: CblImportSummary | undefined; - dryRunSummary: CblImportSummary | undefined; - dryRunResults: Array = []; - finalizeSummary: CblImportSummary | undefined; - finalizeResults: Array = []; - isLoading: boolean = false; steps: Array = [ - {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 = []; + failedFiles: Array = []; + 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(); }); } diff --git a/UI/Web/src/app/reading-list/_pipes/cbl-conflict-reason.pipe.ts b/UI/Web/src/app/reading-list/_pipes/cbl-conflict-reason.pipe.ts index bce9dcb54..92517c544 100644 --- a/UI/Web/src/app/reading-list/_pipes/cbl-conflict-reason.pipe.ts +++ b/UI/Web/src/app/reading-list/_pipes/cbl-conflict-reason.pipe.ts @@ -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, ' + `${result.series}` + ', 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.'; } } diff --git a/UI/Web/src/app/reading-list/_pipes/cbl-import-result.pipe.ts b/UI/Web/src/app/reading-list/_pipes/cbl-import-result.pipe.ts index 6675b1b02..fef49d805 100644 --- a/UI/Web/src/app/reading-list/_pipes/cbl-import-result.pipe.ts +++ b/UI/Web/src/app/reading-list/_pipes/cbl-import-result.pipe.ts @@ -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'; } diff --git a/UI/Web/src/app/reading-list/reading-list.module.ts b/UI/Web/src/app/reading-list/reading-list.module.ts index a9c315a61..6cd2ba2d8 100644 --- a/UI/Web/src/app/reading-list/reading-list.module.ts +++ b/UI/Web/src/app/reading-list/reading-list.module.ts @@ -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, diff --git a/openapi.json b/openapi.json index 44fc2e5af..4972715c1 100644 --- a/openapi.json +++ b/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": {