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:
Joseph Milazzo 2022-04-11 17:43:40 -05:00 committed by GitHub
parent 912dfa8a80
commit 3bbb02f574
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
64 changed files with 3397 additions and 343 deletions

4
.gitignore vendored
View File

@ -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/

View File

@ -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]

View File

@ -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

View File

@ -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
}

View File

@ -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);
}

View File

@ -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>

View File

@ -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())

View File

@ -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)
{

View File

@ -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>

View File

@ -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; }
}
}

View File

@ -6,5 +6,6 @@
public string Title { get; set; }
public string Summary { get; set; }
public bool Promoted { get; set; }
public bool CoverImageLocked { get; set; }
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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");
}
}
}

View File

@ -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");

View File

@ -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);

View File

@ -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;
}
}

View File

@ -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; }

View File

@ -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>();

View File

@ -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.

View File

@ -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);
}
}

View File

@ -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}";
}
}

View File

@ -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>

View File

@ -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;

View File

@ -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.

View File

@ -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.

View File

@ -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
View 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
View File

@ -0,0 +1,3 @@
node_modules/
test-results/
playwright-report/

View File

@ -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

View File

@ -0,0 +1,4 @@
{
"cookies": [],
"origins": []
}

View 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);
// }

View File

@ -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));
});
});

View 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');
// });

View 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');
});

View File

@ -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');
});

View 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
View 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;

View File

@ -0,0 +1,4 @@
{
"cookies": [],
"origins": []
}

526
UI/Web/package-lock.json generated
View File

@ -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",

View File

@ -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
View 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;

View File

@ -19,5 +19,6 @@ export interface ReadingList {
title: string;
summary: string;
promoted: boolean;
coverImageLocked: boolean;
items: Array<ReadingListItem>;
}

View File

@ -32,7 +32,7 @@ export interface SeriesMetadata {
tagsLocked: boolean;
writersLocked: boolean;
coverArtistsLocked: boolean;
publishersLocked: boolean;
publisherLocked: boolean;
charactersLocked: boolean;
pencillersLocked: boolean;
inkersLocked: boolean;

View File

@ -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) {

View File

@ -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;
}

View File

@ -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);

View File

@ -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)});
}

View File

@ -130,7 +130,7 @@
<span class="d-none d-sm-block">&nbsp;{{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">&nbsp;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">

View File

@ -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">

View File

@ -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) {

View File

@ -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>

View File

@ -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> -->

View File

@ -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) {

View File

@ -27,13 +27,10 @@
img, .full-width {
width: 100% !important;
max-width: 100% !important;
height: auto;
}
// .img-container {
// overflow: auto;
// }
@keyframes move-up-down {

View File

@ -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>

View File

@ -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;
}
}

View File

@ -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}}&nbsp;<span *ngIf="readingList?.promoted">(<i class="fa fa-angle-double-up" aria-hidden="true"></i>)</span>&nbsp;
<span class="badge bg-primary rounded-pill" attr.aria-label="{{items.length}} total items">{{items.length}}</span>
{{readingList?.title}}&nbsp;<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">&nbsp;Read</span>
<span class="read-btn--text">&nbsp;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>

View File

@ -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:

View File

@ -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,

View File

@ -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>

View File

@ -0,0 +1,4 @@
{
"cookies": [],
"origins": []
}

4
UI/Web/storage/user.json Normal file
View File

@ -0,0 +1,4 @@
{
"cookies": [],
"origins": []
}