mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-05-24 00:52:23 -04:00
On Deck + Misc Fixes and Changes (#1215)
* Added playwright and started writing e2e tests. * To make things easy, disabled other browsers while I get confortable. Added a login flow (assumes my dev env) * More tests on login page * Lots more testing code, trying to figure out auth code. * Ensure we don't track DBs inside config * Added a new date property for when chapters are added to a series which helps with OnDeck calculations. Changed a lot of heavy api calls to use IEnumerable to stream repsonse to UI. * Fixed OnDeck with a new field for when last chapter was added on Series. This is a streamlined way to query. Updated Reading List with NormalizedTitle, CoverImage, CoverImageLocked. * Implemented the ability to read a random item in the reading list and for the reading list to be intact for order. * Tweaked the style for webtoon to not span the whole width, but use max width * When we update a cover image just send an event so we don't need to have logic for when updates occur * Fixed a bad name for entity type on cover updates * Aligned the edit collection tag modal to align with new tab design * Rewrote code for picking the first file for metadata to ensure it always picks the correct file, esp if the first chapter of a series starts with a float (1.1) * Refactored setting LastChapterAdded to ensure we do it on the Series. * Updated Chapter updating in scan loop to avoid nested for loop and an additional loop. * Fixed a bug where locked person fields wouldn't persist between scans. * Updated Contributing to reflect how to view the swagger api
This commit is contained in:
parent
912dfa8a80
commit
3bbb02f574
4
.gitignore
vendored
4
.gitignore
vendored
@ -517,10 +517,12 @@ UI/Web/dist/
|
||||
/API/config/kavita.db-shm
|
||||
/API/config/kavita.db-wal
|
||||
/API/config/kavita.db-journal
|
||||
/API/config/*.db
|
||||
/API/config/*.bak
|
||||
/API/config/*.backup
|
||||
/API/config/Hangfire.db
|
||||
/API/config/Hangfire-log.db
|
||||
API/config/covers/
|
||||
API/config/*.db
|
||||
API/config/stats/*
|
||||
API/config/stats/app_stats.json
|
||||
API/config/pre-metadata/
|
||||
|
@ -9,67 +9,6 @@ namespace API.Tests.Extensions;
|
||||
|
||||
public class VolumeListExtensionsTests
|
||||
{
|
||||
#region FirstWithChapters
|
||||
|
||||
[Fact]
|
||||
public void FirstWithChapters_ReturnsVolumeWithChapters()
|
||||
{
|
||||
var volumes = new List<Volume>()
|
||||
{
|
||||
EntityFactory.CreateVolume("0", new List<Chapter>()),
|
||||
EntityFactory.CreateVolume("1", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("1", false),
|
||||
EntityFactory.CreateChapter("2", false),
|
||||
}),
|
||||
};
|
||||
|
||||
Assert.Equal(volumes[1].Number, volumes.FirstWithChapters(false).Number);
|
||||
Assert.Equal(volumes[1].Number, volumes.FirstWithChapters(true).Number);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FirstWithChapters_Book()
|
||||
{
|
||||
var volumes = new List<Volume>()
|
||||
{
|
||||
EntityFactory.CreateVolume("1", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("3", false),
|
||||
EntityFactory.CreateChapter("4", false),
|
||||
}),
|
||||
EntityFactory.CreateVolume("0", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("1", false),
|
||||
EntityFactory.CreateChapter("0", true),
|
||||
}),
|
||||
};
|
||||
|
||||
Assert.Equal(volumes[0].Number, volumes.FirstWithChapters(true).Number);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FirstWithChapters_NonBook()
|
||||
{
|
||||
var volumes = new List<Volume>()
|
||||
{
|
||||
EntityFactory.CreateVolume("1", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("3", false),
|
||||
EntityFactory.CreateChapter("4", false),
|
||||
}),
|
||||
EntityFactory.CreateVolume("0", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("1", false),
|
||||
EntityFactory.CreateChapter("0", true),
|
||||
}),
|
||||
};
|
||||
|
||||
Assert.Equal(volumes[0].Number, volumes.FirstWithChapters(false).Number);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetCoverImage
|
||||
|
||||
[Fact]
|
||||
|
@ -17,6 +17,7 @@ namespace API.Tests.Services
|
||||
{
|
||||
private readonly ILogger<DirectoryService> _logger = Substitute.For<ILogger<DirectoryService>>();
|
||||
|
||||
|
||||
#region TraverseTreeParallelForEach
|
||||
[Fact]
|
||||
public void TraverseTreeParallelForEach_JustArchives_ShouldBe28()
|
||||
@ -575,19 +576,22 @@ namespace API.Tests.Services
|
||||
[Fact]
|
||||
public void CopyFilesToDirectory_ShouldAppendWhenTargetFileExists()
|
||||
{
|
||||
|
||||
const string testDirectory = "/manga/";
|
||||
var fileSystem = new MockFileSystem();
|
||||
fileSystem.AddFile($"{testDirectory}file.zip", new MockFileData(""));
|
||||
fileSystem.AddFile($"/manga/output/file (1).zip", new MockFileData(""));
|
||||
fileSystem.AddFile($"/manga/output/file (2).zip", new MockFileData(""));
|
||||
fileSystem.AddFile(MockUnixSupport.Path($"{testDirectory}file.zip"), new MockFileData(""));
|
||||
fileSystem.AddFile(MockUnixSupport.Path($"/manga/output/file (1).zip"), new MockFileData(""));
|
||||
fileSystem.AddFile(MockUnixSupport.Path($"/manga/output/file (2).zip"), new MockFileData(""));
|
||||
|
||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
|
||||
ds.CopyFilesToDirectory(new []{$"{testDirectory}file.zip"}, "/manga/output/");
|
||||
ds.CopyFilesToDirectory(new []{$"{testDirectory}file.zip"}, "/manga/output/");
|
||||
ds.CopyFilesToDirectory(new []{MockUnixSupport.Path($"{testDirectory}file.zip")}, "/manga/output/");
|
||||
ds.CopyFilesToDirectory(new []{MockUnixSupport.Path($"{testDirectory}file.zip")}, "/manga/output/");
|
||||
var outputFiles = ds.GetFiles("/manga/output/").Select(API.Parser.Parser.NormalizePath).ToList();
|
||||
Assert.Equal(4, outputFiles.Count()); // we have 2 already there and 2 copies
|
||||
// For some reason, this has C:/ on directory even though everything is emulated
|
||||
Assert.True(outputFiles.Contains(API.Parser.Parser.NormalizePath("/manga/output/file (3).zip")) || outputFiles.Contains(API.Parser.Parser.NormalizePath("C:/manga/output/file (3).zip")));
|
||||
// For some reason, this has C:/ on directory even though everything is emulated (System.IO.Abstractions issue, not changing)
|
||||
// https://github.com/TestableIO/System.IO.Abstractions/issues/831
|
||||
Assert.True(outputFiles.Contains(API.Parser.Parser.NormalizePath("/manga/output/file (3).zip"))
|
||||
|| outputFiles.Contains(API.Parser.Parser.NormalizePath("C:/manga/output/file (3).zip")));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
@ -22,6 +22,7 @@ using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace API.Tests.Services;
|
||||
|
||||
@ -703,6 +704,85 @@ public class SeriesServiceTests
|
||||
Assert.False(series.Metadata.GenresLocked); // GenreLocked is false unless the UI Explicitly says it should be locked
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeriesMetadata_ShouldAddNewPerson_NoExistingPeople()
|
||||
{
|
||||
await ResetDb();
|
||||
var s = new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Library = new Library()
|
||||
{
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Book,
|
||||
},
|
||||
Metadata = DbFactory.SeriesMetadata(new List<CollectionTag>())
|
||||
};
|
||||
var g = DbFactory.Person("Existing Person", PersonRole.Publisher);
|
||||
_context.Series.Add(s);
|
||||
|
||||
_context.Person.Add(g);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto()
|
||||
{
|
||||
SeriesMetadata = new SeriesMetadataDto()
|
||||
{
|
||||
SeriesId = 1,
|
||||
Publishers = new List<PersonDto>() {new () {Id = 0, Name = "Existing Person", Role = PersonRole.Publisher}},
|
||||
},
|
||||
CollectionTags = new List<CollectionTagDto>()
|
||||
});
|
||||
|
||||
Assert.True(success);
|
||||
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1);
|
||||
Assert.NotNull(series.Metadata);
|
||||
Assert.True(series.Metadata.People.Select(g => g.Name).All(g => g == "Existing Person"));
|
||||
Assert.False(series.Metadata.PublisherLocked); // PublisherLocked is false unless the UI Explicitly says it should be locked
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeriesMetadata_ShouldAddNewPerson_ExistingPeople()
|
||||
{
|
||||
await ResetDb();
|
||||
var s = new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Library = new Library()
|
||||
{
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Book,
|
||||
},
|
||||
Metadata = DbFactory.SeriesMetadata(new List<CollectionTag>())
|
||||
};
|
||||
var g = DbFactory.Person("Existing Person", PersonRole.Publisher);
|
||||
s.Metadata.People = new List<Person>() {DbFactory.Person("Existing Writer", PersonRole.Writer),
|
||||
DbFactory.Person("Existing Translator", PersonRole.Translator), DbFactory.Person("Existing Publisher 2", PersonRole.Publisher)};
|
||||
_context.Series.Add(s);
|
||||
|
||||
_context.Person.Add(g);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto()
|
||||
{
|
||||
SeriesMetadata = new SeriesMetadataDto()
|
||||
{
|
||||
SeriesId = 1,
|
||||
Publishers = new List<PersonDto>() {new () {Id = 0, Name = "Existing Person", Role = PersonRole.Publisher}},
|
||||
PublisherLocked = true
|
||||
},
|
||||
CollectionTags = new List<CollectionTagDto>()
|
||||
});
|
||||
|
||||
Assert.True(success);
|
||||
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1);
|
||||
Assert.NotNull(series.Metadata);
|
||||
Assert.True(series.Metadata.People.Select(g => g.Name).All(g => g == "Existing Person"));
|
||||
Assert.True(series.Metadata.PublisherLocked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeriesMetadata_ShouldLockIfTold()
|
||||
{
|
||||
@ -745,4 +825,86 @@ public class SeriesServiceTests
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetFirstChapterForMetadata
|
||||
|
||||
private static Series CreateSeriesMock()
|
||||
{
|
||||
var files = new List<MangaFile>()
|
||||
{
|
||||
EntityFactory.CreateMangaFile("Test.cbz", MangaFormat.Archive, 1)
|
||||
};
|
||||
return new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Library = new Library()
|
||||
{
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Manga,
|
||||
},
|
||||
Volumes = new List<Volume>()
|
||||
{
|
||||
EntityFactory.CreateVolume("0", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("95", false, files, 1),
|
||||
EntityFactory.CreateChapter("96", false, files, 1),
|
||||
EntityFactory.CreateChapter("A Special Case", true, files, 1),
|
||||
}),
|
||||
EntityFactory.CreateVolume("1", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("1", false, files, 1),
|
||||
EntityFactory.CreateChapter("2", false, files, 1),
|
||||
}),
|
||||
EntityFactory.CreateVolume("2", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("21", false, files, 1),
|
||||
EntityFactory.CreateChapter("22", false, files, 1),
|
||||
}),
|
||||
EntityFactory.CreateVolume("3", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("31", false, files, 1),
|
||||
EntityFactory.CreateChapter("32", false, files, 1),
|
||||
}),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFirstChapterForMetadata_Book_Test()
|
||||
{
|
||||
var series = CreateSeriesMock();
|
||||
|
||||
var firstChapter = SeriesService.GetFirstChapterForMetadata(series, true);
|
||||
Assert.Same("1", firstChapter.Range);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFirstChapterForMetadata_NonBook_ShouldReturnVolume1()
|
||||
{
|
||||
var series = CreateSeriesMock();
|
||||
|
||||
var firstChapter = SeriesService.GetFirstChapterForMetadata(series, false);
|
||||
Assert.Same("1", firstChapter.Range);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFirstChapterForMetadata_NonBook_ShouldReturnVolume1_WhenFirstChapterIsFloat()
|
||||
{
|
||||
var series = CreateSeriesMock();
|
||||
var files = new List<MangaFile>()
|
||||
{
|
||||
EntityFactory.CreateMangaFile("Test.cbz", MangaFormat.Archive, 1)
|
||||
};
|
||||
series.Volumes[1].Chapters = new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("2", false, files, 1),
|
||||
EntityFactory.CreateChapter("1.1", false, files, 1),
|
||||
EntityFactory.CreateChapter("1.2", false, files, 1),
|
||||
};
|
||||
|
||||
var firstChapter = SeriesService.GetFirstChapterForMetadata(series, false);
|
||||
Assert.Same("1.1", firstChapter.Range);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
@ -157,7 +157,7 @@ namespace API.Controllers
|
||||
tag.CoverImageLocked = false;
|
||||
tag.CoverImage = string.Empty;
|
||||
await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate,
|
||||
MessageFactory.CoverUpdateEvent(tag.Id, "collectionTag"), false);
|
||||
MessageFactory.CoverUpdateEvent(tag.Id, "collection"), false);
|
||||
_unitOfWork.CollectionTagRepository.Update(tag);
|
||||
}
|
||||
|
||||
|
@ -88,6 +88,22 @@ namespace API.Controllers
|
||||
return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns cover image for a Reading List
|
||||
/// </summary>
|
||||
/// <param name="readingListId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("readinglist-cover")]
|
||||
public async Task<ActionResult> GetReadingListCoverImage(int readingListId)
|
||||
{
|
||||
var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.ReadingListRepository.GetCoverImageAsync(readingListId));
|
||||
if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"No cover image");
|
||||
var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", "");
|
||||
|
||||
Response.AddCacheHeader(path);
|
||||
return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns image for a given bookmark page
|
||||
/// </summary>
|
||||
|
@ -7,6 +7,7 @@ using API.DTOs.ReadingLists;
|
||||
using API.Entities;
|
||||
using API.Extensions;
|
||||
using API.Helpers;
|
||||
using API.SignalR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace API.Controllers
|
||||
@ -14,11 +15,13 @@ namespace API.Controllers
|
||||
public class ReadingListController : BaseApiController
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IEventHub _eventHub;
|
||||
private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst();
|
||||
|
||||
public ReadingListController(IUnitOfWork unitOfWork)
|
||||
public ReadingListController(IUnitOfWork unitOfWork, IEventHub eventHub)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_eventHub = eventHub;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -233,9 +236,12 @@ namespace API.Controllers
|
||||
var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(dto.ReadingListId);
|
||||
if (readingList == null) return BadRequest("List does not exist");
|
||||
|
||||
|
||||
|
||||
if (!string.IsNullOrEmpty(dto.Title))
|
||||
{
|
||||
readingList.Title = dto.Title; // Should I check if this is unique?
|
||||
readingList.NormalizedTitle = Parser.Parser.Normalize(readingList.Title);
|
||||
}
|
||||
if (!string.IsNullOrEmpty(dto.Title))
|
||||
{
|
||||
@ -244,6 +250,19 @@ namespace API.Controllers
|
||||
|
||||
readingList.Promoted = dto.Promoted;
|
||||
|
||||
readingList.CoverImageLocked = dto.CoverImageLocked;
|
||||
|
||||
if (!dto.CoverImageLocked)
|
||||
{
|
||||
readingList.CoverImageLocked = false;
|
||||
readingList.CoverImage = string.Empty;
|
||||
await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate,
|
||||
MessageFactory.CoverUpdateEvent(readingList.Id, "readingList"), false);
|
||||
_unitOfWork.ReadingListRepository.Update(readingList);
|
||||
}
|
||||
|
||||
|
||||
|
||||
_unitOfWork.ReadingListRepository.Update(readingList);
|
||||
|
||||
if (await _unitOfWork.CommitAsync())
|
||||
|
@ -214,13 +214,6 @@ namespace API.Controllers
|
||||
return Ok(await _unitOfWork.SeriesRepository.GetRecentlyUpdatedSeries(userId));
|
||||
}
|
||||
|
||||
[HttpPost("recently-added-chapters")]
|
||||
public async Task<ActionResult<IEnumerable<RecentlyAddedItemDto>>> GetRecentlyAddedChaptersAlt()
|
||||
{
|
||||
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
|
||||
return Ok(await _unitOfWork.SeriesRepository.GetRecentlyAddedChapters(userId));
|
||||
}
|
||||
|
||||
[HttpPost("all")]
|
||||
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetAllSeries(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0)
|
||||
{
|
||||
|
@ -5,6 +5,7 @@ using API.Data;
|
||||
using API.DTOs.Uploads;
|
||||
using API.Extensions;
|
||||
using API.Services;
|
||||
using API.SignalR;
|
||||
using Flurl.Http;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@ -24,16 +25,18 @@ namespace API.Controllers
|
||||
private readonly ILogger<UploadController> _logger;
|
||||
private readonly ITaskScheduler _taskScheduler;
|
||||
private readonly IDirectoryService _directoryService;
|
||||
private readonly IEventHub _eventHub;
|
||||
|
||||
/// <inheritdoc />
|
||||
public UploadController(IUnitOfWork unitOfWork, IImageService imageService, ILogger<UploadController> logger,
|
||||
ITaskScheduler taskScheduler, IDirectoryService directoryService)
|
||||
ITaskScheduler taskScheduler, IDirectoryService directoryService, IEventHub eventHub)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_imageService = imageService;
|
||||
_logger = logger;
|
||||
_taskScheduler = taskScheduler;
|
||||
_directoryService = directoryService;
|
||||
_eventHub = eventHub;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -145,6 +148,8 @@ namespace API.Controllers
|
||||
if (_unitOfWork.HasChanges())
|
||||
{
|
||||
await _unitOfWork.CommitAsync();
|
||||
await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate,
|
||||
MessageFactory.CoverUpdateEvent(tag.Id, "collection"), false);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@ -158,6 +163,53 @@ namespace API.Controllers
|
||||
return BadRequest("Unable to save cover image to Collection Tag");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces reading list cover image and locks it with a base64 encoded image
|
||||
/// </summary>
|
||||
/// <param name="uploadFileDto"></param>
|
||||
/// <returns></returns>
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
[RequestSizeLimit(8_000_000)]
|
||||
[HttpPost("reading-list")]
|
||||
public async Task<ActionResult> UploadReadingListCoverImageFromUrl(UploadFileDto uploadFileDto)
|
||||
{
|
||||
// Check if Url is non empty, request the image and place in temp, then ask image service to handle it.
|
||||
// See if we can do this all in memory without touching underlying system
|
||||
if (string.IsNullOrEmpty(uploadFileDto.Url))
|
||||
{
|
||||
return BadRequest("You must pass a url to use");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var filePath = _imageService.CreateThumbnailFromBase64(uploadFileDto.Url, $"{ImageService.GetReadingListFormat(uploadFileDto.Id)}");
|
||||
var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(uploadFileDto.Id);
|
||||
|
||||
if (!string.IsNullOrEmpty(filePath))
|
||||
{
|
||||
readingList.CoverImage = filePath;
|
||||
readingList.CoverImageLocked = true;
|
||||
_unitOfWork.ReadingListRepository.Update(readingList);
|
||||
}
|
||||
|
||||
if (_unitOfWork.HasChanges())
|
||||
{
|
||||
await _unitOfWork.CommitAsync();
|
||||
await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate,
|
||||
MessageFactory.CoverUpdateEvent(readingList.Id, "readingList"), false);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "There was an issue uploading cover image for Reading List {Id}", uploadFileDto.Id);
|
||||
await _unitOfWork.RollbackAsync();
|
||||
}
|
||||
|
||||
return BadRequest("Unable to save cover image to Reading List");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces chapter cover image and locks it with a base64 encoded image. This will update the parent volume's cover image.
|
||||
/// </summary>
|
||||
|
@ -9,5 +9,6 @@
|
||||
/// Reading lists that are promoted are only done by admins
|
||||
/// </summary>
|
||||
public bool Promoted { get; set; }
|
||||
public bool CoverImageLocked { get; set; }
|
||||
}
|
||||
}
|
||||
|
@ -6,5 +6,6 @@
|
||||
public string Title { get; set; }
|
||||
public string Summary { get; set; }
|
||||
public bool Promoted { get; set; }
|
||||
public bool CoverImageLocked { get; set; }
|
||||
}
|
||||
}
|
||||
|
1469
API/Data/Migrations/20220410230540_SeriesLastChapterAddedAndReadingListNormalization.Designer.cs
generated
Normal file
1469
API/Data/Migrations/20220410230540_SeriesLastChapterAddedAndReadingListNormalization.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,58 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
public partial class SeriesLastChapterAddedAndReadingListNormalization : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "LastChapterAdded",
|
||||
table: "Series",
|
||||
type: "TEXT",
|
||||
nullable: false,
|
||||
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CoverImage",
|
||||
table: "ReadingList",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "CoverImageLocked",
|
||||
table: "ReadingList",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "NormalizedTitle",
|
||||
table: "ReadingList",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LastChapterAdded",
|
||||
table: "Series");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CoverImage",
|
||||
table: "ReadingList");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CoverImageLocked",
|
||||
table: "ReadingList");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "NormalizedTitle",
|
||||
table: "ReadingList");
|
||||
}
|
||||
}
|
||||
}
|
@ -15,7 +15,7 @@ namespace API.Data.Migrations
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "6.0.2");
|
||||
modelBuilder.HasAnnotation("ProductVersion", "6.0.3");
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppRole", b =>
|
||||
{
|
||||
@ -621,12 +621,21 @@ namespace API.Data.Migrations
|
||||
b.Property<int>("AppUserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CoverImage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("CoverImageLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("Created")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastModified")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedTitle")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Promoted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@ -695,6 +704,9 @@ namespace API.Data.Migrations
|
||||
b.Property<int>("Format")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("LastChapterAdded")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastModified")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
@ -26,6 +26,7 @@ public interface IReadingListRepository
|
||||
void BulkRemove(IEnumerable<ReadingListItem> items);
|
||||
void Update(ReadingList list);
|
||||
Task<int> Count();
|
||||
Task<string> GetCoverImageAsync(int readingListId);
|
||||
}
|
||||
|
||||
public class ReadingListRepository : IReadingListRepository
|
||||
@ -49,6 +50,15 @@ public class ReadingListRepository : IReadingListRepository
|
||||
return await _context.ReadingList.CountAsync();
|
||||
}
|
||||
|
||||
public async Task<string> GetCoverImageAsync(int readingListId)
|
||||
{
|
||||
return await _context.ReadingList
|
||||
.Where(c => c.Id == readingListId)
|
||||
.Select(c => c.CoverImage)
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
public void Remove(ReadingListItem item)
|
||||
{
|
||||
_context.ReadingListItem.Remove(item);
|
||||
|
@ -95,8 +95,7 @@ public interface ISeriesRepository
|
||||
Task<IList<AgeRatingDto>> GetAllAgeRatingsDtosForLibrariesAsync(List<int> libraryIds);
|
||||
Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync(List<int> libraryIds);
|
||||
Task<IList<PublicationStatusDto>> GetAllPublicationStatusesDtosForLibrariesAsync(List<int> libraryIds);
|
||||
Task<IList<GroupedSeriesDto>> GetRecentlyUpdatedSeries(int userId);
|
||||
Task<IList<RecentlyAddedItemDto>> GetRecentlyAddedChapters(int userId);
|
||||
Task<IEnumerable<GroupedSeriesDto>> GetRecentlyUpdatedSeries(int userId);
|
||||
}
|
||||
|
||||
public class SeriesRepository : ISeriesRepository
|
||||
@ -607,7 +606,6 @@ public class SeriesRepository : ISeriesRepository
|
||||
/// <returns></returns>
|
||||
public async Task<IEnumerable<SeriesDto>> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter, bool cutoffOnDate = true)
|
||||
{
|
||||
var cutoffProgressPoint = DateTime.Now - TimeSpan.FromDays(30);
|
||||
var query = (await CreateFilteredSearchQueryable(userId, libraryId, filter))
|
||||
.Join(_context.AppUserProgresses, s => s.Id, progress => progress.SeriesId, (s, progress) =>
|
||||
new
|
||||
@ -619,24 +617,19 @@ public class SeriesRepository : ISeriesRepository
|
||||
LastReadingProgress = _context.AppUserProgresses
|
||||
.Where(p => p.Id == progress.Id && p.AppUserId == userId)
|
||||
.Max(p => p.LastModified),
|
||||
// This is only taking into account chapters that have progress on them, not all chapters in said series
|
||||
//LastChapterCreated = _context.Chapter.Where(c => progress.ChapterId == c.Id).Max(c => c.Created),
|
||||
LastChapterCreated = s.Volumes.SelectMany(v => v.Chapters).Max(c => c.Created)
|
||||
s.LastChapterAdded
|
||||
});
|
||||
if (cutoffOnDate)
|
||||
{
|
||||
query = query.Where(d => d.LastReadingProgress >= cutoffProgressPoint || d.LastChapterCreated >= cutoffProgressPoint);
|
||||
var cutoffProgressPoint = DateTime.Now - TimeSpan.FromDays(30);
|
||||
query = query.Where(d => d.LastReadingProgress >= cutoffProgressPoint || d.LastChapterAdded >= cutoffProgressPoint);
|
||||
}
|
||||
|
||||
// I think I need another Join statement. The problem is the chapters are still limited to progress
|
||||
|
||||
|
||||
|
||||
var retSeries = query.Where(s => s.AppUserId == userId
|
||||
&& s.PagesRead > 0
|
||||
&& s.PagesRead < s.Series.Pages)
|
||||
.OrderByDescending(s => s.LastReadingProgress)
|
||||
.ThenByDescending(s => s.LastChapterCreated)
|
||||
.ThenByDescending(s => s.LastChapterAdded)
|
||||
.Select(s => s.Series)
|
||||
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
|
||||
.AsSplitQuery()
|
||||
@ -903,87 +896,16 @@ public class SeriesRepository : ISeriesRepository
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
private static string RecentlyAddedItemTitle(RecentlyAddedSeries item)
|
||||
{
|
||||
switch (item.LibraryType)
|
||||
{
|
||||
case LibraryType.Book:
|
||||
return string.Empty;
|
||||
case LibraryType.Comic:
|
||||
return "Issue";
|
||||
case LibraryType.Manga:
|
||||
default:
|
||||
return "Chapter";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Show all recently added chapters. Provide some mapping for chapter 0 -> Volume 1
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<IList<RecentlyAddedItemDto>> GetRecentlyAddedChapters(int userId)
|
||||
{
|
||||
var ret = await GetRecentlyAddedChaptersQuery(userId);
|
||||
|
||||
var items = new List<RecentlyAddedItemDto>();
|
||||
foreach (var item in ret)
|
||||
{
|
||||
var dto = new RecentlyAddedItemDto()
|
||||
{
|
||||
LibraryId = item.LibraryId,
|
||||
LibraryType = item.LibraryType,
|
||||
SeriesId = item.SeriesId,
|
||||
SeriesName = item.SeriesName,
|
||||
Created = item.Created,
|
||||
Id = items.Count,
|
||||
Format = item.Format
|
||||
};
|
||||
|
||||
// Add title and Volume/Chapter Id
|
||||
var chapterTitle = RecentlyAddedItemTitle(item);
|
||||
string title;
|
||||
if (item.ChapterNumber.Equals(Parser.Parser.DefaultChapter))
|
||||
{
|
||||
if ((item.VolumeNumber + string.Empty).Equals(Parser.Parser.DefaultChapter))
|
||||
{
|
||||
title = item.ChapterTitle;
|
||||
}
|
||||
else
|
||||
{
|
||||
title = "Volume " + item.VolumeNumber;
|
||||
}
|
||||
|
||||
dto.VolumeId = item.VolumeId;
|
||||
}
|
||||
else
|
||||
{
|
||||
title = item.IsSpecial
|
||||
? item.ChapterRange
|
||||
: $"{chapterTitle} {item.ChapterRange}";
|
||||
dto.ChapterId = item.ChapterId;
|
||||
}
|
||||
|
||||
dto.Title = title;
|
||||
items.Add(dto);
|
||||
}
|
||||
|
||||
|
||||
return items;
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Return recently updated series, regardless of read progress, and group the number of volume or chapters added.
|
||||
/// </summary>
|
||||
/// <param name="userId">Used to ensure user has access to libraries</param>
|
||||
/// <returns></returns>
|
||||
public async Task<IList<GroupedSeriesDto>> GetRecentlyUpdatedSeries(int userId)
|
||||
public async Task<IEnumerable<GroupedSeriesDto>> GetRecentlyUpdatedSeries(int userId)
|
||||
{
|
||||
var ret = await GetRecentlyAddedChaptersQuery(userId, 150);
|
||||
|
||||
|
||||
var seriesMap = new Dictionary<string, GroupedSeriesDto>();
|
||||
var index = 0;
|
||||
foreach (var item in ret)
|
||||
@ -1003,16 +925,50 @@ public class SeriesRepository : ISeriesRepository
|
||||
Created = item.Created,
|
||||
Id = index,
|
||||
Format = item.Format,
|
||||
Count = 1
|
||||
Count = 1,
|
||||
};
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return seriesMap.Values.ToList();
|
||||
return seriesMap.Values.AsEnumerable();
|
||||
|
||||
//return seriesMap.Values.ToList();
|
||||
|
||||
// var libraries = await _context.AppUser
|
||||
// .Where(u => u.Id == userId)
|
||||
// .SelectMany(u => u.Libraries.Select(l => new {LibraryId = l.Id, LibraryType = l.Type}))
|
||||
// .ToListAsync();
|
||||
// var libraryIds = libraries.Select(l => l.LibraryId).ToList();
|
||||
//
|
||||
// var cuttoffDate = DateTime.Now - TimeSpan.FromDays(12);
|
||||
//
|
||||
// var ret2 = _context.Series
|
||||
// .Where(s => s.LastChapterAdded >= cuttoffDate
|
||||
// && libraryIds.Contains(s.LibraryId))
|
||||
// .Select((s) => new GroupedSeriesDto
|
||||
// {
|
||||
// LibraryId = s.LibraryId,
|
||||
// LibraryType = s.Library.Type,
|
||||
// SeriesId = s.Id,
|
||||
// SeriesName = s.Name,
|
||||
// //Created = s.LastChapterAdded, // Hmm on first migration this wont work
|
||||
// Created = s.Volumes.SelectMany(v => v.Chapters).Max(c => c.Created), // Hmm on first migration this wont work
|
||||
// Count = s.Volumes.SelectMany(v => v.Chapters).Count(c => c.Created >= cuttoffDate),
|
||||
// //Id = index,
|
||||
// Format = s.Format
|
||||
// })
|
||||
// .Take(50)
|
||||
// .OrderByDescending(c => c.Created)
|
||||
// .AsSplitQuery()
|
||||
// .AsEnumerable();
|
||||
//
|
||||
// return ret2;
|
||||
|
||||
|
||||
}
|
||||
|
||||
private async Task<List<RecentlyAddedSeries>> GetRecentlyAddedChaptersQuery(int userId, int maxRecords = 50)
|
||||
private async Task<IEnumerable<RecentlyAddedSeries>> GetRecentlyAddedChaptersQuery(int userId, int maxRecords = 50)
|
||||
{
|
||||
var libraries = await _context.AppUser
|
||||
.Where(u => u.Id == userId)
|
||||
@ -1021,7 +977,7 @@ public class SeriesRepository : ISeriesRepository
|
||||
var libraryIds = libraries.Select(l => l.LibraryId).ToList();
|
||||
|
||||
var withinLastWeek = DateTime.Now - TimeSpan.FromDays(12);
|
||||
var ret = await _context.Chapter
|
||||
var ret = _context.Chapter
|
||||
.Where(c => c.Created >= withinLastWeek)
|
||||
.AsNoTracking()
|
||||
.Include(c => c.Volume)
|
||||
@ -1045,8 +1001,9 @@ public class SeriesRepository : ISeriesRepository
|
||||
ChapterTitle = c.Title
|
||||
})
|
||||
.Take(maxRecords)
|
||||
.AsSplitQuery()
|
||||
.Where(c => c.Created >= withinLastWeek && libraryIds.Contains(c.LibraryId))
|
||||
.ToListAsync();
|
||||
.AsEnumerable();
|
||||
return ret;
|
||||
}
|
||||
}
|
||||
|
@ -11,11 +11,21 @@ namespace API.Entities
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public string Title { get; set; }
|
||||
/// <summary>
|
||||
/// A normalized string used to check if the reading list already exists in the DB
|
||||
/// </summary>
|
||||
public string NormalizedTitle { get; set; }
|
||||
public string Summary { get; set; }
|
||||
/// <summary>
|
||||
/// Reading lists that are promoted are only done by admins
|
||||
/// </summary>
|
||||
public bool Promoted { get; set; }
|
||||
/// <summary>
|
||||
/// Absolute path to the (managed) image file
|
||||
/// </summary>
|
||||
/// <remarks>The file is managed internally to Kavita's APPDIR</remarks>
|
||||
public string CoverImage { get; set; }
|
||||
public bool CoverImageLocked { get; set; }
|
||||
|
||||
public ICollection<ReadingListItem> Items { get; set; }
|
||||
public DateTime Created { get; set; }
|
||||
|
@ -62,6 +62,11 @@ namespace API.Entities
|
||||
public bool SortNameLocked { get; set; }
|
||||
public bool LocalizedNameLocked { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When a Chapter was last added onto the Series
|
||||
/// </summary>
|
||||
public DateTime LastChapterAdded { get; set; }
|
||||
|
||||
public SeriesMetadata Metadata { get; set; }
|
||||
public ICollection<AppUserRating> Ratings { get; set; } = new List<AppUserRating>();
|
||||
public ICollection<AppUserProgress> Progress { get; set; } = new List<AppUserProgress>();
|
||||
|
@ -8,14 +8,6 @@ namespace API.Extensions
|
||||
{
|
||||
public static class VolumeListExtensions
|
||||
{
|
||||
public static Volume FirstWithChapters(this IEnumerable<Volume> volumes, bool inBookSeries)
|
||||
{
|
||||
return inBookSeries
|
||||
? volumes.FirstOrDefault(v => v.Chapters.Any())
|
||||
: volumes.OrderBy(v => v.Number, new ChapterSortComparer())
|
||||
.FirstOrDefault(v => v.Chapters.Any());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selects the first Volume to get the cover image from. For a book with only a special, the special will be returned.
|
||||
/// If there are both specials and non-specials, then the first non-special will be returned.
|
||||
|
@ -64,16 +64,14 @@ public static class PersonHelper
|
||||
/// </summary>
|
||||
/// <param name="existingPeople"></param>
|
||||
/// <param name="removeAllExcept"></param>
|
||||
/// <param name="action">Callback for all entities that was removed</param>
|
||||
public static void KeepOnlySamePeopleBetweenLists(ICollection<Person> existingPeople, ICollection<Person> removeAllExcept, Action<Person> action = null)
|
||||
/// <param name="action">Callback for all entities that should be removed</param>
|
||||
public static void KeepOnlySamePeopleBetweenLists(IEnumerable<Person> existingPeople, ICollection<Person> removeAllExcept, Action<Person> action = null)
|
||||
{
|
||||
var existing = existingPeople.ToList();
|
||||
foreach (var person in existing)
|
||||
foreach (var person in existingPeople)
|
||||
{
|
||||
var existingPerson = removeAllExcept.FirstOrDefault(p => p.Role == person.Role && person.NormalizedName.Equals(p.NormalizedName));
|
||||
if (existingPerson == null)
|
||||
{
|
||||
existingPeople.Remove(person);
|
||||
action?.Invoke(person);
|
||||
}
|
||||
}
|
||||
|
@ -143,4 +143,16 @@ public class ImageService : IImageService
|
||||
{
|
||||
return $"tag{tagId}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the name format for a reading list cover image
|
||||
/// </summary>
|
||||
/// <param name="readingListId"></param>
|
||||
/// <returns></returns>
|
||||
public static string GetReadingListFormat(int readingListId)
|
||||
{
|
||||
return $"readinglist{readingListId}";
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
@ -33,7 +33,7 @@ public class ReadingItemService : IReadingItemService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the ComicInfo for the file if it exists. Null otherewise.
|
||||
/// Gets the ComicInfo for the file if it exists. Null otherwise.
|
||||
/// </summary>
|
||||
/// <param name="filePath">Fully qualified path of file</param>
|
||||
/// <returns></returns>
|
||||
|
@ -10,6 +10,7 @@ using API.DTOs.CollectionTags;
|
||||
using API.DTOs.Metadata;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Helpers;
|
||||
using API.SignalR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@ -41,6 +42,19 @@ public class SeriesService : ISeriesService
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the first chapter for a series to extract metadata from (ie Summary, etc)
|
||||
/// </summary>
|
||||
/// <param name="series"></param>
|
||||
/// <param name="isBookLibrary"></param>
|
||||
/// <returns></returns>
|
||||
public static Chapter GetFirstChapterForMetadata(Series series, bool isBookLibrary)
|
||||
{
|
||||
return series.Volumes.OrderBy(v => v.Number, new ChapterSortComparer())
|
||||
.SelectMany(v => v.Chapters.OrderBy(c => float.Parse(c.Number), new ChapterSortComparer()))
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateSeriesMetadata(UpdateSeriesMetadataDto updateSeriesMetadataDto)
|
||||
{
|
||||
try
|
||||
@ -154,6 +168,8 @@ public class SeriesService : ISeriesService
|
||||
if (!updateSeriesMetadataDto.SeriesMetadata.WriterLocked) series.Metadata.WriterLocked = false;
|
||||
if (!updateSeriesMetadataDto.SeriesMetadata.SummaryLocked) series.Metadata.SummaryLocked = false;
|
||||
|
||||
series.Metadata.PublisherLocked = updateSeriesMetadataDto.SeriesMetadata.PublisherLocked;
|
||||
|
||||
|
||||
|
||||
if (!_unitOfWork.HasChanges())
|
||||
@ -334,7 +350,7 @@ public class SeriesService : ISeriesService
|
||||
var existingTag = allTags.SingleOrDefault(t => t.Name == tag.Name && t.Role == tag.Role);
|
||||
if (existingTag != null)
|
||||
{
|
||||
if (series.Metadata.People.All(t => t.Name != tag.Name && t.Role == tag.Role))
|
||||
if (series.Metadata.People.Where(t => t.Role == tag.Role).All(t => !t.Name.Equals(tag.Name)))
|
||||
{
|
||||
handleAdd(existingTag);
|
||||
isModified = true;
|
||||
|
@ -564,8 +564,7 @@ public class ScannerService : IScannerService
|
||||
private static void UpdateSeriesMetadata(Series series, ICollection<Person> allPeople, ICollection<Genre> allGenres, ICollection<Tag> allTags, LibraryType libraryType)
|
||||
{
|
||||
var isBook = libraryType == LibraryType.Book;
|
||||
var firstVolume = series.Volumes.OrderBy(c => c.Number, new ChapterSortComparer()).FirstWithChapters(isBook);
|
||||
var firstChapter = firstVolume?.Chapters.GetFirstChapterWithFiles();
|
||||
var firstChapter = SeriesService.GetFirstChapterForMetadata(series, isBook);
|
||||
|
||||
var firstFile = firstChapter?.Files.FirstOrDefault();
|
||||
if (firstFile == null) return;
|
||||
@ -695,9 +694,50 @@ public class ScannerService : IScannerService
|
||||
}
|
||||
}
|
||||
|
||||
// BUG: The issue here is that people is just from chapter, but series metadata might already have some people on it
|
||||
// I might be able to filter out people that are in locked fields?
|
||||
var people = chapters.SelectMany(c => c.People).ToList();
|
||||
PersonHelper.KeepOnlySamePeopleBetweenLists(series.Metadata.People,
|
||||
people, person => series.Metadata.People.Remove(person));
|
||||
people, person =>
|
||||
{
|
||||
switch (person.Role)
|
||||
{
|
||||
case PersonRole.Writer:
|
||||
if (!series.Metadata.WriterLocked) series.Metadata.People.Remove(person);
|
||||
break;
|
||||
case PersonRole.Penciller:
|
||||
if (!series.Metadata.PencillerLocked) series.Metadata.People.Remove(person);
|
||||
break;
|
||||
case PersonRole.Inker:
|
||||
if (!series.Metadata.InkerLocked) series.Metadata.People.Remove(person);
|
||||
break;
|
||||
case PersonRole.Colorist:
|
||||
if (!series.Metadata.ColoristLocked) series.Metadata.People.Remove(person);
|
||||
break;
|
||||
case PersonRole.Letterer:
|
||||
if (!series.Metadata.LettererLocked) series.Metadata.People.Remove(person);
|
||||
break;
|
||||
case PersonRole.CoverArtist:
|
||||
if (!series.Metadata.CoverArtistLocked) series.Metadata.People.Remove(person);
|
||||
break;
|
||||
case PersonRole.Editor:
|
||||
if (!series.Metadata.EditorLocked) series.Metadata.People.Remove(person);
|
||||
break;
|
||||
case PersonRole.Publisher:
|
||||
if (!series.Metadata.PublisherLocked) series.Metadata.People.Remove(person);
|
||||
break;
|
||||
case PersonRole.Character:
|
||||
if (!series.Metadata.CharacterLocked) series.Metadata.People.Remove(person);
|
||||
break;
|
||||
case PersonRole.Translator:
|
||||
if (!series.Metadata.TranslatorLocked) series.Metadata.People.Remove(person);
|
||||
break;
|
||||
case PersonRole.Other:
|
||||
default:
|
||||
series.Metadata.People.Remove(person);
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -720,7 +760,7 @@ public class ScannerService : IScannerService
|
||||
|
||||
_logger.LogDebug("[ScannerService] Parsing {SeriesName} - Volume {VolumeNumber}", series.Name, volume.Name);
|
||||
var infos = parsedInfos.Where(p => p.Volumes == volumeNumber).ToArray();
|
||||
UpdateChapters(volume, infos);
|
||||
UpdateChapters(series, volume, infos);
|
||||
volume.Pages = volume.Chapters.Sum(c => c.Pages);
|
||||
|
||||
// Update all the metadata on the Chapters
|
||||
@ -767,7 +807,7 @@ public class ScannerService : IScannerService
|
||||
series.Name, startingVolumeCount, series.Volumes.Count);
|
||||
}
|
||||
|
||||
private void UpdateChapters(Volume volume, IList<ParserInfo> parsedInfos)
|
||||
private void UpdateChapters(Series series, Volume volume, IList<ParserInfo> parsedInfos)
|
||||
{
|
||||
// Add new chapters
|
||||
foreach (var info in parsedInfos)
|
||||
@ -789,30 +829,18 @@ public class ScannerService : IScannerService
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"[ScannerService] Adding new chapter, {Series} - Vol {Volume} Ch {Chapter}", info.Series, info.Volumes, info.Chapters);
|
||||
volume.Chapters.Add(DbFactory.Chapter(info));
|
||||
chapter = DbFactory.Chapter(info);
|
||||
volume.Chapters.Add(chapter);
|
||||
series.LastChapterAdded = DateTime.Now;
|
||||
}
|
||||
else
|
||||
{
|
||||
chapter.UpdateFrom(info);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Add files
|
||||
foreach (var info in parsedInfos)
|
||||
{
|
||||
var specialTreatment = info.IsSpecialInfo();
|
||||
Chapter chapter;
|
||||
try
|
||||
{
|
||||
chapter = volume.Chapters.GetChapterByRange(info);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "There was an exception parsing chapter. Skipping {SeriesName} Vol {VolumeNumber} Chapter {ChapterNumber} - Special treatment: {NeedsSpecialTreatment}", info.Series, volume.Name, info.Chapters, specialTreatment);
|
||||
continue;
|
||||
}
|
||||
if (chapter == null) continue;
|
||||
// Add files
|
||||
var specialTreatment = info.IsSpecialInfo();
|
||||
AddOrUpdateFileForChapter(chapter, info);
|
||||
chapter.Number = Parser.Parser.MinimumNumberFromRange(info.Chapters) + string.Empty;
|
||||
chapter.Range = specialTreatment ? info.Filename : info.Chapters;
|
||||
|
Binary file not shown.
Binary file not shown.
@ -54,4 +54,10 @@ Setup guides, FAQ, the more information we have on the [wiki](https://wiki.kavit
|
||||
- feature/parser-enhancements (Great)
|
||||
- bugfix/book-issues (Great)
|
||||
|
||||
### Swagger API ###
|
||||
If you just want to play with Swagger, you can just
|
||||
- cd Kavita/API
|
||||
- dotnet run -c Debug
|
||||
- Go to http://localhost:5000/swagger/index.html
|
||||
|
||||
If you have any questions about any of this, please let us know.
|
||||
|
@ -20,7 +20,7 @@ your reading collection with your friends and family!
|
||||
## Goals
|
||||
- [x] Serve up Manga/Webtoons/Comics (cbr, cbz, zip/rar, 7zip, raw images) and Books (epub, pdf)
|
||||
- [x] First class responsive readers that work great on any device (phone, tablet, desktop)
|
||||
- [x] Dark and Light themes
|
||||
- [x] Dark and Light themes (and customizable themes)
|
||||
- [ ] Provide hooks into metadata providers to fetch metadata for Comics, Manga, and Books
|
||||
- [x] Metadata should allow for collections, want to read integration from 3rd party services, genres.
|
||||
- [x] Ability to manage users, access, and ratings
|
||||
|
27
UI/Web/.github/workflows/playwright.yml
vendored
Normal file
27
UI/Web/.github/workflows/playwright.yml
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
name: Playwright Tests
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
pull_request:
|
||||
branches: [ main, master ]
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '14.x'
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Install Playwright
|
||||
run: npx playwright install --with-deps
|
||||
- name: Run Playwright tests
|
||||
run: npx playwright test
|
||||
- uses: actions/upload-artifact@v2
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
3
UI/Web/.gitignore
vendored
Normal file
3
UI/Web/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
test-results/
|
||||
playwright-report/
|
@ -20,7 +20,9 @@ Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.
|
||||
|
||||
## Running end-to-end tests
|
||||
|
||||
Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/).
|
||||
~~Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/).~~
|
||||
|
||||
Run `npx playwright test --reporter=line` or `npx playwright test` to run e2e tests.
|
||||
|
||||
## Further help
|
||||
|
||||
|
4
UI/Web/adminStorageState.json
Normal file
4
UI/Web/adminStorageState.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"cookies": [],
|
||||
"origins": []
|
||||
}
|
398
UI/Web/e2e/example.spec.ts.txt
Normal file
398
UI/Web/e2e/example.spec.ts.txt
Normal file
@ -0,0 +1,398 @@
|
||||
// import { test, expect, Page } from '@playwright/test';
|
||||
|
||||
// test.beforeEach(async ({ page }) => {
|
||||
// await page.goto('https://demo.playwright.dev/todomvc');
|
||||
// });
|
||||
|
||||
// const TODO_ITEMS = [
|
||||
// 'buy some cheese',
|
||||
// 'feed the cat',
|
||||
// 'book a doctors appointment'
|
||||
// ];
|
||||
|
||||
// test.describe('New Todo', () => {
|
||||
// test('should allow me to add todo items', async ({ page }) => {
|
||||
// // Create 1st todo.
|
||||
// await page.locator('.new-todo').fill(TODO_ITEMS[0]);
|
||||
// await page.locator('.new-todo').press('Enter');
|
||||
|
||||
// // Make sure the list only has one todo item.
|
||||
// await expect(page.locator('.view label')).toHaveText([
|
||||
// TODO_ITEMS[0]
|
||||
// ]);
|
||||
|
||||
// // Create 2nd todo.
|
||||
// await page.locator('.new-todo').fill(TODO_ITEMS[1]);
|
||||
// await page.locator('.new-todo').press('Enter');
|
||||
|
||||
// // Make sure the list now has two todo items.
|
||||
// await expect(page.locator('.view label')).toHaveText([
|
||||
// TODO_ITEMS[0],
|
||||
// TODO_ITEMS[1]
|
||||
// ]);
|
||||
|
||||
// await checkNumberOfTodosInLocalStorage(page, 2);
|
||||
// });
|
||||
|
||||
// test('should clear text input field when an item is added', async ({ page }) => {
|
||||
// // Create one todo item.
|
||||
// await page.locator('.new-todo').fill(TODO_ITEMS[0]);
|
||||
// await page.locator('.new-todo').press('Enter');
|
||||
|
||||
// // Check that input is empty.
|
||||
// await expect(page.locator('.new-todo')).toBeEmpty();
|
||||
// await checkNumberOfTodosInLocalStorage(page, 1);
|
||||
// });
|
||||
|
||||
// test('should append new items to the bottom of the list', async ({ page }) => {
|
||||
// // Create 3 items.
|
||||
// await createDefaultTodos(page);
|
||||
|
||||
// // Check test using different methods.
|
||||
// await expect(page.locator('.todo-count')).toHaveText('3 items left');
|
||||
// await expect(page.locator('.todo-count')).toContainText('3');
|
||||
// await expect(page.locator('.todo-count')).toHaveText(/3/);
|
||||
|
||||
// // Check all items in one call.
|
||||
// await expect(page.locator('.view label')).toHaveText(TODO_ITEMS);
|
||||
// await checkNumberOfTodosInLocalStorage(page, 3);
|
||||
// });
|
||||
|
||||
// test('should show #main and #footer when items added', async ({ page }) => {
|
||||
// await page.locator('.new-todo').fill(TODO_ITEMS[0]);
|
||||
// await page.locator('.new-todo').press('Enter');
|
||||
|
||||
// await expect(page.locator('.main')).toBeVisible();
|
||||
// await expect(page.locator('.footer')).toBeVisible();
|
||||
// await checkNumberOfTodosInLocalStorage(page, 1);
|
||||
// });
|
||||
// });
|
||||
|
||||
// test.describe('Mark all as completed', () => {
|
||||
// test.beforeEach(async ({ page }) => {
|
||||
// await createDefaultTodos(page);
|
||||
// await checkNumberOfTodosInLocalStorage(page, 3);
|
||||
// });
|
||||
|
||||
// test.afterEach(async ({ page }) => {
|
||||
// await checkNumberOfTodosInLocalStorage(page, 3);
|
||||
// });
|
||||
|
||||
// test('should allow me to mark all items as completed', async ({ page }) => {
|
||||
// // Complete all todos.
|
||||
// await page.locator('.toggle-all').check();
|
||||
|
||||
// // Ensure all todos have 'completed' class.
|
||||
// await expect(page.locator('.todo-list li')).toHaveClass(['completed', 'completed', 'completed']);
|
||||
// await checkNumberOfCompletedTodosInLocalStorage(page, 3);
|
||||
// });
|
||||
|
||||
// test('should allow me to clear the complete state of all items', async ({ page }) => {
|
||||
// // Check and then immediately uncheck.
|
||||
// await page.locator('.toggle-all').check();
|
||||
// await page.locator('.toggle-all').uncheck();
|
||||
|
||||
// // Should be no completed classes.
|
||||
// await expect(page.locator('.todo-list li')).toHaveClass(['', '', '']);
|
||||
// });
|
||||
|
||||
// test('complete all checkbox should update state when items are completed / cleared', async ({ page }) => {
|
||||
// const toggleAll = page.locator('.toggle-all');
|
||||
// await toggleAll.check();
|
||||
// await expect(toggleAll).toBeChecked();
|
||||
// await checkNumberOfCompletedTodosInLocalStorage(page, 3);
|
||||
|
||||
// // Uncheck first todo.
|
||||
// const firstTodo = page.locator('.todo-list li').nth(0);
|
||||
// await firstTodo.locator('.toggle').uncheck();
|
||||
|
||||
// // Reuse toggleAll locator and make sure its not checked.
|
||||
// await expect(toggleAll).not.toBeChecked();
|
||||
|
||||
// await firstTodo.locator('.toggle').check();
|
||||
// await checkNumberOfCompletedTodosInLocalStorage(page, 3);
|
||||
|
||||
// // Assert the toggle all is checked again.
|
||||
// await expect(toggleAll).toBeChecked();
|
||||
// });
|
||||
// });
|
||||
|
||||
// test.describe('Item', () => {
|
||||
|
||||
// test('should allow me to mark items as complete', async ({ page }) => {
|
||||
// // Create two items.
|
||||
// for (const item of TODO_ITEMS.slice(0, 2)) {
|
||||
// await page.locator('.new-todo').fill(item);
|
||||
// await page.locator('.new-todo').press('Enter');
|
||||
// }
|
||||
|
||||
// // Check first item.
|
||||
// const firstTodo = page.locator('.todo-list li').nth(0);
|
||||
// await firstTodo.locator('.toggle').check();
|
||||
// await expect(firstTodo).toHaveClass('completed');
|
||||
|
||||
// // Check second item.
|
||||
// const secondTodo = page.locator('.todo-list li').nth(1);
|
||||
// await expect(secondTodo).not.toHaveClass('completed');
|
||||
// await secondTodo.locator('.toggle').check();
|
||||
|
||||
// // Assert completed class.
|
||||
// await expect(firstTodo).toHaveClass('completed');
|
||||
// await expect(secondTodo).toHaveClass('completed');
|
||||
// });
|
||||
|
||||
// test('should allow me to un-mark items as complete', async ({ page }) => {
|
||||
// // Create two items.
|
||||
// for (const item of TODO_ITEMS.slice(0, 2)) {
|
||||
// await page.locator('.new-todo').fill(item);
|
||||
// await page.locator('.new-todo').press('Enter');
|
||||
// }
|
||||
|
||||
// const firstTodo = page.locator('.todo-list li').nth(0);
|
||||
// const secondTodo = page.locator('.todo-list li').nth(1);
|
||||
// await firstTodo.locator('.toggle').check();
|
||||
// await expect(firstTodo).toHaveClass('completed');
|
||||
// await expect(secondTodo).not.toHaveClass('completed');
|
||||
// await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
||||
|
||||
// await firstTodo.locator('.toggle').uncheck();
|
||||
// await expect(firstTodo).not.toHaveClass('completed');
|
||||
// await expect(secondTodo).not.toHaveClass('completed');
|
||||
// await checkNumberOfCompletedTodosInLocalStorage(page, 0);
|
||||
// });
|
||||
|
||||
// test('should allow me to edit an item', async ({ page }) => {
|
||||
// await createDefaultTodos(page);
|
||||
|
||||
// const todoItems = page.locator('.todo-list li');
|
||||
// const secondTodo = todoItems.nth(1);
|
||||
// await secondTodo.dblclick();
|
||||
// await expect(secondTodo.locator('.edit')).toHaveValue(TODO_ITEMS[1]);
|
||||
// await secondTodo.locator('.edit').fill('buy some sausages');
|
||||
// await secondTodo.locator('.edit').press('Enter');
|
||||
|
||||
// // Explicitly assert the new text value.
|
||||
// await expect(todoItems).toHaveText([
|
||||
// TODO_ITEMS[0],
|
||||
// 'buy some sausages',
|
||||
// TODO_ITEMS[2]
|
||||
// ]);
|
||||
// await checkTodosInLocalStorage(page, 'buy some sausages');
|
||||
// });
|
||||
// });
|
||||
|
||||
// test.describe('Editing', () => {
|
||||
// test.beforeEach(async ({ page }) => {
|
||||
// await createDefaultTodos(page);
|
||||
// await checkNumberOfTodosInLocalStorage(page, 3);
|
||||
// });
|
||||
|
||||
// test('should hide other controls when editing', async ({ page }) => {
|
||||
// const todoItem = page.locator('.todo-list li').nth(1);
|
||||
// await todoItem.dblclick();
|
||||
// await expect(todoItem.locator('.toggle')).not.toBeVisible();
|
||||
// await expect(todoItem.locator('label')).not.toBeVisible();
|
||||
// await checkNumberOfTodosInLocalStorage(page, 3);
|
||||
// });
|
||||
|
||||
// test('should save edits on blur', async ({ page }) => {
|
||||
// const todoItems = page.locator('.todo-list li');
|
||||
// await todoItems.nth(1).dblclick();
|
||||
// await todoItems.nth(1).locator('.edit').fill('buy some sausages');
|
||||
// await todoItems.nth(1).locator('.edit').dispatchEvent('blur');
|
||||
|
||||
// await expect(todoItems).toHaveText([
|
||||
// TODO_ITEMS[0],
|
||||
// 'buy some sausages',
|
||||
// TODO_ITEMS[2],
|
||||
// ]);
|
||||
// await checkTodosInLocalStorage(page, 'buy some sausages');
|
||||
// });
|
||||
|
||||
// test('should trim entered text', async ({ page }) => {
|
||||
// const todoItems = page.locator('.todo-list li');
|
||||
// await todoItems.nth(1).dblclick();
|
||||
// await todoItems.nth(1).locator('.edit').fill(' buy some sausages ');
|
||||
// await todoItems.nth(1).locator('.edit').press('Enter');
|
||||
|
||||
// await expect(todoItems).toHaveText([
|
||||
// TODO_ITEMS[0],
|
||||
// 'buy some sausages',
|
||||
// TODO_ITEMS[2],
|
||||
// ]);
|
||||
// await checkTodosInLocalStorage(page, 'buy some sausages');
|
||||
// });
|
||||
|
||||
// test('should remove the item if an empty text string was entered', async ({ page }) => {
|
||||
// const todoItems = page.locator('.todo-list li');
|
||||
// await todoItems.nth(1).dblclick();
|
||||
// await todoItems.nth(1).locator('.edit').fill('');
|
||||
// await todoItems.nth(1).locator('.edit').press('Enter');
|
||||
|
||||
// await expect(todoItems).toHaveText([
|
||||
// TODO_ITEMS[0],
|
||||
// TODO_ITEMS[2],
|
||||
// ]);
|
||||
// });
|
||||
|
||||
// test('should cancel edits on escape', async ({ page }) => {
|
||||
// const todoItems = page.locator('.todo-list li');
|
||||
// await todoItems.nth(1).dblclick();
|
||||
// await todoItems.nth(1).locator('.edit').press('Escape');
|
||||
// await expect(todoItems).toHaveText(TODO_ITEMS);
|
||||
// });
|
||||
// });
|
||||
|
||||
// test.describe('Counter', () => {
|
||||
// test('should display the current number of todo items', async ({ page }) => {
|
||||
// await page.locator('.new-todo').fill(TODO_ITEMS[0]);
|
||||
// await page.locator('.new-todo').press('Enter');
|
||||
// await expect(page.locator('.todo-count')).toContainText('1');
|
||||
|
||||
// await page.locator('.new-todo').fill(TODO_ITEMS[1]);
|
||||
// await page.locator('.new-todo').press('Enter');
|
||||
// await expect(page.locator('.todo-count')).toContainText('2');
|
||||
|
||||
// await checkNumberOfTodosInLocalStorage(page, 2);
|
||||
// });
|
||||
// });
|
||||
|
||||
// test.describe('Clear completed button', () => {
|
||||
// test.beforeEach(async ({ page }) => {
|
||||
// await createDefaultTodos(page);
|
||||
// });
|
||||
|
||||
// test('should display the correct text', async ({ page }) => {
|
||||
// await page.locator('.todo-list li .toggle').first().check();
|
||||
// await expect(page.locator('.clear-completed')).toHaveText('Clear completed');
|
||||
// });
|
||||
|
||||
// test('should remove completed items when clicked', async ({ page }) => {
|
||||
// const todoItems = page.locator('.todo-list li');
|
||||
// await todoItems.nth(1).locator('.toggle').check();
|
||||
// await page.locator('.clear-completed').click();
|
||||
// await expect(todoItems).toHaveCount(2);
|
||||
// await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]);
|
||||
// });
|
||||
|
||||
// test('should be hidden when there are no items that are completed', async ({ page }) => {
|
||||
// await page.locator('.todo-list li .toggle').first().check();
|
||||
// await page.locator('.clear-completed').click();
|
||||
// await expect(page.locator('.clear-completed')).toBeHidden();
|
||||
// });
|
||||
// });
|
||||
|
||||
// test.describe('Persistence', () => {
|
||||
// test('should persist its data', async ({ page }) => {
|
||||
// for (const item of TODO_ITEMS.slice(0, 2)) {
|
||||
// await page.locator('.new-todo').fill(item);
|
||||
// await page.locator('.new-todo').press('Enter');
|
||||
// }
|
||||
|
||||
// const todoItems = page.locator('.todo-list li');
|
||||
// await todoItems.nth(0).locator('.toggle').check();
|
||||
// await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]);
|
||||
// await expect(todoItems).toHaveClass(['completed', '']);
|
||||
|
||||
// // Ensure there is 1 completed item.
|
||||
// checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
||||
|
||||
// // Now reload.
|
||||
// await page.reload();
|
||||
// await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]);
|
||||
// await expect(todoItems).toHaveClass(['completed', '']);
|
||||
// });
|
||||
// });
|
||||
|
||||
// test.describe('Routing', () => {
|
||||
// test.beforeEach(async ({ page }) => {
|
||||
// await createDefaultTodos(page);
|
||||
// // make sure the app had a chance to save updated todos in storage
|
||||
// // before navigating to a new view, otherwise the items can get lost :(
|
||||
// // in some frameworks like Durandal
|
||||
// await checkTodosInLocalStorage(page, TODO_ITEMS[0]);
|
||||
// });
|
||||
|
||||
// test('should allow me to display active items', async ({ page }) => {
|
||||
// await page.locator('.todo-list li .toggle').nth(1).check();
|
||||
// await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
||||
// await page.locator('.filters >> text=Active').click();
|
||||
// await expect(page.locator('.todo-list li')).toHaveCount(2);
|
||||
// await expect(page.locator('.todo-list li')).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]);
|
||||
// });
|
||||
|
||||
// test('should respect the back button', async ({ page }) => {
|
||||
// await page.locator('.todo-list li .toggle').nth(1).check();
|
||||
// await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
||||
|
||||
// await test.step('Showing all items', async () => {
|
||||
// await page.locator('.filters >> text=All').click();
|
||||
// await expect(page.locator('.todo-list li')).toHaveCount(3);
|
||||
// });
|
||||
|
||||
// await test.step('Showing active items', async () => {
|
||||
// await page.locator('.filters >> text=Active').click();
|
||||
// });
|
||||
|
||||
// await test.step('Showing completed items', async () => {
|
||||
// await page.locator('.filters >> text=Completed').click();
|
||||
// });
|
||||
|
||||
// await expect(page.locator('.todo-list li')).toHaveCount(1);
|
||||
// await page.goBack();
|
||||
// await expect(page.locator('.todo-list li')).toHaveCount(2);
|
||||
// await page.goBack();
|
||||
// await expect(page.locator('.todo-list li')).toHaveCount(3);
|
||||
// });
|
||||
|
||||
// test('should allow me to display completed items', async ({ page }) => {
|
||||
// await page.locator('.todo-list li .toggle').nth(1).check();
|
||||
// await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
||||
// await page.locator('.filters >> text=Completed').click();
|
||||
// await expect(page.locator('.todo-list li')).toHaveCount(1);
|
||||
// });
|
||||
|
||||
// test('should allow me to display all items', async ({ page }) => {
|
||||
// await page.locator('.todo-list li .toggle').nth(1).check();
|
||||
// await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
||||
// await page.locator('.filters >> text=Active').click();
|
||||
// await page.locator('.filters >> text=Completed').click();
|
||||
// await page.locator('.filters >> text=All').click();
|
||||
// await expect(page.locator('.todo-list li')).toHaveCount(3);
|
||||
// });
|
||||
|
||||
// test('should highlight the currently applied filter', async ({ page }) => {
|
||||
// await expect(page.locator('.filters >> text=All')).toHaveClass('selected');
|
||||
// await page.locator('.filters >> text=Active').click();
|
||||
// // Page change - active items.
|
||||
// await expect(page.locator('.filters >> text=Active')).toHaveClass('selected');
|
||||
// await page.locator('.filters >> text=Completed').click();
|
||||
// // Page change - completed items.
|
||||
// await expect(page.locator('.filters >> text=Completed')).toHaveClass('selected');
|
||||
// });
|
||||
// });
|
||||
|
||||
// async function createDefaultTodos(page: Page) {
|
||||
// for (const item of TODO_ITEMS) {
|
||||
// await page.locator('.new-todo').fill(item);
|
||||
// await page.locator('.new-todo').press('Enter');
|
||||
// }
|
||||
// }
|
||||
|
||||
// async function checkNumberOfTodosInLocalStorage(page: Page, expected: number) {
|
||||
// return await page.waitForFunction(e => {
|
||||
// return JSON.parse(localStorage['react-todos']).length === e;
|
||||
// }, expected);
|
||||
// }
|
||||
|
||||
// async function checkNumberOfCompletedTodosInLocalStorage(page: Page, expected: number) {
|
||||
// return await page.waitForFunction(e => {
|
||||
// return JSON.parse(localStorage['react-todos']).filter(i => i.completed).length === e;
|
||||
// }, expected);
|
||||
// }
|
||||
|
||||
// async function checkTodosInLocalStorage(page: Page, title: string) {
|
||||
// return await page.waitForFunction(t => {
|
||||
// return JSON.parse(localStorage['react-todos']).map(i => i.title).includes(t);
|
||||
// }, title);
|
||||
// }
|
@ -1,23 +0,0 @@
|
||||
import { AppPage } from './app.po';
|
||||
import { browser, logging } from 'protractor';
|
||||
|
||||
describe('workspace-project App', () => {
|
||||
let page: AppPage;
|
||||
|
||||
beforeEach(() => {
|
||||
page = new AppPage();
|
||||
});
|
||||
|
||||
it('should display welcome message', async () => {
|
||||
await page.navigateTo();
|
||||
expect(await page.getTitleText()).toEqual('kavita-webui app is running!');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Assert that there are no errors emitted from the browser
|
||||
const logs = await browser.manage().logs().get(logging.Type.BROWSER);
|
||||
expect(logs).not.toContain(jasmine.objectContaining({
|
||||
level: logging.Level.SEVERE,
|
||||
} as logging.Entry));
|
||||
});
|
||||
});
|
18
UI/Web/e2e/src/app.spec.ts
Normal file
18
UI/Web/e2e/src/app.spec.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('When not authenticated, should be redirected to login page', async ({ page }) => {
|
||||
await page.goto('http://localhost:4200/', { waitUntil: 'networkidle' });
|
||||
expect(page.url()).toBe('http://localhost:4200/login');
|
||||
});
|
||||
|
||||
test('When not authenticated, should be redirected to login page from an authenticated page', async ({ page }) => {
|
||||
await page.goto('http://localhost:4200/library', { waitUntil: 'networkidle' });
|
||||
expect(page.url()).toBe('http://localhost:4200/login');
|
||||
});
|
||||
|
||||
// Not sure how to test when we need localStorage: https://github.com/microsoft/playwright/issues/6258
|
||||
// test('When authenticated, should be redirected to library page', async ({ page }) => {
|
||||
// await page.goto('http://localhost:4200/', { waitUntil: 'networkidle' });
|
||||
// console.log('url: ', page.url());
|
||||
// expect(page.url()).toBe('http://localhost:4200/library');
|
||||
// });
|
43
UI/Web/e2e/src/login/login.spec.ts
Normal file
43
UI/Web/e2e/src/login/login.spec.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test('When not authenticated, should be redirected to login page', async ({ page }) => {
|
||||
await page.goto('http://localhost:4200/', { waitUntil: 'networkidle' });
|
||||
expect(page.url()).toBe('http://localhost:4200/login');
|
||||
});
|
||||
|
||||
test('Should be able to log in', async ({ page }) => {
|
||||
|
||||
await page.goto('http://localhost:4200/login', { waitUntil: 'networkidle' });
|
||||
const username = page.locator('#username');
|
||||
expect(username).toBeEditable();
|
||||
const password = page.locator('#password');
|
||||
expect(password).toBeEditable();
|
||||
|
||||
await username.type('Joe');
|
||||
await password.type('P4ssword');
|
||||
|
||||
const button = page.locator('button[type="submit"]');
|
||||
await button.click();
|
||||
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(200);
|
||||
expect(page.url()).toBe('http://localhost:4200/library');
|
||||
});
|
||||
|
||||
test('Should get a toastr when no username', async ({ page }) => {
|
||||
|
||||
await page.goto('http://localhost:4200/login', { waitUntil: 'networkidle' });
|
||||
const username = page.locator('#username');
|
||||
expect(username).toBeEditable();
|
||||
|
||||
await username.type('');
|
||||
|
||||
const button = page.locator('button[type="submit"]');
|
||||
await button.click();
|
||||
|
||||
await page.waitForTimeout(100);
|
||||
const toastr = page.locator('#toast-container div[role="alertdialog"]')
|
||||
await expect(toastr).toHaveText('Invalid username');
|
||||
|
||||
expect(page.url()).toBe('http://localhost:4200/login');
|
||||
});
|
@ -0,0 +1,34 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test('When on login page, clicking Forgot Password should redirect', async ({ page }) => {
|
||||
await page.goto('http://localhost:4200/login', { waitUntil: 'networkidle' });
|
||||
|
||||
await page.click('a[routerlink="/registration/reset-password"]')
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
expect(page.url()).toBe('http://localhost:4200/registration/reset-password');
|
||||
});
|
||||
|
||||
test('Going directly to reset url should stay on the page', async ({page}) => {
|
||||
await page.goto('http://localhost:4200/registration/reset-password', { waitUntil: 'networkidle' });
|
||||
const email = page.locator('#email');
|
||||
expect(email).toBeEditable();
|
||||
})
|
||||
|
||||
test('Submitting an email, should give a prompt to user, redirect back to login', async ({ page }) => {
|
||||
await page.goto('http://localhost:4200/registration/reset-password', { waitUntil: 'networkidle' });
|
||||
|
||||
const email = page.locator('#email');
|
||||
expect(email).toBeEditable();
|
||||
|
||||
await email.type('XXX@gmail.com');
|
||||
|
||||
const button = page.locator('button[type="submit"]');
|
||||
await button.click();
|
||||
|
||||
const toastr = page.locator('#toast-container div[role="alertdialog"]')
|
||||
await expect(toastr).toHaveText('An email will be sent to the email if it exists in our database');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
expect(page.url()).toBe('http://localhost:4200/login');
|
||||
});
|
13
UI/Web/e2e/src/side-nav/side-nav.spec.ts
Normal file
13
UI/Web/e2e/src/side-nav/side-nav.spec.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.use({ storageState: 'storage/admin.json' });
|
||||
|
||||
test('When on login page, side nav should not render', async ({ page }) => {
|
||||
await page.goto('http://localhost:4200/login', { waitUntil: 'networkidle' });
|
||||
await expect(page.locator(".side-nav")).toHaveCount(0)
|
||||
});
|
||||
|
||||
test('When on library page, side nav should render', async ({ page }) => {
|
||||
await page.goto('http://localhost:4200/library', { waitUntil: 'networkidle' });
|
||||
await expect(page.locator(".side-nav")).toHaveCount(1)
|
||||
});
|
46
UI/Web/global-setup.ts
Normal file
46
UI/Web/global-setup.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { Browser, chromium, FullConfig, request } from '@playwright/test';
|
||||
|
||||
async function globalSetup(config: FullConfig) {
|
||||
let requestContext = await request.newContext();
|
||||
var token = await requestContext.post('http://localhost:5000/account/login', {
|
||||
form: {
|
||||
'user': 'Joe',
|
||||
'password': 'P4ssword'
|
||||
}
|
||||
});
|
||||
console.log(token.json());
|
||||
// Save signed-in state to 'storageState.json'.
|
||||
//await requestContext.storageState({ path: 'adminStorageState.json' });
|
||||
await requestContext.dispose();
|
||||
|
||||
requestContext = await request.newContext();
|
||||
await requestContext.post('http://localhost:5000/account/login', {
|
||||
form: {
|
||||
'user': 'nonadmin',
|
||||
'password': 'P4ssword'
|
||||
}
|
||||
});
|
||||
// Save signed-in state to 'storageState.json'.
|
||||
//await requestContext.storageState({ path: 'nonAdminStorageState.json' });
|
||||
await requestContext.dispose();
|
||||
}
|
||||
|
||||
|
||||
|
||||
// async function globalSetup (config: FullConfig) {
|
||||
// const browser = await chromium.launch()
|
||||
// await saveStorage(browser, 'nonadmin', 'P4ssword', 'storage/user.json')
|
||||
// await saveStorage(browser, 'Joe', 'P4ssword', 'storage/admin.json')
|
||||
// await browser.close()
|
||||
// }
|
||||
|
||||
async function saveStorage (browser: Browser, username: string, password: string, saveStoragePath: string) {
|
||||
const page = await browser.newPage()
|
||||
await page.goto('http://localhost:5000/account/login')
|
||||
await page.type('#username', username)
|
||||
await page.type('#password', password)
|
||||
await page.click('button[type="submit"]')
|
||||
await page.context().storageState({ path: saveStoragePath })
|
||||
}
|
||||
|
||||
export default globalSetup;
|
4
UI/Web/nonAdminStorageState.json
Normal file
4
UI/Web/nonAdminStorageState.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"cookies": [],
|
||||
"origins": []
|
||||
}
|
526
UI/Web/package-lock.json
generated
526
UI/Web/package-lock.json
generated
@ -1599,6 +1599,17 @@
|
||||
"@babel/helper-plugin-utils": "^7.16.7"
|
||||
}
|
||||
},
|
||||
"@babel/plugin-transform-typescript": {
|
||||
"version": "7.16.8",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.16.8.tgz",
|
||||
"integrity": "sha512-bHdQ9k7YpBDO2d0NVfkj51DpQcvwIzIusJ7mEUaMlbZq3Kt/U47j24inXZHQ5MDiYpCs+oZiwnXyKedE8+q7AQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/helper-create-class-features-plugin": "^7.16.7",
|
||||
"@babel/helper-plugin-utils": "^7.16.7",
|
||||
"@babel/plugin-syntax-typescript": "^7.16.7"
|
||||
}
|
||||
},
|
||||
"@babel/plugin-transform-unicode-escapes": {
|
||||
"version": "7.16.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.16.7.tgz",
|
||||
@ -1721,6 +1732,17 @@
|
||||
"esutils": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"@babel/preset-typescript": {
|
||||
"version": "7.16.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.16.7.tgz",
|
||||
"integrity": "sha512-WbVEmgXdIyvzB77AQjGBEyYPZx+8tTsO50XtfozQrkW8QB2rLJpH2lgx0TRw5EJrBxOZQ+wCcyPVQvS8tjEHpQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/helper-plugin-utils": "^7.16.7",
|
||||
"@babel/helper-validator-option": "^7.16.7",
|
||||
"@babel/plugin-transform-typescript": "^7.16.7"
|
||||
}
|
||||
},
|
||||
"@babel/runtime": {
|
||||
"version": "7.16.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.16.7.tgz",
|
||||
@ -2553,6 +2575,278 @@
|
||||
"read-package-json-fast": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"@playwright/test": {
|
||||
"version": "1.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.20.2.tgz",
|
||||
"integrity": "sha512-unkLa+xe/lP7MVC0qpgadc9iSG1+LEyGBzlXhGS/vLGAJaSFs8DNfI89hNd5shHjWfNzb34JgPVnkRKCSNo5iw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/code-frame": "7.16.7",
|
||||
"@babel/core": "7.16.12",
|
||||
"@babel/helper-plugin-utils": "7.16.7",
|
||||
"@babel/plugin-proposal-class-properties": "7.16.7",
|
||||
"@babel/plugin-proposal-dynamic-import": "7.16.7",
|
||||
"@babel/plugin-proposal-export-namespace-from": "7.16.7",
|
||||
"@babel/plugin-proposal-logical-assignment-operators": "7.16.7",
|
||||
"@babel/plugin-proposal-nullish-coalescing-operator": "7.16.7",
|
||||
"@babel/plugin-proposal-numeric-separator": "7.16.7",
|
||||
"@babel/plugin-proposal-optional-chaining": "7.16.7",
|
||||
"@babel/plugin-proposal-private-methods": "7.16.11",
|
||||
"@babel/plugin-proposal-private-property-in-object": "7.16.7",
|
||||
"@babel/plugin-syntax-async-generators": "7.8.4",
|
||||
"@babel/plugin-syntax-json-strings": "7.8.3",
|
||||
"@babel/plugin-syntax-object-rest-spread": "7.8.3",
|
||||
"@babel/plugin-syntax-optional-catch-binding": "7.8.3",
|
||||
"@babel/plugin-transform-modules-commonjs": "7.16.8",
|
||||
"@babel/preset-typescript": "7.16.7",
|
||||
"colors": "1.4.0",
|
||||
"commander": "8.3.0",
|
||||
"debug": "4.3.3",
|
||||
"expect": "27.2.5",
|
||||
"jest-matcher-utils": "27.2.5",
|
||||
"json5": "2.2.1",
|
||||
"mime": "3.0.0",
|
||||
"minimatch": "3.0.4",
|
||||
"ms": "2.1.3",
|
||||
"open": "8.4.0",
|
||||
"pirates": "4.0.4",
|
||||
"playwright-core": "1.20.2",
|
||||
"rimraf": "3.0.2",
|
||||
"source-map-support": "0.4.18",
|
||||
"stack-utils": "2.0.5",
|
||||
"yazl": "2.5.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/code-frame": {
|
||||
"version": "7.16.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz",
|
||||
"integrity": "sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/highlight": "^7.16.7"
|
||||
}
|
||||
},
|
||||
"@babel/core": {
|
||||
"version": "7.16.12",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.16.12.tgz",
|
||||
"integrity": "sha512-dK5PtG1uiN2ikk++5OzSYsitZKny4wOCD0nrO4TqnW4BVBTQ2NGS3NgilvT/TEyxTST7LNyWV/T4tXDoD3fOgg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/code-frame": "^7.16.7",
|
||||
"@babel/generator": "^7.16.8",
|
||||
"@babel/helper-compilation-targets": "^7.16.7",
|
||||
"@babel/helper-module-transforms": "^7.16.7",
|
||||
"@babel/helpers": "^7.16.7",
|
||||
"@babel/parser": "^7.16.12",
|
||||
"@babel/template": "^7.16.7",
|
||||
"@babel/traverse": "^7.16.10",
|
||||
"@babel/types": "^7.16.8",
|
||||
"convert-source-map": "^1.7.0",
|
||||
"debug": "^4.1.0",
|
||||
"gensync": "^1.0.0-beta.2",
|
||||
"json5": "^2.1.2",
|
||||
"semver": "^6.3.0",
|
||||
"source-map": "^0.5.0"
|
||||
}
|
||||
},
|
||||
"@babel/helper-validator-identifier": {
|
||||
"version": "7.16.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz",
|
||||
"integrity": "sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==",
|
||||
"dev": true
|
||||
},
|
||||
"@babel/highlight": {
|
||||
"version": "7.17.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.17.9.tgz",
|
||||
"integrity": "sha512-J9PfEKCbFIv2X5bjTMiZu6Vf341N05QIY+d6FvVKynkG1S7G0j3I0QoRtWIrXhZ+/Nlb5Q0MzqL7TokEJ5BNHg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/helper-validator-identifier": "^7.16.7",
|
||||
"chalk": "^2.0.0",
|
||||
"js-tokens": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"debug": "4"
|
||||
}
|
||||
},
|
||||
"ansi-styles": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true
|
||||
},
|
||||
"color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"color-name": "~1.1.4"
|
||||
}
|
||||
},
|
||||
"color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true
|
||||
},
|
||||
"commander": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
|
||||
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
|
||||
"dev": true
|
||||
},
|
||||
"expect": {
|
||||
"version": "27.2.5",
|
||||
"resolved": "https://registry.npmjs.org/expect/-/expect-27.2.5.tgz",
|
||||
"integrity": "sha512-ZrO0w7bo8BgGoP/bLz+HDCI+0Hfei9jUSZs5yI/Wyn9VkG9w8oJ7rHRgYj+MA7yqqFa0IwHA3flJzZtYugShJA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@jest/types": "^27.2.5",
|
||||
"ansi-styles": "^5.0.0",
|
||||
"jest-get-type": "^27.0.6",
|
||||
"jest-matcher-utils": "^27.2.5",
|
||||
"jest-message-util": "^27.2.5",
|
||||
"jest-regex-util": "^27.0.6"
|
||||
}
|
||||
},
|
||||
"has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true
|
||||
},
|
||||
"https-proxy-agent": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz",
|
||||
"integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
}
|
||||
},
|
||||
"jest-matcher-utils": {
|
||||
"version": "27.2.5",
|
||||
"resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.2.5.tgz",
|
||||
"integrity": "sha512-qNR/kh6bz0Dyv3m68Ck2g1fLW5KlSOUNcFQh87VXHZwWc/gY6XwnKofx76Qytz3x5LDWT09/2+yXndTkaG4aWg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"chalk": "^4.0.0",
|
||||
"jest-diff": "^27.2.5",
|
||||
"jest-get-type": "^27.0.6",
|
||||
"pretty-format": "^27.2.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"color-convert": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"json5": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz",
|
||||
"integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==",
|
||||
"dev": true
|
||||
},
|
||||
"mime": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
|
||||
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
|
||||
"dev": true
|
||||
},
|
||||
"ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true
|
||||
},
|
||||
"pirates": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.4.tgz",
|
||||
"integrity": "sha512-ZIrVPH+A52Dw84R0L3/VS9Op04PuQ2SEoJL6bkshmiTic/HldyW9Tf7oH5mhJZBK7NmDx27vSMrYEXPXclpDKw==",
|
||||
"dev": true
|
||||
},
|
||||
"playwright-core": {
|
||||
"version": "1.20.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.20.2.tgz",
|
||||
"integrity": "sha512-iV6+HftSPalynkq0CYJala1vaTOq7+gU9BRfKCdM9bAxNq/lFLrwbluug2Wt5OoUwbMABcnTThIEm3/qUhCdJQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"colors": "1.4.0",
|
||||
"commander": "8.3.0",
|
||||
"debug": "4.3.3",
|
||||
"extract-zip": "2.0.1",
|
||||
"https-proxy-agent": "5.0.0",
|
||||
"jpeg-js": "0.4.3",
|
||||
"mime": "3.0.0",
|
||||
"pixelmatch": "5.2.1",
|
||||
"pngjs": "6.0.0",
|
||||
"progress": "2.0.3",
|
||||
"proper-lockfile": "4.1.2",
|
||||
"proxy-from-env": "1.1.0",
|
||||
"rimraf": "3.0.2",
|
||||
"socks-proxy-agent": "6.1.1",
|
||||
"stack-utils": "2.0.5",
|
||||
"ws": "8.4.2",
|
||||
"yauzl": "2.10.0",
|
||||
"yazl": "2.5.1"
|
||||
}
|
||||
},
|
||||
"semver": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
|
||||
"integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
|
||||
"dev": true
|
||||
},
|
||||
"source-map-support": {
|
||||
"version": "0.4.18",
|
||||
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.4.18.tgz",
|
||||
"integrity": "sha512-try0/JqxPLF9nOjvSta7tVondkP5dwgyLDjVoyMDlmjugT2lRZ1OfsrYTkCd2hkDnJTKRbO/Rl3orm8vlsUzbA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"source-map": "^0.5.6"
|
||||
}
|
||||
},
|
||||
"supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"has-flag": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"ws": {
|
||||
"version": "8.4.2",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.4.2.tgz",
|
||||
"integrity": "sha512-Kbk4Nxyq7/ZWqr/tarI9yIt/+iNNFOjBXEWgTb4ydaNHBNGgvf2QHbS9fdfsndfjFlFwEd4Al+mw83YkaD10ZA==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"@polka/url": {
|
||||
"version": "1.0.0-next.21",
|
||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.21.tgz",
|
||||
@ -2919,6 +3213,16 @@
|
||||
"integrity": "sha512-7tFImggNeNBVMsn0vLrpn1H1uPrUBdnARPTpZoitY37ZrdJREzf7I16tMrlK3hen349gr1NYh8CmZQa7CTG6Aw==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/yauzl": {
|
||||
"version": "2.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.9.2.tgz",
|
||||
"integrity": "sha512-8uALY5LTvSuHgloDVUvWP3pIauILm+8/0pDMokuDYIoNsOkSwd5AiHBTSEJjKTDcZr5z8UpgOWZkxBF4iJftoA==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"@webassemblyjs/ast": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz",
|
||||
@ -3906,6 +4210,12 @@
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"buffer-crc32": {
|
||||
"version": "0.2.13",
|
||||
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
|
||||
"integrity": "sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=",
|
||||
"dev": true
|
||||
},
|
||||
"buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
@ -4237,6 +4547,12 @@
|
||||
"integrity": "sha512-hUewv7oMjCp+wkBv5Rm0v87eJhq4woh5rSR+42YSQJKecCqgIqNkZ6lAlQms/BwHPJA5NKMRlpxPRv0n8HQW6g==",
|
||||
"dev": true
|
||||
},
|
||||
"colors": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz",
|
||||
"integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==",
|
||||
"dev": true
|
||||
},
|
||||
"combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
@ -5113,6 +5429,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"end-of-stream": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
|
||||
"integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"enhanced-resolve": {
|
||||
"version": "5.9.0",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.9.0.tgz",
|
||||
@ -5558,6 +5883,29 @@
|
||||
"tmp": "^0.0.33"
|
||||
}
|
||||
},
|
||||
"extract-zip": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
|
||||
"integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/yauzl": "^2.9.1",
|
||||
"debug": "^4.1.1",
|
||||
"get-stream": "^5.1.0",
|
||||
"yauzl": "^2.10.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"get-stream": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
|
||||
"integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"pump": "^3.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"extsprintf": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz",
|
||||
@ -5639,6 +5987,15 @@
|
||||
"bser": "2.1.1"
|
||||
}
|
||||
},
|
||||
"fd-slicer": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
|
||||
"integrity": "sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"pend": "~1.2.0"
|
||||
}
|
||||
},
|
||||
"fetch-cookie": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-0.11.0.tgz",
|
||||
@ -8301,6 +8658,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"jpeg-js": {
|
||||
"version": "0.4.3",
|
||||
"resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.3.tgz",
|
||||
"integrity": "sha512-ru1HWKek8octvUHFHvE5ZzQ1yAsJmIvRdGWvSoKV52XKyuyYA437QWDttXT8eZXDSbuMpHlLzPDZUPd6idIz+Q==",
|
||||
"dev": true
|
||||
},
|
||||
"js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@ -9897,6 +10260,12 @@
|
||||
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
|
||||
"dev": true
|
||||
},
|
||||
"pend": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||
"integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=",
|
||||
"dev": true
|
||||
},
|
||||
"performance-now": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
||||
@ -9955,6 +10324,23 @@
|
||||
"nice-napi": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"pixelmatch": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-5.2.1.tgz",
|
||||
"integrity": "sha512-WjcAdYSnKrrdDdqTcVEY7aB7UhhwjYQKYhHiBXdJef0MOaQeYpUdQ+iVyBLa5YBKS8MPVPPMX7rpOByISLpeEQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"pngjs": "^4.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"pngjs": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-4.0.1.tgz",
|
||||
"integrity": "sha512-rf5+2/ioHeQxR6IxuYNYGFytUyG3lma/WW1nsmjeHlWwtb2aByla6dkVc8pmJ9nplzkTA0q2xx7mMWrOTqT4Gg==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"pkg-dir": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
|
||||
@ -9964,6 +10350,86 @@
|
||||
"find-up": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"playwright": {
|
||||
"version": "1.20.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.20.2.tgz",
|
||||
"integrity": "sha512-p6GE8A/f2G7t8FIk/AwQ94nT7R7tyPRJyKt1FwRjwBDf4WdpgoAr4hDfMgHy+CkClR22adFjopGwhxXAPsewhg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"playwright-core": "1.20.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"debug": "4"
|
||||
}
|
||||
},
|
||||
"commander": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
|
||||
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
|
||||
"dev": true
|
||||
},
|
||||
"https-proxy-agent": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz",
|
||||
"integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
}
|
||||
},
|
||||
"mime": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
|
||||
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
|
||||
"dev": true
|
||||
},
|
||||
"playwright-core": {
|
||||
"version": "1.20.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.20.2.tgz",
|
||||
"integrity": "sha512-iV6+HftSPalynkq0CYJala1vaTOq7+gU9BRfKCdM9bAxNq/lFLrwbluug2Wt5OoUwbMABcnTThIEm3/qUhCdJQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"colors": "1.4.0",
|
||||
"commander": "8.3.0",
|
||||
"debug": "4.3.3",
|
||||
"extract-zip": "2.0.1",
|
||||
"https-proxy-agent": "5.0.0",
|
||||
"jpeg-js": "0.4.3",
|
||||
"mime": "3.0.0",
|
||||
"pixelmatch": "5.2.1",
|
||||
"pngjs": "6.0.0",
|
||||
"progress": "2.0.3",
|
||||
"proper-lockfile": "4.1.2",
|
||||
"proxy-from-env": "1.1.0",
|
||||
"rimraf": "3.0.2",
|
||||
"socks-proxy-agent": "6.1.1",
|
||||
"stack-utils": "2.0.5",
|
||||
"ws": "8.4.2",
|
||||
"yauzl": "2.10.0",
|
||||
"yazl": "2.5.1"
|
||||
}
|
||||
},
|
||||
"ws": {
|
||||
"version": "8.4.2",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.4.2.tgz",
|
||||
"integrity": "sha512-Kbk4Nxyq7/ZWqr/tarI9yIt/+iNNFOjBXEWgTb4ydaNHBNGgvf2QHbS9fdfsndfjFlFwEd4Al+mw83YkaD10ZA==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"pngjs": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz",
|
||||
"integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==",
|
||||
"dev": true
|
||||
},
|
||||
"portfinder": {
|
||||
"version": "1.0.28",
|
||||
"resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.28.tgz",
|
||||
@ -10366,6 +10832,12 @@
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"dev": true
|
||||
},
|
||||
"progress": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
||||
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
|
||||
"dev": true
|
||||
},
|
||||
"promise-inflight": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz",
|
||||
@ -10400,6 +10872,25 @@
|
||||
"sisteransi": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"proper-lockfile": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
|
||||
"integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"graceful-fs": "^4.2.4",
|
||||
"retry": "^0.12.0",
|
||||
"signal-exit": "^3.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"retry": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
|
||||
"integrity": "sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs=",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"protractor": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/protractor/-/protractor-7.0.0.tgz",
|
||||
@ -10761,6 +11252,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"dev": true
|
||||
},
|
||||
"prr": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz",
|
||||
@ -10773,6 +11270,16 @@
|
||||
"resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz",
|
||||
"integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ=="
|
||||
},
|
||||
"pump": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
|
||||
"integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"end-of-stream": "^1.1.0",
|
||||
"once": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"punycode": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
|
||||
@ -12953,6 +13460,25 @@
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.0.0.tgz",
|
||||
"integrity": "sha512-z9kApYUOCwoeZ78rfRYYWdiU/iNL6mwwYlkkZfJoyMR1xps+NEBX5X7XmRpxkZHhXJ6+Ey00IwKxBBSW9FIjyA=="
|
||||
},
|
||||
"yauzl": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
|
||||
"integrity": "sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"buffer-crc32": "~0.2.3",
|
||||
"fd-slicer": "~1.1.0"
|
||||
}
|
||||
},
|
||||
"yazl": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/yazl/-/yazl-2.5.1.tgz",
|
||||
"integrity": "sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"buffer-crc32": "~0.2.3"
|
||||
}
|
||||
},
|
||||
"yn": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
|
||||
|
@ -49,12 +49,14 @@
|
||||
"@angular-devkit/build-angular": "~13.2.3",
|
||||
"@angular/cli": "^13.2.3",
|
||||
"@angular/compiler-cli": "~13.2.2",
|
||||
"@playwright/test": "^1.20.2",
|
||||
"@types/jest": "^27.4.0",
|
||||
"@types/node": "^17.0.17",
|
||||
"codelyzer": "^6.0.2",
|
||||
"jest": "^27.5.1",
|
||||
"jest-preset-angular": "^11.1.0",
|
||||
"karma-coverage": "~2.2.0",
|
||||
"playwright": "^1.20.2",
|
||||
"protractor": "~7.0.0",
|
||||
"ts-node": "~10.5.0",
|
||||
"tslint": "^6.1.3",
|
||||
|
106
UI/Web/playwright.config.ts
Normal file
106
UI/Web/playwright.config.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import type { PlaywrightTestConfig } from '@playwright/test';
|
||||
import { devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
const config: PlaywrightTestConfig = {
|
||||
testDir: './e2e',
|
||||
/* Maximum time one test can run for. */
|
||||
timeout: 30 * 1000,
|
||||
expect: {
|
||||
/**
|
||||
* Maximum time expect() should wait for the condition to be met.
|
||||
* For example in `await expect(locator).toHaveText();`
|
||||
*/
|
||||
timeout: 5000
|
||||
},
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
|
||||
actionTimeout: 0,
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: 'http://localhost:4200',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
globalSetup: require.resolve('./global-setup'),
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
},
|
||||
},
|
||||
|
||||
// {
|
||||
// name: 'firefox',
|
||||
// use: {
|
||||
// ...devices['Desktop Firefox'],
|
||||
// },
|
||||
// },
|
||||
|
||||
// {
|
||||
// name: 'webkit',
|
||||
// use: {
|
||||
// ...devices['Desktop Safari'],
|
||||
// },
|
||||
// },
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
// {
|
||||
// name: 'Mobile Chrome',
|
||||
// use: {
|
||||
// ...devices['Pixel 5'],
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// name: 'Mobile Safari',
|
||||
// use: {
|
||||
// ...devices['iPhone 12'],
|
||||
// },
|
||||
// },
|
||||
|
||||
/* Test against branded browsers. */
|
||||
// {
|
||||
// name: 'Microsoft Edge',
|
||||
// use: {
|
||||
// channel: 'msedge',
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// name: 'Google Chrome',
|
||||
// use: {
|
||||
// channel: 'chrome',
|
||||
// },
|
||||
// },
|
||||
],
|
||||
|
||||
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
|
||||
// outputDir: 'test-results/',
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
// webServer: {
|
||||
// command: 'npm run start',
|
||||
// port: 3000,
|
||||
// },
|
||||
};
|
||||
|
||||
export default config;
|
@ -19,5 +19,6 @@ export interface ReadingList {
|
||||
title: string;
|
||||
summary: string;
|
||||
promoted: boolean;
|
||||
coverImageLocked: boolean;
|
||||
items: Array<ReadingListItem>;
|
||||
}
|
@ -32,7 +32,7 @@ export interface SeriesMetadata {
|
||||
tagsLocked: boolean;
|
||||
writersLocked: boolean;
|
||||
coverArtistsLocked: boolean;
|
||||
publishersLocked: boolean;
|
||||
publisherLocked: boolean;
|
||||
charactersLocked: boolean;
|
||||
pencillersLocked: boolean;
|
||||
inkersLocked: boolean;
|
||||
|
@ -460,7 +460,7 @@ export class ActionService implements OnDestroy {
|
||||
}
|
||||
|
||||
editReadingList(readingList: ReadingList, callback?: ReadingListActionCallback) {
|
||||
const readingListModalRef = this.modalService.open(EditReadingListModalComponent, { scrollable: true, size: 'md' });
|
||||
const readingListModalRef = this.modalService.open(EditReadingListModalComponent, { scrollable: true, size: 'lg' });
|
||||
readingListModalRef.componentInstance.readingList = readingList;
|
||||
readingListModalRef.closed.pipe(take(1)).subscribe((list) => {
|
||||
if (callback && list !== undefined) {
|
||||
|
@ -75,6 +75,10 @@ export class ImageService implements OnDestroy {
|
||||
return this.baseUrl + 'image/collection-cover?collectionTagId=' + collectionTagId;
|
||||
}
|
||||
|
||||
getReadingListCoverImage(readingListId: number) {
|
||||
return this.baseUrl + 'image/readinglist-cover?readingListId=' + readingListId;
|
||||
}
|
||||
|
||||
getChapterCoverImage(chapterId: number) {
|
||||
return this.baseUrl + 'image/chapter-cover?chapterId=' + chapterId;
|
||||
}
|
||||
|
@ -133,9 +133,6 @@ export class SeriesService {
|
||||
getRecentlyUpdatedSeries() {
|
||||
return this.httpClient.post<SeriesGroup[]>(this.baseUrl + 'series/recently-updated-series', {});
|
||||
}
|
||||
getRecentlyAddedChapters() {
|
||||
return this.httpClient.post<RecentlyAddedItem[]>(this.baseUrl + 'series/recently-added-chapters', {});
|
||||
}
|
||||
|
||||
getOnDeck(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) {
|
||||
const data = this.createSeriesFilter(filter);
|
||||
|
@ -30,6 +30,10 @@ export class UploadService {
|
||||
return this.httpClient.post<number>(this.baseUrl + 'upload/collection', {id: tagId, url: this._cleanBase64Url(url)});
|
||||
}
|
||||
|
||||
updateReadingListCoverImage(readingListId: number, url: string) {
|
||||
return this.httpClient.post<number>(this.baseUrl + 'upload/reading-list', {id: readingListId, url: this._cleanBase64Url(url)});
|
||||
}
|
||||
|
||||
updateChapterCoverImage(chapterId: number, url: string) {
|
||||
return this.httpClient.post<number>(this.baseUrl + 'upload/chapter', {id: chapterId, url: this._cleanBase64Url(url)});
|
||||
}
|
||||
|
@ -130,7 +130,7 @@
|
||||
<span class="d-none d-sm-block"> {{readingDirection === ReadingDirection.LeftToRight ? 'Previous' : 'Next'}}</span>
|
||||
</button>
|
||||
<button *ngIf="!this.adhocPageHistory.isEmpty()" class="btn btn-outline-secondary btn-icon col-2 col-xs-1" (click)="goBack()" title="Go Back"><i class="fa fa-reply" aria-hidden="true"></i><span class="d-none d-sm-block"> Go Back</span></button>
|
||||
<button class="btn btn-secondary col-2 col-xs-1" (click)="toggleDrawer()"><i class="fa fa-bars" aria-hidden="true"></i><span class="d-none d-sm-block">Settings {{drawerOpen}}</span></button>
|
||||
<button class="btn btn-secondary col-2 col-xs-1" (click)="toggleDrawer()"><i class="fa fa-bars" aria-hidden="true"></i><span class="d-none d-sm-block">Settings</span></button>
|
||||
<div class="book-title col-2 d-none d-sm-block">
|
||||
<ng-container *ngIf="isLoading; else showTitle">
|
||||
<div class="spinner-border spinner-border-sm text-primary" style="border-radius: 50%;" role="status">
|
||||
|
@ -5,16 +5,15 @@
|
||||
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="modal-body {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? '' : 'd-flex'}}">
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-pills" orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 135px;">
|
||||
<li [ngbNavItem]="tabs[0]">
|
||||
<a ngbNavLink>{{tabs[0]}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<p>
|
||||
This tag is currently {{tag?.promoted ? 'promoted' : 'not promoted'}} (<i class="fa fa-angle-double-up" aria-hidden="true"></i>).
|
||||
Promotion means that the tag can be seen server-wide, not just for admin users. All series that have this tag will still have user-access restrictions placed on them.
|
||||
</p>
|
||||
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-tabs nav-pills">
|
||||
<li [ngbNavItem]="tabs[0]">
|
||||
<a ngbNavLink>{{tabs[0]}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<form [formGroup]="collectionTagForm">
|
||||
<div class="mb-3">
|
||||
<label for="summary" class="form-label">Summary</label>
|
||||
@ -62,7 +61,7 @@
|
||||
</ul>
|
||||
|
||||
|
||||
<div [ngbNavOutlet]="nav" class="mt-3"></div>
|
||||
<div [ngbNavOutlet]="nav" class="mt-3 ms-2"></div>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
|
@ -4,6 +4,7 @@ import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { ConfirmService } from 'src/app/shared/confirm.service';
|
||||
import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service';
|
||||
import { SelectionModel } from 'src/app/typeahead/typeahead.component';
|
||||
import { CollectionTag } from 'src/app/_models/collection-tag';
|
||||
import { Pagination } from 'src/app/_models/pagination';
|
||||
@ -14,6 +15,7 @@ import { LibraryService } from 'src/app/_services/library.service';
|
||||
import { SeriesService } from 'src/app/_services/series.service';
|
||||
import { UploadService } from 'src/app/_services/upload.service';
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'app-edit-collection-tags',
|
||||
templateUrl: './edit-collection-tags.component.html',
|
||||
@ -39,11 +41,15 @@ export class EditCollectionTagsComponent implements OnInit {
|
||||
return this.selections != null && this.selections.hasSomeSelected();
|
||||
}
|
||||
|
||||
get Breakpoint() {
|
||||
return Breakpoint;
|
||||
}
|
||||
|
||||
constructor(public modal: NgbActiveModal, private seriesService: SeriesService,
|
||||
private collectionService: CollectionTagService, private toastr: ToastrService,
|
||||
private confirmSerivce: ConfirmService, private libraryService: LibraryService,
|
||||
private imageService: ImageService, private uploadService: UploadService) { }
|
||||
private imageService: ImageService, private uploadService: UploadService,
|
||||
public utilityService: UtilityService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.pagination == undefined) {
|
||||
|
@ -192,8 +192,8 @@
|
||||
<div class="mb-3">
|
||||
<label for="publisher" class="form-label">Publisher</label>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Publisher)" [settings]="getPersonsSettings(PersonRole.Publisher)"
|
||||
[(locked)]="metadata.publishersLocked" (onUnlock)="metadata.publishersLocked = false"
|
||||
(newItemAdded)="metadata.publishersLocked = true" (selectedData)="metadata.publishersLocked = true">
|
||||
[(locked)]="metadata.publisherLocked" (onUnlock)="metadata.publisherLocked = false"
|
||||
(newItemAdded)="metadata.publisherLocked = true" (selectedData)="metadata.publisherLocked = true">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
|
@ -15,7 +15,6 @@
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
|
||||
<!-- TODO: Refactor this so we can use series actions here -->
|
||||
<app-carousel-reel [items]="recentlyUpdatedSeries" title="Recently Updated Series" (sectionClick)="handleSectionClick($event)">
|
||||
<ng-template #carouselItem let-item let-position="idx">
|
||||
<app-card-item [entity]="item" [title]="item.seriesName" [imageUrl]="imageService.getSeriesCoverImage(item.seriesId)"
|
||||
@ -28,10 +27,3 @@
|
||||
<app-series-card [data]="item" [libraryId]="item.libraryId" (dataChanged)="loadRecentlyAddedSeries()"></app-series-card>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
|
||||
<!-- <app-carousel-reel [items]="recentlyAddedChapters" title="Recently Added" (sectionClick)="handleSectionClick($event)">
|
||||
<ng-template #carouselItem let-item let-position="idx">
|
||||
<app-card-item [entity]="item" [title]="item.title" [subtitle]="item.seriesName" [imageUrl]="imageService.getRecentlyAddedItem(item)"
|
||||
[supressArchiveWarning]="true" (clicked)="handleRecentlyAddedChapterClick(item)"></app-card-item>
|
||||
</ng-template>
|
||||
</app-carousel-reel> -->
|
||||
|
@ -29,7 +29,6 @@ export class LibraryComponent implements OnInit, OnDestroy {
|
||||
isAdmin = false;
|
||||
|
||||
recentlyUpdatedSeries: SeriesGroup[] = [];
|
||||
recentlyAddedChapters: RecentlyAddedItem[] = [];
|
||||
inProgress: Series[] = [];
|
||||
recentlyAddedSeries: Series[] = [];
|
||||
|
||||
@ -57,7 +56,6 @@ export class LibraryComponent implements OnInit, OnDestroy {
|
||||
this.inProgress = this.inProgress.filter(item => item.id != seriesRemovedEvent.seriesId);
|
||||
this.recentlyAddedSeries = this.recentlyAddedSeries.filter(item => item.id != seriesRemovedEvent.seriesId);
|
||||
this.recentlyUpdatedSeries = this.recentlyUpdatedSeries.filter(item => item.seriesId != seriesRemovedEvent.seriesId);
|
||||
this.recentlyAddedChapters = this.recentlyAddedChapters.filter(item => item.seriesId != seriesRemovedEvent.seriesId);
|
||||
} else if (res.event === EVENTS.ScanSeries) {
|
||||
// We don't have events for when series are updated, but we do get events when a scan update occurs. Refresh recentlyAdded at that time.
|
||||
this.loadRecentlyAdded$.next();
|
||||
@ -125,10 +123,6 @@ export class LibraryComponent implements OnInit, OnDestroy {
|
||||
this.seriesService.getRecentlyUpdatedSeries().pipe(takeUntil(this.onDestroy)).subscribe(updatedSeries => {
|
||||
this.recentlyUpdatedSeries = updatedSeries;
|
||||
});
|
||||
|
||||
this.seriesService.getRecentlyAddedChapters().pipe(takeUntil(this.onDestroy)).subscribe(updatedSeries => {
|
||||
this.recentlyAddedChapters = updatedSeries;
|
||||
});
|
||||
}
|
||||
|
||||
handleRecentlyAddedChapterClick(item: RecentlyAddedItem) {
|
||||
|
@ -27,13 +27,10 @@
|
||||
|
||||
|
||||
img, .full-width {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
// .img-container {
|
||||
// overflow: auto;
|
||||
// }
|
||||
|
||||
|
||||
@keyframes move-up-down {
|
||||
|
@ -1,11 +1,13 @@
|
||||
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">Edit {{readingList.title}} Reading List</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
|
||||
|
||||
</button>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="modal-body {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? '' : 'd-flex'}}">
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-pills" orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 135px;">
|
||||
<li [ngbNavItem]="tabs[0]">
|
||||
<a ngbNavLink>{{tabs[0].title}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<p>
|
||||
This list is currently {{readingList?.promoted ? 'promoted' : 'not promoted'}} (<i class="fa fa-angle-double-up" aria-hidden="true"></i>).
|
||||
Promotion means that the list can be seen server-wide, not just for admin users. All series that are within this list will still have user-access restrictions placed on them.
|
||||
@ -21,6 +23,19 @@
|
||||
<textarea id="summary" class="form-control" formControlName="summary" rows="3"></textarea>
|
||||
</div>
|
||||
</form>
|
||||
</ng-template>
|
||||
</li>
|
||||
<li [ngbNavItem]="tabs[1]">
|
||||
<a ngbNavLink>{{tabs[1].title}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<p class="alert alert-primary" role="alert">
|
||||
Upload and choose a new cover image. Press Save to upload and override the cover.
|
||||
</p>
|
||||
<app-cover-image-chooser [(imageUrls)]="imageUrls" (imageSelected)="updateSelectedIndex($event)" (selectedBase64Url)="updateSelectedImage($event)" [showReset]="readingList.coverImageLocked" (resetClicked)="handleReset()"></app-cover-image-chooser>
|
||||
</ng-template>
|
||||
</li>
|
||||
</ul>
|
||||
<div [ngbNavOutlet]="nav" class="ms-2 mt-3"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" (click)="close()">Close</button>
|
||||
|
@ -1,8 +1,14 @@
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { FormGroup, FormControl, Validators } from '@angular/forms';
|
||||
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 { ReadingList } from 'src/app/_models/reading-list';
|
||||
import { ImageService } from 'src/app/_services/image.service';
|
||||
import { ReadingListService } from 'src/app/_services/reading-list.service';
|
||||
import { UploadService } from 'src/app/_services/upload.service';
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'app-edit-reading-list-modal',
|
||||
@ -14,13 +20,33 @@ export class EditReadingListModalComponent implements OnInit {
|
||||
@Input() readingList!: ReadingList;
|
||||
reviewGroup!: FormGroup;
|
||||
|
||||
constructor(private ngModal: NgbActiveModal, private readingListService: ReadingListService) { }
|
||||
coverImageIndex: number = 0;
|
||||
/**
|
||||
* Url of the selected cover
|
||||
*/
|
||||
selectedCover: string = '';
|
||||
coverImageLocked: boolean = false;
|
||||
|
||||
imageUrls: Array<string> = [];
|
||||
|
||||
tabs = [{title: 'General', disabled: false}, {title: 'Cover', disabled: false}];
|
||||
active = this.tabs[0];
|
||||
|
||||
get Breakpoint() {
|
||||
return Breakpoint;
|
||||
}
|
||||
|
||||
constructor(private ngModal: NgbActiveModal, private readingListService: ReadingListService,
|
||||
public utilityService: UtilityService, private uploadService: UploadService, private toastr: ToastrService,
|
||||
private imageService: ImageService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.reviewGroup = new FormGroup({
|
||||
title: new FormControl(this.readingList.title, [Validators.required]),
|
||||
summary: new FormControl(this.readingList.summary, [])
|
||||
});
|
||||
|
||||
this.imageUrls.push(this.imageService.randomize(this.imageService.getReadingListCoverImage(this.readingList.id)));
|
||||
}
|
||||
|
||||
close() {
|
||||
@ -29,12 +55,22 @@ export class EditReadingListModalComponent implements OnInit {
|
||||
|
||||
save() {
|
||||
if (this.reviewGroup.value.title.trim() === '') return;
|
||||
const model = {...this.reviewGroup.value, readingListId: this.readingList.id, promoted: this.readingList.promoted};
|
||||
|
||||
this.readingListService.update(model).subscribe(() => {
|
||||
|
||||
const model = {...this.reviewGroup.value, readingListId: this.readingList.id, promoted: this.readingList.promoted, coverImageLocked: this.coverImageLocked};
|
||||
|
||||
const apis = [this.readingListService.update(model)];
|
||||
|
||||
if (this.selectedCover !== '') {
|
||||
apis.push(this.uploadService.updateReadingListCoverImage(this.readingList.id, this.selectedCover))
|
||||
}
|
||||
|
||||
forkJoin(apis).subscribe(results => {
|
||||
this.readingList.title = model.title;
|
||||
this.readingList.summary = model.summary;
|
||||
this.readingList.coverImageLocked = this.coverImageLocked;
|
||||
this.ngModal.close(this.readingList);
|
||||
this.toastr.success('Reading List updated');
|
||||
});
|
||||
}
|
||||
|
||||
@ -49,4 +85,16 @@ export class EditReadingListModalComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
updateSelectedIndex(index: number) {
|
||||
this.coverImageIndex = index;
|
||||
}
|
||||
|
||||
updateSelectedImage(url: string) {
|
||||
this.selectedCover = url;
|
||||
}
|
||||
|
||||
handleReset() {
|
||||
this.coverImageLocked = false;
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -3,19 +3,24 @@
|
||||
<span *ngIf="actions.length > 0">
|
||||
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="readingList.title"></app-card-actionables>
|
||||
</span>
|
||||
{{readingList.title}} <span *ngIf="readingList?.promoted">(<i class="fa fa-angle-double-up" aria-hidden="true"></i>)</span>
|
||||
<span class="badge bg-primary rounded-pill" attr.aria-label="{{items.length}} total items">{{items.length}}</span>
|
||||
{{readingList?.title}} <span *ngIf="readingList?.promoted">(<i class="fa fa-angle-double-up" aria-hidden="true"></i>)</span>
|
||||
</h2>
|
||||
<h6 subtitle>{{items.length}} Items</h6>
|
||||
</app-side-nav-companion-bar>
|
||||
<div class="container-fluid mt-2" *ngIf="readingList">
|
||||
<div class="mb-3">
|
||||
<!-- Action row-->
|
||||
<div class="row g-0">
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-2 col-xs-4 col-sm-6 d-none d-sm-block">
|
||||
<app-image maxWidth="300px" [imageUrl]="readingListImage"></app-image>
|
||||
</div>
|
||||
<div class="col-md-10 col-xs-8 col-sm-6 mt-2">
|
||||
<div class="row g-0 mb-3">
|
||||
<div class="col-auto me-2">
|
||||
<!-- Action row-->
|
||||
<button class="btn btn-primary" title="Read" (click)="read()">
|
||||
<span>
|
||||
<i class="fa fa-book-open" aria-hidden="true"></i>
|
||||
<span class="read-btn--text"> Read</span>
|
||||
<span class="read-btn--text"> Read</span> <!-- IDEA: We can provide them the ability to read/continue like we do with a series -->
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
@ -40,6 +45,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div *ngIf="items.length === 0">
|
||||
No chapters added
|
||||
</div>
|
||||
@ -63,6 +69,8 @@
|
||||
<span *ngIf="item.promoted">
|
||||
<i class="fa fa-angle-double-up" aria-hidden="true"></i>
|
||||
</span>
|
||||
<br/>
|
||||
<a href="javascript:void(0);" (click)="readChapter(item)">Read</a>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
@ -15,6 +15,7 @@ import { ReadingListService } from 'src/app/_services/reading-list.service';
|
||||
import { IndexUpdateEvent, ItemRemoveEvent } from '../dragable-ordered-list/dragable-ordered-list.component';
|
||||
import { LibraryService } from '../../_services/library.service';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { ReaderService } from 'src/app/_services/reader.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-reading-list-detail',
|
||||
@ -38,6 +39,8 @@ export class ReadingListDetailComponent implements OnInit {
|
||||
|
||||
libraryTypes: {[key: number]: LibraryType} = {};
|
||||
|
||||
readingListImage: string = '';
|
||||
|
||||
get MangaFormat(): typeof MangaFormat {
|
||||
return MangaFormat;
|
||||
}
|
||||
@ -45,7 +48,7 @@ export class ReadingListDetailComponent implements OnInit {
|
||||
constructor(private route: ActivatedRoute, private router: Router, private readingListService: ReadingListService,
|
||||
private actionService: ActionService, private actionFactoryService: ActionFactoryService, public utilityService: UtilityService,
|
||||
public imageService: ImageService, private accountService: AccountService, private toastr: ToastrService,
|
||||
private confirmService: ConfirmService, private libraryService: LibraryService) {}
|
||||
private confirmService: ConfirmService, private libraryService: LibraryService, private readerService: ReaderService) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
const listId = this.route.snapshot.paramMap.get('id');
|
||||
@ -57,6 +60,8 @@ export class ReadingListDetailComponent implements OnInit {
|
||||
|
||||
this.listId = parseInt(listId, 10);
|
||||
|
||||
this.readingListImage = this.imageService.randomize(this.imageService.getReadingListCoverImage(this.listId));
|
||||
|
||||
this.libraryService.getLibraries().subscribe(libs => {
|
||||
|
||||
});
|
||||
@ -107,6 +112,15 @@ export class ReadingListDetailComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
readChapter(item: ReadingListItem) {
|
||||
let reader = 'manga';
|
||||
if (item.seriesFormat === MangaFormat.EPUB) {
|
||||
reader = 'book;'
|
||||
}
|
||||
const params = this.readerService.getQueryParamsObject(false, true, this.readingList.id);
|
||||
this.router.navigate(['library', item.libraryId, 'series', item.seriesId, 'book', item.chapterId], {queryParams: params});
|
||||
}
|
||||
|
||||
handleReadingListActionCallback(action: Action, readingList: ReadingList) {
|
||||
switch(action) {
|
||||
case Action.Delete:
|
||||
|
@ -12,6 +12,7 @@ import { EditReadingListModalComponent } from './_modals/edit-reading-list-modal
|
||||
import { PipeModule } from '../pipe/pipe.module';
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
import { SidenavModule } from '../sidenav/sidenav.module';
|
||||
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
|
||||
|
||||
|
||||
@ -31,7 +32,8 @@ import { SidenavModule } from '../sidenav/sidenav.module';
|
||||
CardsModule,
|
||||
PipeModule,
|
||||
SharedModule,
|
||||
SidenavModule
|
||||
SidenavModule,
|
||||
NgbNavModule
|
||||
],
|
||||
exports: [
|
||||
AddToListModalComponent,
|
||||
|
@ -15,6 +15,6 @@
|
||||
(pageChange)="onPageChange($event)"
|
||||
>
|
||||
<ng-template #cardItem let-item let-position="idx">
|
||||
<app-card-item [title]="item.title" [entity]="item" [actions]="getActions(item)" [supressLibraryLink]="true" [imageUrl]="imageService.placeholderImage" (clicked)="handleClick(item)"></app-card-item>
|
||||
<app-card-item [title]="item.title" [entity]="item" [actions]="getActions(item)" [supressLibraryLink]="true" [imageUrl]="imageService.getReadingListCoverImage(item.id)" (clicked)="handleClick(item)"></app-card-item>
|
||||
</ng-template>
|
||||
</app-card-detail-layout>
|
||||
|
4
UI/Web/storage/admin.json
Normal file
4
UI/Web/storage/admin.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"cookies": [],
|
||||
"origins": []
|
||||
}
|
4
UI/Web/storage/user.json
Normal file
4
UI/Web/storage/user.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"cookies": [],
|
||||
"origins": []
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user