Merge remote-tracking branch 'upstream/develop' into feature/ux-content-page-mobile-redesign

This commit is contained in:
Christopher 2025-05-15 22:32:01 -06:00
commit 14df0139c9
158 changed files with 6590 additions and 1253 deletions

View file

@ -9,10 +9,10 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.4" /> <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" /> <PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="22.0.13" /> <PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="22.0.14" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="22.0.13" /> <PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="22.0.14" />
<PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2"> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>

View file

@ -1,5 +1,10 @@
using System.Linq; using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Entities.Enums;
using API.Helpers;
using API.Helpers.Builders;
using Xunit;
namespace API.Tests.Helpers; namespace API.Tests.Helpers;
@ -7,127 +12,215 @@ public class PersonHelperTests : AbstractDbTest
{ {
protected override async Task ResetDb() protected override async Task ResetDb()
{ {
Context.Series.RemoveRange(Context.Series.ToList());
Context.Person.RemoveRange(Context.Person.ToList());
Context.Library.RemoveRange(Context.Library.ToList());
Context.Series.RemoveRange(Context.Series.ToList()); Context.Series.RemoveRange(Context.Series.ToList());
await Context.SaveChangesAsync(); await Context.SaveChangesAsync();
} }
//
// // 1. Test adding new people and keeping existing ones // 1. Test adding new people and keeping existing ones
// [Fact] [Fact]
// public async Task UpdateChapterPeopleAsync_AddNewPeople_ExistingPersonRetained() public async Task UpdateChapterPeopleAsync_AddNewPeople_ExistingPersonRetained()
// { {
// var existingPerson = new PersonBuilder("Joe Shmo").Build(); await ResetDb();
// var chapter = new ChapterBuilder("1").Build();
// var library = new LibraryBuilder("My Library")
// // Create an existing person and assign them to the series with a role .Build();
// var series = new SeriesBuilder("Test 1")
// .WithFormat(MangaFormat.Archive) UnitOfWork.LibraryRepository.Add(library);
// .WithMetadata(new SeriesMetadataBuilder() await UnitOfWork.CommitAsync();
// .WithPerson(existingPerson, PersonRole.Editor)
// .Build()) var existingPerson = new PersonBuilder("Joe Shmo").Build();
// .WithVolume(new VolumeBuilder("1").WithChapter(chapter).Build()) var chapter = new ChapterBuilder("1").Build();
// .Build();
// // Create an existing person and assign them to the series with a role
// _unitOfWork.SeriesRepository.Add(series); var series = new SeriesBuilder("Test 1")
// await _unitOfWork.CommitAsync(); .WithLibraryId(library.Id)
// .WithFormat(MangaFormat.Archive)
// // Call UpdateChapterPeopleAsync with one existing and one new person .WithMetadata(new SeriesMetadataBuilder()
// await PersonHelper.UpdateChapterPeopleAsync(chapter, new List<string> { "Joe Shmo", "New Person" }, PersonRole.Editor, _unitOfWork); .WithPerson(existingPerson, PersonRole.Editor)
// .Build())
// // Assert existing person retained and new person added .WithVolume(new VolumeBuilder("1").WithChapter(chapter).Build())
// var people = await _unitOfWork.PersonRepository.GetAllPeople(); .Build();
// Assert.Contains(people, p => p.Name == "Joe Shmo");
// Assert.Contains(people, p => p.Name == "New Person"); UnitOfWork.SeriesRepository.Add(series);
// await UnitOfWork.CommitAsync();
// var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList();
// Assert.Contains("Joe Shmo", chapterPeople); // Call UpdateChapterPeopleAsync with one existing and one new person
// Assert.Contains("New Person", chapterPeople); await PersonHelper.UpdateChapterPeopleAsync(chapter, new List<string> { "Joe Shmo", "New Person" }, PersonRole.Editor, UnitOfWork);
// }
// // Assert existing person retained and new person added
// // 2. Test removing a person no longer in the list var people = await UnitOfWork.PersonRepository.GetAllPeople();
// [Fact] Assert.Contains(people, p => p.Name == "Joe Shmo");
// public async Task UpdateChapterPeopleAsync_RemovePeople() Assert.Contains(people, p => p.Name == "New Person");
// {
// var existingPerson1 = new PersonBuilder("Joe Shmo").Build(); var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList();
// var existingPerson2 = new PersonBuilder("Jane Doe").Build(); Assert.Contains("Joe Shmo", chapterPeople);
// var chapter = new ChapterBuilder("1").Build(); Assert.Contains("New Person", chapterPeople);
// }
// var series = new SeriesBuilder("Test 1")
// .WithVolume(new VolumeBuilder("1") // 2. Test removing a person no longer in the list
// .WithChapter(new ChapterBuilder("1") [Fact]
// .WithPerson(existingPerson1, PersonRole.Editor) public async Task UpdateChapterPeopleAsync_RemovePeople()
// .WithPerson(existingPerson2, PersonRole.Editor) {
// .Build()) await ResetDb();
// .Build())
// .Build(); var library = new LibraryBuilder("My Library")
// .Build();
// _unitOfWork.SeriesRepository.Add(series);
// await _unitOfWork.CommitAsync(); UnitOfWork.LibraryRepository.Add(library);
// await UnitOfWork.CommitAsync();
// // Call UpdateChapterPeopleAsync with only one person
// await PersonHelper.UpdateChapterPeopleAsync(chapter, new List<string> { "Joe Shmo" }, PersonRole.Editor, _unitOfWork); var existingPerson1 = new PersonBuilder("Joe Shmo").Build();
// var existingPerson2 = new PersonBuilder("Jane Doe").Build();
// var people = await _unitOfWork.PersonRepository.GetAllPeople(); var chapter = new ChapterBuilder("1")
// Assert.DoesNotContain(people, p => p.Name == "Jane Doe"); .WithPerson(existingPerson1, PersonRole.Editor)
// .WithPerson(existingPerson2, PersonRole.Editor)
// var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList(); .Build();
// Assert.Contains("Joe Shmo", chapterPeople);
// Assert.DoesNotContain("Jane Doe", chapterPeople); var series = new SeriesBuilder("Test 1")
// } .WithLibraryId(library.Id)
// .WithVolume(new VolumeBuilder("1")
// // 3. Test no changes when the list of people is the same .WithChapter(chapter)
// [Fact] .Build())
// public async Task UpdateChapterPeopleAsync_NoChanges() .Build();
// {
// var existingPerson = new PersonBuilder("Joe Shmo").Build(); UnitOfWork.SeriesRepository.Add(series);
// var chapter = new ChapterBuilder("1").Build(); await UnitOfWork.CommitAsync();
//
// var series = new SeriesBuilder("Test 1") // Call UpdateChapterPeopleAsync with only one person
// .WithVolume(new VolumeBuilder("1") await PersonHelper.UpdateChapterPeopleAsync(chapter, new List<string> { "Joe Shmo" }, PersonRole.Editor, UnitOfWork);
// .WithChapter(new ChapterBuilder("1")
// .WithPerson(existingPerson, PersonRole.Editor) // PersonHelper does not remove the Person from the global DbSet itself
// .Build()) await UnitOfWork.PersonRepository.RemoveAllPeopleNoLongerAssociated();
// .Build())
// .Build(); var people = await UnitOfWork.PersonRepository.GetAllPeople();
// Assert.DoesNotContain(people, p => p.Name == "Jane Doe");
// _unitOfWork.SeriesRepository.Add(series);
// await _unitOfWork.CommitAsync(); var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList();
// Assert.Contains("Joe Shmo", chapterPeople);
// // Call UpdateChapterPeopleAsync with the same list Assert.DoesNotContain("Jane Doe", chapterPeople);
// await PersonHelper.UpdateChapterPeopleAsync(chapter, new List<string> { "Joe Shmo" }, PersonRole.Editor, _unitOfWork); }
//
// var people = await _unitOfWork.PersonRepository.GetAllPeople(); // 3. Test no changes when the list of people is the same
// Assert.Contains(people, p => p.Name == "Joe Shmo"); [Fact]
// public async Task UpdateChapterPeopleAsync_NoChanges()
// var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList(); {
// Assert.Contains("Joe Shmo", chapterPeople); await ResetDb();
// Assert.Single(chapter.People); // No duplicate entries
// } var library = new LibraryBuilder("My Library")
// .Build();
// // 4. Test multiple roles for a person
// [Fact] UnitOfWork.LibraryRepository.Add(library);
// public async Task UpdateChapterPeopleAsync_MultipleRoles() await UnitOfWork.CommitAsync();
// {
// var person = new PersonBuilder("Joe Shmo").Build(); var existingPerson = new PersonBuilder("Joe Shmo").Build();
// var chapter = new ChapterBuilder("1").Build(); var chapter = new ChapterBuilder("1").WithPerson(existingPerson, PersonRole.Editor).Build();
//
// var series = new SeriesBuilder("Test 1") var series = new SeriesBuilder("Test 1")
// .WithVolume(new VolumeBuilder("1") .WithLibraryId(library.Id)
// .WithChapter(new ChapterBuilder("1") .WithVolume(new VolumeBuilder("1")
// .WithPerson(person, PersonRole.Writer) // Assign person as Writer .WithChapter(chapter)
// .Build()) .Build())
// .Build()) .Build();
// .Build();
// UnitOfWork.SeriesRepository.Add(series);
// _unitOfWork.SeriesRepository.Add(series); await UnitOfWork.CommitAsync();
// await _unitOfWork.CommitAsync();
// // Call UpdateChapterPeopleAsync with the same list
// // Add same person as Editor await PersonHelper.UpdateChapterPeopleAsync(chapter, new List<string> { "Joe Shmo" }, PersonRole.Editor, UnitOfWork);
// await PersonHelper.UpdateChapterPeopleAsync(chapter, new List<string> { "Joe Shmo" }, PersonRole.Editor, _unitOfWork);
// var people = await UnitOfWork.PersonRepository.GetAllPeople();
// // Ensure that the same person is assigned with two roles Assert.Contains(people, p => p.Name == "Joe Shmo");
// var chapterPeople = chapter.People.Where(cp => cp.Person.Name == "Joe Shmo").ToList();
// Assert.Equal(2, chapterPeople.Count); // One for each role var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList();
// Assert.Contains(chapterPeople, cp => cp.Role == PersonRole.Writer); Assert.Contains("Joe Shmo", chapterPeople);
// Assert.Contains(chapterPeople, cp => cp.Role == PersonRole.Editor); Assert.Single(chapter.People); // No duplicate entries
// } }
// 4. Test multiple roles for a person
[Fact]
public async Task UpdateChapterPeopleAsync_MultipleRoles()
{
await ResetDb();
var library = new LibraryBuilder("My Library")
.Build();
UnitOfWork.LibraryRepository.Add(library);
await UnitOfWork.CommitAsync();
var person = new PersonBuilder("Joe Shmo").Build();
var chapter = new ChapterBuilder("1").WithPerson(person, PersonRole.Writer).Build();
var series = new SeriesBuilder("Test 1")
.WithLibraryId(library.Id)
.WithVolume(new VolumeBuilder("1")
.WithChapter(chapter)
.Build())
.Build();
UnitOfWork.SeriesRepository.Add(series);
await UnitOfWork.CommitAsync();
// Add same person as Editor
await PersonHelper.UpdateChapterPeopleAsync(chapter, new List<string> { "Joe Shmo" }, PersonRole.Editor, UnitOfWork);
// Ensure that the same person is assigned with two roles
var chapterPeople = chapter
.People
.Where(cp =>
cp.Person.Name == "Joe Shmo")
.ToList();
Assert.Equal(2, chapterPeople.Count); // One for each role
Assert.Contains(chapterPeople, cp => cp.Role == PersonRole.Writer);
Assert.Contains(chapterPeople, cp => cp.Role == PersonRole.Editor);
}
[Fact]
public async Task UpdateChapterPeopleAsync_MatchOnAlias_NoChanges()
{
await ResetDb();
var library = new LibraryBuilder("My Library")
.Build();
UnitOfWork.LibraryRepository.Add(library);
await UnitOfWork.CommitAsync();
var person = new PersonBuilder("Joe Doe")
.WithAlias("Jonny Doe")
.Build();
var chapter = new ChapterBuilder("1")
.WithPerson(person, PersonRole.Editor)
.Build();
var series = new SeriesBuilder("Test 1")
.WithLibraryId(library.Id)
.WithVolume(new VolumeBuilder("1")
.WithChapter(chapter)
.Build())
.Build();
UnitOfWork.SeriesRepository.Add(series);
await UnitOfWork.CommitAsync();
// Add on Name
await PersonHelper.UpdateChapterPeopleAsync(chapter, new List<string> { "Joe Doe" }, PersonRole.Editor, UnitOfWork);
await UnitOfWork.CommitAsync();
var allPeople = await UnitOfWork.PersonRepository.GetAllPeople();
Assert.Single(allPeople);
// Add on alias
await PersonHelper.UpdateChapterPeopleAsync(chapter, new List<string> { "Jonny Doe" }, PersonRole.Editor, UnitOfWork);
await UnitOfWork.CommitAsync();
allPeople = await UnitOfWork.PersonRepository.GetAllPeople();
Assert.Single(allPeople);
}
// TODO: Unit tests for series
} }

View file

@ -1678,6 +1678,130 @@ public class ExternalMetadataServiceTests : AbstractDbTest
#endregion #endregion
#region People Alias
[Fact]
public async Task PeopleAliasing_AddAsAlias()
{
await ResetDb();
const string seriesName = "Test - People - Add as Alias";
var series = new SeriesBuilder(seriesName)
.WithLibraryId(1)
.WithMetadata(new SeriesMetadataBuilder()
.Build())
.Build();
Context.Series.Attach(series);
Context.Person.Add(new PersonBuilder("John Doe").Build());
await Context.SaveChangesAsync();
var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings();
metadataSettings.Enabled = true;
metadataSettings.EnablePeople = true;
metadataSettings.FirstLastPeopleNaming = true;
metadataSettings.Overrides = [MetadataSettingField.People];
metadataSettings.PersonRoles = [PersonRole.Writer];
Context.MetadataSettings.Update(metadataSettings);
await Context.SaveChangesAsync();
await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto()
{
Name = seriesName,
Staff = [CreateStaff("Doe", "John", "Story")]
}, 1);
var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata);
Assert.NotNull(postSeries);
var allWriters = postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer).ToList();
Assert.Single(allWriters);
var johnDoe = allWriters[0].Person;
Assert.Contains("Doe John", johnDoe.Aliases.Select(pa => pa.Alias));
}
[Fact]
public async Task PeopleAliasing_AddOnAlias()
{
await ResetDb();
const string seriesName = "Test - People - Add as Alias";
var series = new SeriesBuilder(seriesName)
.WithLibraryId(1)
.WithMetadata(new SeriesMetadataBuilder()
.Build())
.Build();
Context.Series.Attach(series);
Context.Person.Add(new PersonBuilder("John Doe").WithAlias("Doe John").Build());
await Context.SaveChangesAsync();
var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings();
metadataSettings.Enabled = true;
metadataSettings.EnablePeople = true;
metadataSettings.FirstLastPeopleNaming = true;
metadataSettings.Overrides = [MetadataSettingField.People];
metadataSettings.PersonRoles = [PersonRole.Writer];
Context.MetadataSettings.Update(metadataSettings);
await Context.SaveChangesAsync();
await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto()
{
Name = seriesName,
Staff = [CreateStaff("Doe", "John", "Story")]
}, 1);
var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata);
Assert.NotNull(postSeries);
var allWriters = postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer).ToList();
Assert.Single(allWriters);
var johnDoe = allWriters[0].Person;
Assert.Contains("Doe John", johnDoe.Aliases.Select(pa => pa.Alias));
}
[Fact]
public async Task PeopleAliasing_DontAddAsAlias_SameButNotSwitched()
{
await ResetDb();
const string seriesName = "Test - People - Add as Alias";
var series = new SeriesBuilder(seriesName)
.WithLibraryId(1)
.WithMetadata(new SeriesMetadataBuilder()
.Build())
.Build();
Context.Series.Attach(series);
await Context.SaveChangesAsync();
var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings();
metadataSettings.Enabled = true;
metadataSettings.EnablePeople = true;
metadataSettings.FirstLastPeopleNaming = true;
metadataSettings.Overrides = [MetadataSettingField.People];
metadataSettings.PersonRoles = [PersonRole.Writer];
Context.MetadataSettings.Update(metadataSettings);
await Context.SaveChangesAsync();
await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto()
{
Name = seriesName,
Staff = [CreateStaff("John", "Doe Doe", "Story"), CreateStaff("Doe", "John Doe", "Story")]
}, 1);
var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata);
Assert.NotNull(postSeries);
var allWriters = postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer).ToList();
Assert.Equal(2, allWriters.Count);
}
#endregion
#region People - Characters #region People - Characters
[Fact] [Fact]

View file

@ -0,0 +1,286 @@
using System.Linq;
using System.Threading.Tasks;
using API.Data.Repositories;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Person;
using API.Extensions;
using API.Helpers.Builders;
using API.Services;
using Xunit;
namespace API.Tests.Services;
public class PersonServiceTests: AbstractDbTest
{
[Fact]
public async Task PersonMerge_KeepNonEmptyMetadata()
{
var ps = new PersonService(UnitOfWork);
var person1 = new Person
{
Name = "Casey Delores",
NormalizedName = "Casey Delores".ToNormalized(),
HardcoverId = "ANonEmptyId",
MalId = 12,
};
var person2 = new Person
{
Name= "Delores Casey",
NormalizedName = "Delores Casey".ToNormalized(),
Description = "Hi, I'm Delores Casey!",
Aliases = [new PersonAliasBuilder("Casey, Delores").Build()],
AniListId = 27,
};
UnitOfWork.PersonRepository.Attach(person1);
UnitOfWork.PersonRepository.Attach(person2);
await UnitOfWork.CommitAsync();
await ps.MergePeopleAsync(person2, person1);
var allPeople = await UnitOfWork.PersonRepository.GetAllPeople();
Assert.Single(allPeople);
var person = allPeople[0];
Assert.Equal("Casey Delores", person.Name);
Assert.NotEmpty(person.Description);
Assert.Equal(27, person.AniListId);
Assert.NotNull(person.HardcoverId);
Assert.NotEmpty(person.HardcoverId);
Assert.Contains(person.Aliases, pa => pa.Alias == "Delores Casey");
Assert.Contains(person.Aliases, pa => pa.Alias == "Casey, Delores");
}
[Fact]
public async Task PersonMerge_MergedPersonDestruction()
{
var ps = new PersonService(UnitOfWork);
var person1 = new Person
{
Name = "Casey Delores",
NormalizedName = "Casey Delores".ToNormalized(),
};
var person2 = new Person
{
Name = "Delores Casey",
NormalizedName = "Delores Casey".ToNormalized(),
};
UnitOfWork.PersonRepository.Attach(person1);
UnitOfWork.PersonRepository.Attach(person2);
await UnitOfWork.CommitAsync();
await ps.MergePeopleAsync(person2, person1);
var allPeople = await UnitOfWork.PersonRepository.GetAllPeople();
Assert.Single(allPeople);
}
[Fact]
public async Task PersonMerge_RetentionChapters()
{
var ps = new PersonService(UnitOfWork);
var library = new LibraryBuilder("My Library").Build();
UnitOfWork.LibraryRepository.Add(library);
await UnitOfWork.CommitAsync();
var user = new AppUserBuilder("Amelia", "amelia@localhost")
.WithLibrary(library).Build();
UnitOfWork.UserRepository.Add(user);
var person = new PersonBuilder("Jillian Cowan").Build();
var person2 = new PersonBuilder("Cowan Jillian").Build();
var chapter = new ChapterBuilder("1")
.WithPerson(person, PersonRole.Editor)
.Build();
var chapter2 = new ChapterBuilder("2")
.WithPerson(person2, PersonRole.Editor)
.Build();
var series = new SeriesBuilder("Test 1")
.WithLibraryId(library.Id)
.WithVolume(new VolumeBuilder("1")
.WithChapter(chapter)
.Build())
.Build();
var series2 = new SeriesBuilder("Test 2")
.WithLibraryId(library.Id)
.WithVolume(new VolumeBuilder("2")
.WithChapter(chapter2)
.Build())
.Build();
UnitOfWork.SeriesRepository.Add(series);
UnitOfWork.SeriesRepository.Add(series2);
await UnitOfWork.CommitAsync();
await ps.MergePeopleAsync(person2, person);
var allPeople = await UnitOfWork.PersonRepository.GetAllPeople();
Assert.Single(allPeople);
var mergedPerson = allPeople[0];
Assert.Equal("Jillian Cowan", mergedPerson.Name);
var chapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(1, 1, PersonRole.Editor);
Assert.Equal(2, chapters.Count());
chapter = await UnitOfWork.ChapterRepository.GetChapterAsync(1, ChapterIncludes.People);
Assert.NotNull(chapter);
Assert.Single(chapter.People);
chapter2 = await UnitOfWork.ChapterRepository.GetChapterAsync(2, ChapterIncludes.People);
Assert.NotNull(chapter2);
Assert.Single(chapter2.People);
Assert.Equal(chapter.People.First().PersonId, chapter2.People.First().PersonId);
}
[Fact]
public async Task PersonMerge_NoDuplicateChaptersOrSeries()
{
await ResetDb();
var ps = new PersonService(UnitOfWork);
var library = new LibraryBuilder("My Library").Build();
UnitOfWork.LibraryRepository.Add(library);
await UnitOfWork.CommitAsync();
var user = new AppUserBuilder("Amelia", "amelia@localhost")
.WithLibrary(library).Build();
UnitOfWork.UserRepository.Add(user);
var person = new PersonBuilder("Jillian Cowan").Build();
var person2 = new PersonBuilder("Cowan Jillian").Build();
var chapter = new ChapterBuilder("1")
.WithPerson(person, PersonRole.Editor)
.WithPerson(person2, PersonRole.Colorist)
.Build();
var chapter2 = new ChapterBuilder("2")
.WithPerson(person2, PersonRole.Editor)
.WithPerson(person, PersonRole.Editor)
.Build();
var series = new SeriesBuilder("Test 1")
.WithLibraryId(library.Id)
.WithVolume(new VolumeBuilder("1")
.WithChapter(chapter)
.Build())
.WithMetadata(new SeriesMetadataBuilder()
.WithPerson(person, PersonRole.Editor)
.WithPerson(person2, PersonRole.Editor)
.Build())
.Build();
var series2 = new SeriesBuilder("Test 2")
.WithLibraryId(library.Id)
.WithVolume(new VolumeBuilder("2")
.WithChapter(chapter2)
.Build())
.WithMetadata(new SeriesMetadataBuilder()
.WithPerson(person, PersonRole.Editor)
.WithPerson(person2, PersonRole.Colorist)
.Build())
.Build();
UnitOfWork.SeriesRepository.Add(series);
UnitOfWork.SeriesRepository.Add(series2);
await UnitOfWork.CommitAsync();
await ps.MergePeopleAsync(person2, person);
var allPeople = await UnitOfWork.PersonRepository.GetAllPeople();
Assert.Single(allPeople);
var mergedPerson = await UnitOfWork.PersonRepository.GetPersonById(person.Id, PersonIncludes.All);
Assert.NotNull(mergedPerson);
Assert.Equal(3, mergedPerson.ChapterPeople.Count);
Assert.Equal(3, mergedPerson.SeriesMetadataPeople.Count);
chapter = await UnitOfWork.ChapterRepository.GetChapterAsync(chapter.Id, ChapterIncludes.People);
Assert.NotNull(chapter);
Assert.Equal(2, chapter.People.Count);
Assert.Single(chapter.People.Select(p => p.Person.Id).Distinct());
Assert.Contains(chapter.People, p => p.Role == PersonRole.Editor);
Assert.Contains(chapter.People, p => p.Role == PersonRole.Colorist);
chapter2 = await UnitOfWork.ChapterRepository.GetChapterAsync(chapter2.Id, ChapterIncludes.People);
Assert.NotNull(chapter2);
Assert.Single(chapter2.People);
Assert.Contains(chapter2.People, p => p.Role == PersonRole.Editor);
Assert.DoesNotContain(chapter2.People, p => p.Role == PersonRole.Colorist);
series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(series.Id, SeriesIncludes.Metadata);
Assert.NotNull(series);
Assert.Single(series.Metadata.People);
Assert.Contains(series.Metadata.People, p => p.Role == PersonRole.Editor);
Assert.DoesNotContain(series.Metadata.People, p => p.Role == PersonRole.Colorist);
series2 = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(series2.Id, SeriesIncludes.Metadata);
Assert.NotNull(series2);
Assert.Equal(2, series2.Metadata.People.Count);
Assert.Contains(series2.Metadata.People, p => p.Role == PersonRole.Editor);
Assert.Contains(series2.Metadata.People, p => p.Role == PersonRole.Colorist);
}
[Fact]
public async Task PersonAddAlias_NoOverlap()
{
await ResetDb();
UnitOfWork.PersonRepository.Attach(new PersonBuilder("Jillian Cowan").Build());
UnitOfWork.PersonRepository.Attach(new PersonBuilder("Jilly Cowan").WithAlias("Jolly Cowan").Build());
await UnitOfWork.CommitAsync();
var ps = new PersonService(UnitOfWork);
var person1 = await UnitOfWork.PersonRepository.GetPersonByNameOrAliasAsync("Jillian Cowan");
var person2 = await UnitOfWork.PersonRepository.GetPersonByNameOrAliasAsync("Jilly Cowan");
Assert.NotNull(person1);
Assert.NotNull(person2);
// Overlap on Name
var success = await ps.UpdatePersonAliasesAsync(person1, ["Jilly Cowan"]);
Assert.False(success);
// Overlap on alias
success = await ps.UpdatePersonAliasesAsync(person1, ["Jolly Cowan"]);
Assert.False(success);
// No overlap
success = await ps.UpdatePersonAliasesAsync(person2, ["Jilly Joy Cowan"]);
Assert.True(success);
// Some overlap
success = await ps.UpdatePersonAliasesAsync(person1, ["Jolly Cowan", "Jilly Joy Cowan"]);
Assert.False(success);
// Some overlap
success = await ps.UpdatePersonAliasesAsync(person1, ["Jolly Cowan", "Jilly Joy Cowan"]);
Assert.False(success);
Assert.Single(person2.Aliases);
}
protected override async Task ResetDb()
{
Context.Person.RemoveRange(Context.Person.ToList());
await Context.SaveChangesAsync();
}
}

View file

@ -8,6 +8,7 @@ using API.Data;
using API.Data.Repositories; using API.Data.Repositories;
using API.DTOs; using API.DTOs;
using API.DTOs.Metadata; using API.DTOs.Metadata;
using API.DTOs.Person;
using API.DTOs.SeriesDetail; using API.DTOs.SeriesDetail;
using API.Entities; using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;

View file

@ -51,7 +51,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="CsvHelper" Version="33.0.1" /> <PackageReference Include="CsvHelper" Version="33.0.1" />
<PackageReference Include="MailKit" Version="4.11.0" /> <PackageReference Include="MailKit" Version="4.12.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.4"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.4">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@ -66,7 +66,7 @@
<PackageReference Include="Hangfire.InMemory" Version="1.0.0" /> <PackageReference Include="Hangfire.InMemory" Version="1.0.0" />
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" /> <PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
<PackageReference Include="Hangfire.Storage.SQLite" Version="0.4.2" /> <PackageReference Include="Hangfire.Storage.SQLite" Version="0.4.2" />
<PackageReference Include="HtmlAgilityPack" Version="1.12.0" /> <PackageReference Include="HtmlAgilityPack" Version="1.12.1" />
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" /> <PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.18" /> <PackageReference Include="Hangfire.AspNetCore" Version="1.8.18" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" /> <PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
@ -78,7 +78,7 @@
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" /> <PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
<PackageReference Include="MimeTypeMapOfficial" Version="1.0.17" /> <PackageReference Include="MimeTypeMapOfficial" Version="1.0.17" />
<PackageReference Include="Nager.ArticleNumber" Version="1.0.7" /> <PackageReference Include="Nager.ArticleNumber" Version="1.0.7" />
<PackageReference Include="NetVips" Version="3.0.0" /> <PackageReference Include="NetVips" Version="3.0.1" />
<PackageReference Include="NetVips.Native" Version="8.16.1" /> <PackageReference Include="NetVips.Native" Version="8.16.1" />
<PackageReference Include="Serilog" Version="4.2.0" /> <PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" /> <PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
@ -87,20 +87,20 @@
<PackageReference Include="Serilog.Settings.Configuration" Version="9.0.0" /> <PackageReference Include="Serilog.Settings.Configuration" Version="9.0.0" />
<PackageReference Include="Serilog.Sinks.AspNetCore.SignalR" Version="0.4.0" /> <PackageReference Include="Serilog.Sinks.AspNetCore.SignalR" Version="0.4.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" /> <PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" /> <PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Serilog.Sinks.SignalR.Core" Version="0.1.2" /> <PackageReference Include="Serilog.Sinks.SignalR.Core" Version="0.1.2" />
<PackageReference Include="SharpCompress" Version="0.39.0" /> <PackageReference Include="SharpCompress" Version="0.39.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.7" /> <PackageReference Include="SixLabors.ImageSharp" Version="3.1.8" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.8.0.113526"> <PackageReference Include="SonarAnalyzer.CSharp" Version="10.9.0.115408">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.1" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.1" />
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.2" /> <PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.3" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.8.0" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.9.0" />
<PackageReference Include="System.IO.Abstractions" Version="22.0.13" /> <PackageReference Include="System.IO.Abstractions" Version="22.0.14" />
<PackageReference Include="System.Drawing.Common" Version="9.0.4" /> <PackageReference Include="System.Drawing.Common" Version="9.0.4" />
<PackageReference Include="VersOne.Epub" Version="3.3.3" /> <PackageReference Include="VersOne.Epub" Version="3.3.4" />
<PackageReference Include="YamlDotNet" Version="16.3.0" /> <PackageReference Include="YamlDotNet" Version="16.3.0" />
</ItemGroup> </ItemGroup>
@ -111,17 +111,16 @@
<ItemGroup> <ItemGroup>
<None Remove="Hangfire-log.db" />
<None Remove="obj\**" /> <None Remove="obj\**" />
<None Remove="cache\**" /> <None Remove="cache\**" />
<None Remove="cache-long\**" />
<None Remove="backups\**" /> <None Remove="backups\**" />
<None Remove="logs\**" /> <None Remove="logs\**" />
<None Remove="temp\**" /> <None Remove="temp\**" />
<None Remove="kavita.log" /> <None Remove="config\kavita.log" />
<None Remove="kavita.db" /> <None Remove="config\kavita.db" />
<None Remove="covers\**" /> <None Remove="config\covers\**" />
<None Remove="wwwroot\**" /> <None Remove="wwwroot\**" />
<None Remove="cache\cache-long\**" />
<None Remove="config\cache\**" /> <None Remove="config\cache\**" />
<None Remove="config\logs\**" /> <None Remove="config\logs\**" />
<None Remove="config\covers\**" /> <None Remove="config\covers\**" />
@ -139,6 +138,7 @@
<Compile Remove="covers\**" /> <Compile Remove="covers\**" />
<Compile Remove="wwwroot\**" /> <Compile Remove="wwwroot\**" />
<Compile Remove="config\cache\**" /> <Compile Remove="config\cache\**" />
<Compile Remove="cache\cache-long\**" />
<Compile Remove="config\logs\**" /> <Compile Remove="config\logs\**" />
<Compile Remove="config\covers\**" /> <Compile Remove="config\covers\**" />
<Compile Remove="config\bookmarks\**" /> <Compile Remove="config\bookmarks\**" />
@ -188,7 +188,6 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Folder Include="config\cache-long\" />
<Folder Include="config\themes" /> <Folder Include="config\themes" />
<Content Include="EmailTemplates\**"> <Content Include="EmailTemplates\**">
<CopyToOutputDirectory>Always</CopyToOutputDirectory> <CopyToOutputDirectory>Always</CopyToOutputDirectory>

View file

@ -6,9 +6,9 @@ using System.Threading.Tasks;
using API.Constants; using API.Constants;
using API.Data; using API.Data;
using API.Data.Repositories; using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Filtering; using API.DTOs.Filtering;
using API.DTOs.Metadata; using API.DTOs.Metadata;
using API.DTOs.Person;
using API.DTOs.Recommendation; using API.DTOs.Recommendation;
using API.DTOs.SeriesDetail; using API.DTOs.SeriesDetail;
using API.Entities.Enums; using API.Entities.Enums;
@ -74,6 +74,7 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
{ {
return Ok(await unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(User.GetUserId(), ids)); return Ok(await unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(User.GetUserId(), ids));
} }
return Ok(await unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(User.GetUserId())); return Ok(await unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(User.GetUserId()));
} }

View file

@ -15,6 +15,7 @@ using API.DTOs.CollectionTags;
using API.DTOs.Filtering; using API.DTOs.Filtering;
using API.DTOs.Filtering.v2; using API.DTOs.Filtering.v2;
using API.DTOs.OPDS; using API.DTOs.OPDS;
using API.DTOs.Person;
using API.DTOs.Progress; using API.DTOs.Progress;
using API.DTOs.Search; using API.DTOs.Search;
using API.Entities; using API.Entities;

View file

@ -1,7 +1,10 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Data; using API.Data;
using API.Data.Repositories;
using API.DTOs; using API.DTOs;
using API.DTOs.Person;
using API.Entities.Enums; using API.Entities.Enums;
using API.Extensions; using API.Extensions;
using API.Helpers; using API.Helpers;
@ -24,9 +27,10 @@ public class PersonController : BaseApiController
private readonly ICoverDbService _coverDbService; private readonly ICoverDbService _coverDbService;
private readonly IImageService _imageService; private readonly IImageService _imageService;
private readonly IEventHub _eventHub; private readonly IEventHub _eventHub;
private readonly IPersonService _personService;
public PersonController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IMapper mapper, public PersonController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IMapper mapper,
ICoverDbService coverDbService, IImageService imageService, IEventHub eventHub) ICoverDbService coverDbService, IImageService imageService, IEventHub eventHub, IPersonService personService)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_localizationService = localizationService; _localizationService = localizationService;
@ -34,6 +38,7 @@ public class PersonController : BaseApiController
_coverDbService = coverDbService; _coverDbService = coverDbService;
_imageService = imageService; _imageService = imageService;
_eventHub = eventHub; _eventHub = eventHub;
_personService = personService;
} }
@ -43,6 +48,17 @@ public class PersonController : BaseApiController
return Ok(await _unitOfWork.PersonRepository.GetPersonDtoByName(name, User.GetUserId())); return Ok(await _unitOfWork.PersonRepository.GetPersonDtoByName(name, User.GetUserId()));
} }
/// <summary>
/// Find a person by name or alias against a query string
/// </summary>
/// <param name="queryString"></param>
/// <returns></returns>
[HttpGet("search")]
public async Task<ActionResult<List<PersonDto>>> SearchPeople([FromQuery] string queryString)
{
return Ok(await _unitOfWork.PersonRepository.SearchPeople(queryString));
}
/// <summary> /// <summary>
/// Returns all roles for a Person /// Returns all roles for a Person
/// </summary> /// </summary>
@ -54,6 +70,7 @@ public class PersonController : BaseApiController
return Ok(await _unitOfWork.PersonRepository.GetRolesForPersonByName(personId, User.GetUserId())); return Ok(await _unitOfWork.PersonRepository.GetRolesForPersonByName(personId, User.GetUserId()));
} }
/// <summary> /// <summary>
/// Returns a list of authors and artists for browsing /// Returns a list of authors and artists for browsing
/// </summary> /// </summary>
@ -78,7 +95,7 @@ public class PersonController : BaseApiController
public async Task<ActionResult<PersonDto>> UpdatePerson(UpdatePersonDto dto) public async Task<ActionResult<PersonDto>> UpdatePerson(UpdatePersonDto dto)
{ {
// This needs to get all people and update them equally // This needs to get all people and update them equally
var person = await _unitOfWork.PersonRepository.GetPersonById(dto.Id); var person = await _unitOfWork.PersonRepository.GetPersonById(dto.Id, PersonIncludes.Aliases);
if (person == null) return BadRequest(_localizationService.Translate(User.GetUserId(), "person-doesnt-exist")); if (person == null) return BadRequest(_localizationService.Translate(User.GetUserId(), "person-doesnt-exist"));
if (string.IsNullOrEmpty(dto.Name)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-name-required")); if (string.IsNullOrEmpty(dto.Name)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-name-required"));
@ -90,6 +107,10 @@ public class PersonController : BaseApiController
return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-name-unique")); return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-name-unique"));
} }
var success = await _personService.UpdatePersonAliasesAsync(person, dto.Aliases);
if (!success) return BadRequest(await _localizationService.Translate(User.GetUserId(), "aliases-have-overlap"));
person.Name = dto.Name?.Trim(); person.Name = dto.Name?.Trim();
person.Description = dto.Description ?? string.Empty; person.Description = dto.Description ?? string.Empty;
person.CoverImageLocked = dto.CoverImageLocked; person.CoverImageLocked = dto.CoverImageLocked;
@ -173,5 +194,41 @@ public class PersonController : BaseApiController
return Ok(await _unitOfWork.PersonRepository.GetChaptersForPersonByRole(personId, User.GetUserId(), role)); return Ok(await _unitOfWork.PersonRepository.GetChaptersForPersonByRole(personId, User.GetUserId(), role));
} }
/// <summary>
/// Merges Persons into one, this action is irreversible
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("merge")]
public async Task<ActionResult<PersonDto>> MergePeople(PersonMergeDto dto)
{
var dst = await _unitOfWork.PersonRepository.GetPersonById(dto.DestId, PersonIncludes.All);
if (dst == null) return BadRequest();
var src = await _unitOfWork.PersonRepository.GetPersonById(dto.SrcId, PersonIncludes.All);
if (src == null) return BadRequest();
await _personService.MergePeopleAsync(src, dst);
await _eventHub.SendMessageAsync(MessageFactory.PersonMerged, MessageFactory.PersonMergedMessage(dst, src));
return Ok(_mapper.Map<PersonDto>(dst));
}
/// <summary>
/// Ensure the alias is valid to be added. For example, the alias cannot be on another person or be the same as the current person name/alias.
/// </summary>
/// <param name="personId"></param>
/// <param name="alias"></param>
/// <returns></returns>
[HttpGet("valid-alias")]
public async Task<ActionResult<bool>> IsValidAlias(int personId, string alias)
{
var person = await _unitOfWork.PersonRepository.GetPersonById(personId, PersonIncludes.Aliases);
if (person == null) return NotFound();
var existingAlias = await _unitOfWork.PersonRepository.AnyAliasExist(alias);
return Ok(!existingAlias && person.NormalizedName != alias.ToNormalized());
}
} }

View file

@ -4,7 +4,7 @@ using System.Threading.Tasks;
using API.Constants; using API.Constants;
using API.Data; using API.Data;
using API.Data.Repositories; using API.Data.Repositories;
using API.DTOs; using API.DTOs.Person;
using API.DTOs.ReadingLists; using API.DTOs.ReadingLists;
using API.Entities.Enums; using API.Entities.Enums;
using API.Extensions; using API.Extensions;

View file

@ -63,6 +63,7 @@ public class SearchController : BaseApiController
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (user == null) return Unauthorized(); if (user == null) return Unauthorized();
var libraries = _unitOfWork.LibraryRepository.GetLibraryIdsForUserIdAsync(user.Id, QueryContext.Search).ToList(); var libraries = _unitOfWork.LibraryRepository.GetLibraryIdsForUserIdAsync(user.Id, QueryContext.Search).ToList();
if (libraries.Count == 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "libraries-restricted")); if (libraries.Count == 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "libraries-restricted"));

View file

@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using API.DTOs.Metadata; using API.DTOs.Metadata;
using API.DTOs.Person;
using API.Entities.Enums; using API.Entities.Enums;
using API.Entities.Interfaces; using API.Entities.Interfaces;

View file

@ -4,14 +4,18 @@ using API.DTOs.Scrobbling;
namespace API.DTOs.KavitaPlus.ExternalMetadata; namespace API.DTOs.KavitaPlus.ExternalMetadata;
#nullable enable #nullable enable
/// <summary>
/// Represents a request to match some series from Kavita to an external id which K+ uses.
/// </summary>
internal sealed record MatchSeriesRequestDto internal sealed record MatchSeriesRequestDto
{ {
public string SeriesName { get; set; } public required string SeriesName { get; set; }
public ICollection<string> AlternativeNames { get; set; } public ICollection<string> AlternativeNames { get; set; } = [];
public int Year { get; set; } = 0; public int Year { get; set; } = 0;
public string Query { get; set; } public string? Query { get; set; }
public int? AniListId { get; set; } public int? AniListId { get; set; }
public long? MalId { get; set; } public long? MalId { get; set; }
public string? HardcoverId { get; set; } public string? HardcoverId { get; set; }
public int? CbrId { get; set; }
public PlusMediaFormat Format { get; set; } public PlusMediaFormat Format { get; set; }
} }

View file

@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using API.DTOs.Person;
using API.Entities.Enums; using API.Entities.Enums;
namespace API.DTOs.Metadata; namespace API.DTOs.Metadata;

View file

@ -1,4 +1,6 @@
namespace API.DTOs; using API.DTOs.Person;
namespace API.DTOs;
/// <summary> /// <summary>
/// Used to browse writers and click in to see their series /// Used to browse writers and click in to see their series

View file

@ -1,6 +1,6 @@
using System.Runtime.Serialization; using System.Collections.Generic;
namespace API.DTOs; namespace API.DTOs.Person;
#nullable enable #nullable enable
public class PersonDto public class PersonDto
@ -13,6 +13,7 @@ public class PersonDto
public string? SecondaryColor { get; set; } public string? SecondaryColor { get; set; }
public string? CoverImage { get; set; } public string? CoverImage { get; set; }
public List<string> Aliases { get; set; } = [];
public string? Description { get; set; } public string? Description { get; set; }
/// <summary> /// <summary>

View file

@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations;
namespace API.DTOs;
public sealed record PersonMergeDto
{
/// <summary>
/// The id of the person being merged into
/// </summary>
[Required]
public int DestId { get; init; }
/// <summary>
/// The id of the person being merged. This person will be removed, and become an alias of <see cref="DestId"/>
/// </summary>
[Required]
public int SrcId { get; init; }
}

View file

@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace API.DTOs; namespace API.DTOs;
#nullable enable #nullable enable
@ -11,6 +12,7 @@ public sealed record UpdatePersonDto
public bool CoverImageLocked { get; set; } public bool CoverImageLocked { get; set; }
[Required] [Required]
public string Name {get; set;} public string Name {get; set;}
public IList<string> Aliases { get; set; } = [];
public string? Description { get; set; } public string? Description { get; set; }
public int? AniListId { get; set; } public int? AniListId { get; set; }

View file

@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using API.DTOs.Person;
namespace API.DTOs.ReadingLists; namespace API.DTOs.ReadingLists;

View file

@ -2,6 +2,7 @@
using API.DTOs.Collection; using API.DTOs.Collection;
using API.DTOs.CollectionTags; using API.DTOs.CollectionTags;
using API.DTOs.Metadata; using API.DTOs.Metadata;
using API.DTOs.Person;
using API.DTOs.Reader; using API.DTOs.Reader;
using API.DTOs.ReadingLists; using API.DTOs.ReadingLists;

View file

@ -1,5 +1,6 @@
using System.Collections.Generic; using System.Collections.Generic;
using API.DTOs.Metadata; using API.DTOs.Metadata;
using API.DTOs.Person;
using API.Entities.Enums; using API.Entities.Enums;
namespace API.DTOs; namespace API.DTOs;

View file

@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using API.DTOs.Metadata; using API.DTOs.Metadata;
using API.DTOs.Person;
using API.Entities.Enums; using API.Entities.Enums;
namespace API.DTOs; namespace API.DTOs;

View file

@ -49,6 +49,7 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
public DbSet<ReadingList> ReadingList { get; set; } = null!; public DbSet<ReadingList> ReadingList { get; set; } = null!;
public DbSet<ReadingListItem> ReadingListItem { get; set; } = null!; public DbSet<ReadingListItem> ReadingListItem { get; set; } = null!;
public DbSet<Person> Person { get; set; } = null!; public DbSet<Person> Person { get; set; } = null!;
public DbSet<PersonAlias> PersonAlias { get; set; } = null!;
public DbSet<Genre> Genre { get; set; } = null!; public DbSet<Genre> Genre { get; set; } = null!;
public DbSet<Tag> Tag { get; set; } = null!; public DbSet<Tag> Tag { get; set; } = null!;
public DbSet<SiteTheme> SiteTheme { get; set; } = null!; public DbSet<SiteTheme> SiteTheme { get; set; } = null!;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,47 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class PersonAliases : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PersonAlias",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Alias = table.Column<string>(type: "TEXT", nullable: true),
NormalizedAlias = table.Column<string>(type: "TEXT", nullable: true),
PersonId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PersonAlias", x => x.Id);
table.ForeignKey(
name: "FK_PersonAlias_Person_PersonId",
column: x => x.PersonId,
principalTable: "Person",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_PersonAlias_PersonId",
table: "PersonAlias",
column: "PersonId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PersonAlias");
}
}
}

View file

@ -1836,6 +1836,28 @@ namespace API.Data.Migrations
b.ToTable("Person"); b.ToTable("Person");
}); });
modelBuilder.Entity("API.Entities.Person.PersonAlias", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Alias")
.HasColumnType("TEXT");
b.Property<string>("NormalizedAlias")
.HasColumnType("TEXT");
b.Property<int>("PersonId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PersonId");
b.ToTable("PersonAlias");
});
modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b =>
{ {
b.Property<int>("SeriesMetadataId") b.Property<int>("SeriesMetadataId")
@ -3082,6 +3104,17 @@ namespace API.Data.Migrations
b.Navigation("Person"); b.Navigation("Person");
}); });
modelBuilder.Entity("API.Entities.Person.PersonAlias", b =>
{
b.HasOne("API.Entities.Person.Person", "Person")
.WithMany("Aliases")
.HasForeignKey("PersonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Person");
});
modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b =>
{ {
b.HasOne("API.Entities.Person.Person", "Person") b.HasOne("API.Entities.Person.Person", "Person")
@ -3496,6 +3529,8 @@ namespace API.Data.Migrations
modelBuilder.Entity("API.Entities.Person.Person", b => modelBuilder.Entity("API.Entities.Person.Person", b =>
{ {
b.Navigation("Aliases");
b.Navigation("ChapterPeople"); b.Navigation("ChapterPeople");
b.Navigation("SeriesMetadataPeople"); b.Navigation("SeriesMetadataPeople");

View file

@ -1,7 +1,9 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.DTOs; using API.DTOs;
using API.DTOs.Person;
using API.Entities.Enums; using API.Entities.Enums;
using API.Entities.Person; using API.Entities.Person;
using API.Extensions; using API.Extensions;
@ -14,6 +16,17 @@ using Microsoft.EntityFrameworkCore;
namespace API.Data.Repositories; namespace API.Data.Repositories;
#nullable enable #nullable enable
[Flags]
public enum PersonIncludes
{
None = 1 << 0,
Aliases = 1 << 1,
ChapterPeople = 1 << 2,
SeriesPeople = 1 << 3,
All = Aliases | ChapterPeople | SeriesPeople,
}
public interface IPersonRepository public interface IPersonRepository
{ {
void Attach(Person person); void Attach(Person person);
@ -23,24 +36,41 @@ public interface IPersonRepository
void Remove(SeriesMetadataPeople person); void Remove(SeriesMetadataPeople person);
void Update(Person person); void Update(Person person);
Task<IList<Person>> GetAllPeople(); Task<IList<Person>> GetAllPeople(PersonIncludes includes = PersonIncludes.Aliases);
Task<IList<PersonDto>> GetAllPersonDtosAsync(int userId); Task<IList<PersonDto>> GetAllPersonDtosAsync(int userId, PersonIncludes includes = PersonIncludes.None);
Task<IList<PersonDto>> GetAllPersonDtosByRoleAsync(int userId, PersonRole role); Task<IList<PersonDto>> GetAllPersonDtosByRoleAsync(int userId, PersonRole role, PersonIncludes includes = PersonIncludes.None);
Task RemoveAllPeopleNoLongerAssociated(); Task RemoveAllPeopleNoLongerAssociated();
Task<IList<PersonDto>> GetAllPeopleDtosForLibrariesAsync(int userId, List<int>? libraryIds = null); Task<IList<PersonDto>> GetAllPeopleDtosForLibrariesAsync(int userId, List<int>? libraryIds = null, PersonIncludes includes = PersonIncludes.None);
Task<string?> GetCoverImageAsync(int personId); Task<string?> GetCoverImageAsync(int personId);
Task<string?> GetCoverImageByNameAsync(string name); Task<string?> GetCoverImageByNameAsync(string name);
Task<IEnumerable<PersonRole>> GetRolesForPersonByName(int personId, int userId); Task<IEnumerable<PersonRole>> GetRolesForPersonByName(int personId, int userId);
Task<PagedList<BrowsePersonDto>> GetAllWritersAndSeriesCount(int userId, UserParams userParams); Task<PagedList<BrowsePersonDto>> GetAllWritersAndSeriesCount(int userId, UserParams userParams);
Task<Person?> GetPersonById(int personId); Task<Person?> GetPersonById(int personId, PersonIncludes includes = PersonIncludes.None);
Task<PersonDto?> GetPersonDtoByName(string name, int userId); Task<PersonDto?> GetPersonDtoByName(string name, int userId, PersonIncludes includes = PersonIncludes.Aliases);
/// <summary>
/// Returns a person matched on normalized name or alias
/// </summary>
/// <param name="name"></param>
/// <param name="includes"></param>
/// <returns></returns>
Task<Person?> GetPersonByNameOrAliasAsync(string name, PersonIncludes includes = PersonIncludes.Aliases);
Task<bool> IsNameUnique(string name); Task<bool> IsNameUnique(string name);
Task<IEnumerable<SeriesDto>> GetSeriesKnownFor(int personId); Task<IEnumerable<SeriesDto>> GetSeriesKnownFor(int personId);
Task<IEnumerable<StandaloneChapterDto>> GetChaptersForPersonByRole(int personId, int userId, PersonRole role); Task<IEnumerable<StandaloneChapterDto>> GetChaptersForPersonByRole(int personId, int userId, PersonRole role);
Task<IList<Person>> GetPeopleByNames(List<string> normalizedNames); /// <summary>
Task<Person?> GetPersonByAniListId(int aniListId); /// Returns all people with a matching name, or alias
/// </summary>
/// <param name="normalizedNames"></param>
/// <param name="includes"></param>
/// <returns></returns>
Task<IList<Person>> GetPeopleByNames(List<string> normalizedNames, PersonIncludes includes = PersonIncludes.Aliases);
Task<Person?> GetPersonByAniListId(int aniListId, PersonIncludes includes = PersonIncludes.Aliases);
Task<IList<PersonDto>> SearchPeople(string searchQuery, PersonIncludes includes = PersonIncludes.Aliases);
Task<bool> AnyAliasExist(string alias);
} }
public class PersonRepository : IPersonRepository public class PersonRepository : IPersonRepository
@ -99,7 +129,7 @@ public class PersonRepository : IPersonRepository
} }
public async Task<IList<PersonDto>> GetAllPeopleDtosForLibrariesAsync(int userId, List<int>? libraryIds = null) public async Task<IList<PersonDto>> GetAllPeopleDtosForLibrariesAsync(int userId, List<int>? libraryIds = null, PersonIncludes includes = PersonIncludes.Aliases)
{ {
var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); var ageRating = await _context.AppUser.GetUserAgeRestriction(userId);
var userLibs = await _context.Library.GetUserLibraries(userId).ToListAsync(); var userLibs = await _context.Library.GetUserLibraries(userId).ToListAsync();
@ -113,6 +143,7 @@ public class PersonRepository : IPersonRepository
.Where(s => userLibs.Contains(s.LibraryId)) .Where(s => userLibs.Contains(s.LibraryId))
.RestrictAgainstAgeRestriction(ageRating) .RestrictAgainstAgeRestriction(ageRating)
.SelectMany(s => s.Metadata.People.Select(p => p.Person)) .SelectMany(s => s.Metadata.People.Select(p => p.Person))
.Includes(includes)
.Distinct() .Distinct()
.OrderBy(p => p.Name) .OrderBy(p => p.Name)
.AsNoTracking() .AsNoTracking()
@ -193,27 +224,41 @@ public class PersonRepository : IPersonRepository
return await PagedList<BrowsePersonDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize); return await PagedList<BrowsePersonDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
} }
public async Task<Person?> GetPersonById(int personId) public async Task<Person?> GetPersonById(int personId, PersonIncludes includes = PersonIncludes.None)
{ {
return await _context.Person.Where(p => p.Id == personId) return await _context.Person.Where(p => p.Id == personId)
.Includes(includes)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
} }
public async Task<PersonDto?> GetPersonDtoByName(string name, int userId) public async Task<PersonDto?> GetPersonDtoByName(string name, int userId, PersonIncludes includes = PersonIncludes.Aliases)
{ {
var normalized = name.ToNormalized(); var normalized = name.ToNormalized();
var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); var ageRating = await _context.AppUser.GetUserAgeRestriction(userId);
return await _context.Person return await _context.Person
.Where(p => p.NormalizedName == normalized) .Where(p => p.NormalizedName == normalized)
.Includes(includes)
.RestrictAgainstAgeRestriction(ageRating) .RestrictAgainstAgeRestriction(ageRating)
.ProjectTo<PersonDto>(_mapper.ConfigurationProvider) .ProjectTo<PersonDto>(_mapper.ConfigurationProvider)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
} }
public Task<Person?> GetPersonByNameOrAliasAsync(string name, PersonIncludes includes = PersonIncludes.Aliases)
{
var normalized = name.ToNormalized();
return _context.Person
.Includes(includes)
.Where(p => p.NormalizedName == normalized || p.Aliases.Any(pa => pa.NormalizedAlias == normalized))
.FirstOrDefaultAsync();
}
public async Task<bool> IsNameUnique(string name) public async Task<bool> IsNameUnique(string name)
{ {
return !(await _context.Person.AnyAsync(p => p.Name == name)); // Should this use Normalized to check?
return !(await _context.Person
.Includes(PersonIncludes.Aliases)
.AnyAsync(p => p.Name == name || p.Aliases.Any(pa => pa.Alias == name)));
} }
public async Task<IEnumerable<SeriesDto>> GetSeriesKnownFor(int personId) public async Task<IEnumerable<SeriesDto>> GetSeriesKnownFor(int personId)
@ -245,45 +290,69 @@ public class PersonRepository : IPersonRepository
.ToListAsync(); .ToListAsync();
} }
public async Task<IList<Person>> GetPeopleByNames(List<string> normalizedNames) public async Task<IList<Person>> GetPeopleByNames(List<string> normalizedNames, PersonIncludes includes = PersonIncludes.Aliases)
{ {
return await _context.Person return await _context.Person
.Where(p => normalizedNames.Contains(p.NormalizedName)) .Includes(includes)
.Where(p => normalizedNames.Contains(p.NormalizedName) || p.Aliases.Any(pa => normalizedNames.Contains(pa.NormalizedAlias)))
.OrderBy(p => p.Name) .OrderBy(p => p.Name)
.ToListAsync(); .ToListAsync();
} }
public async Task<Person?> GetPersonByAniListId(int aniListId) public async Task<Person?> GetPersonByAniListId(int aniListId, PersonIncludes includes = PersonIncludes.Aliases)
{ {
return await _context.Person return await _context.Person
.Where(p => p.AniListId == aniListId) .Where(p => p.AniListId == aniListId)
.Includes(includes)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
} }
public async Task<IList<Person>> GetAllPeople() public async Task<IList<PersonDto>> SearchPeople(string searchQuery, PersonIncludes includes = PersonIncludes.Aliases)
{
searchQuery = searchQuery.ToNormalized();
return await _context.Person
.Includes(includes)
.Where(p => EF.Functions.Like(p.Name, $"%{searchQuery}%")
|| p.Aliases.Any(pa => EF.Functions.Like(pa.Alias, $"%{searchQuery}%")))
.ProjectTo<PersonDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
public async Task<bool> AnyAliasExist(string alias)
{
return await _context.PersonAlias.AnyAsync(pa => pa.NormalizedAlias == alias.ToNormalized());
}
public async Task<IList<Person>> GetAllPeople(PersonIncludes includes = PersonIncludes.Aliases)
{ {
return await _context.Person return await _context.Person
.Includes(includes)
.OrderBy(p => p.Name) .OrderBy(p => p.Name)
.ToListAsync(); .ToListAsync();
} }
public async Task<IList<PersonDto>> GetAllPersonDtosAsync(int userId) public async Task<IList<PersonDto>> GetAllPersonDtosAsync(int userId, PersonIncludes includes = PersonIncludes.Aliases)
{ {
var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); var ageRating = await _context.AppUser.GetUserAgeRestriction(userId);
return await _context.Person return await _context.Person
.Includes(includes)
.OrderBy(p => p.Name) .OrderBy(p => p.Name)
.RestrictAgainstAgeRestriction(ageRating) .RestrictAgainstAgeRestriction(ageRating)
.ProjectTo<PersonDto>(_mapper.ConfigurationProvider) .ProjectTo<PersonDto>(_mapper.ConfigurationProvider)
.ToListAsync(); .ToListAsync();
} }
public async Task<IList<PersonDto>> GetAllPersonDtosByRoleAsync(int userId, PersonRole role) public async Task<IList<PersonDto>> GetAllPersonDtosByRoleAsync(int userId, PersonRole role, PersonIncludes includes = PersonIncludes.Aliases)
{ {
var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); var ageRating = await _context.AppUser.GetUserAgeRestriction(userId);
return await _context.Person return await _context.Person
.Where(p => p.SeriesMetadataPeople.Any(smp => smp.Role == role) || p.ChapterPeople.Any(cp => cp.Role == role)) // Filter by role in both series and chapters .Where(p => p.SeriesMetadataPeople.Any(smp => smp.Role == role) || p.ChapterPeople.Any(cp => cp.Role == role)) // Filter by role in both series and chapters
.Includes(includes)
.OrderBy(p => p.Name) .OrderBy(p => p.Name)
.RestrictAgainstAgeRestriction(ageRating) .RestrictAgainstAgeRestriction(ageRating)
.ProjectTo<PersonDto>(_mapper.ConfigurationProvider) .ProjectTo<PersonDto>(_mapper.ConfigurationProvider)

View file

@ -3,7 +3,7 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Data.Misc; using API.Data.Misc;
using API.DTOs; using API.DTOs.Person;
using API.DTOs.ReadingLists; using API.DTOs.ReadingLists;
using API.Entities; using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;

View file

@ -15,6 +15,7 @@ using API.DTOs.Filtering;
using API.DTOs.Filtering.v2; using API.DTOs.Filtering.v2;
using API.DTOs.KavitaPlus.Metadata; using API.DTOs.KavitaPlus.Metadata;
using API.DTOs.Metadata; using API.DTOs.Metadata;
using API.DTOs.Person;
using API.DTOs.ReadingLists; using API.DTOs.ReadingLists;
using API.DTOs.Recommendation; using API.DTOs.Recommendation;
using API.DTOs.Scrobbling; using API.DTOs.Scrobbling;
@ -455,11 +456,18 @@ public class SeriesRepository : ISeriesRepository
.ProjectTo<AppUserCollectionDto>(_mapper.ConfigurationProvider) .ProjectTo<AppUserCollectionDto>(_mapper.ConfigurationProvider)
.ToListAsync(); .ToListAsync();
result.Persons = await _context.SeriesMetadata // I can't work out how to map people in DB layer
var personIds = await _context.SeriesMetadata
.SearchPeople(searchQuery, seriesIds) .SearchPeople(searchQuery, seriesIds)
.Take(maxRecords) .Select(p => p.Id)
.OrderBy(t => t.NormalizedName)
.Distinct() .Distinct()
.OrderBy(id => id)
.Take(maxRecords)
.ToListAsync();
result.Persons = await _context.Person
.Where(p => personIds.Contains(p.Id))
.OrderBy(p => p.NormalizedName)
.ProjectTo<PersonDto>(_mapper.ConfigurationProvider) .ProjectTo<PersonDto>(_mapper.ConfigurationProvider)
.ToListAsync(); .ToListAsync();
@ -475,8 +483,8 @@ public class SeriesRepository : ISeriesRepository
.ProjectTo<TagDto>(_mapper.ConfigurationProvider) .ProjectTo<TagDto>(_mapper.ConfigurationProvider)
.ToListAsync(); .ToListAsync();
result.Files = new List<MangaFileDto>(); result.Files = [];
result.Chapters = new List<ChapterDto>(); result.Chapters = (List<ChapterDto>) [];
if (includeChapterAndFiles) if (includeChapterAndFiles)

View file

@ -8,8 +8,7 @@ public class Person : IHasCoverImage
public int Id { get; set; } public int Id { get; set; }
public required string Name { get; set; } public required string Name { get; set; }
public required string NormalizedName { get; set; } public required string NormalizedName { get; set; }
public ICollection<PersonAlias> Aliases { get; set; } = [];
//public ICollection<PersonAlias> Aliases { get; set; } = default!;
public string? CoverImage { get; set; } public string? CoverImage { get; set; }
public bool CoverImageLocked { get; set; } public bool CoverImageLocked { get; set; }
@ -47,8 +46,8 @@ public class Person : IHasCoverImage
//public long MetronId { get; set; } = 0; //public long MetronId { get; set; } = 0;
// Relationships // Relationships
public ICollection<ChapterPeople> ChapterPeople { get; set; } = new List<ChapterPeople>(); public ICollection<ChapterPeople> ChapterPeople { get; set; } = [];
public ICollection<SeriesMetadataPeople> SeriesMetadataPeople { get; set; } = new List<SeriesMetadataPeople>(); public ICollection<SeriesMetadataPeople> SeriesMetadataPeople { get; set; } = [];
public void ResetColorScape() public void ResetColorScape()

View file

@ -0,0 +1,11 @@
namespace API.Entities.Person;
public class PersonAlias
{
public int Id { get; set; }
public required string Alias { get; set; }
public required string NormalizedAlias { get; set; }
public int PersonId { get; set; }
public Person Person { get; set; }
}

View file

@ -53,6 +53,7 @@ public static class ApplicationServiceExtensions
services.AddScoped<IMediaConversionService, MediaConversionService>(); services.AddScoped<IMediaConversionService, MediaConversionService>();
services.AddScoped<IStreamService, StreamService>(); services.AddScoped<IStreamService, StreamService>();
services.AddScoped<IRatingService, RatingService>(); services.AddScoped<IRatingService, RatingService>();
services.AddScoped<IPersonService, PersonService>();
services.AddScoped<IScannerService, ScannerService>(); services.AddScoped<IScannerService, ScannerService>();
services.AddScoped<IProcessSeries, ProcessSeries>(); services.AddScoped<IProcessSeries, ProcessSeries>();

View file

@ -1,4 +1,5 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using API.Data.Misc; using API.Data.Misc;
using API.Data.Repositories; using API.Data.Repositories;
@ -49,23 +50,26 @@ public static class SearchQueryableExtensions
// Get people from SeriesMetadata // Get people from SeriesMetadata
var peopleFromSeriesMetadata = queryable var peopleFromSeriesMetadata = queryable
.Where(sm => seriesIds.Contains(sm.SeriesId)) .Where(sm => seriesIds.Contains(sm.SeriesId))
.SelectMany(sm => sm.People) .SelectMany(sm => sm.People.Select(sp => sp.Person))
.Where(p => p.Person.Name != null && EF.Functions.Like(p.Person.Name, $"%{searchQuery}%")) .Where(p =>
.Select(p => p.Person); EF.Functions.Like(p.Name, $"%{searchQuery}%") ||
p.Aliases.Any(pa => EF.Functions.Like(pa.Alias, $"%{searchQuery}%"))
);
// Get people from ChapterPeople by navigating through Volume -> Series
var peopleFromChapterPeople = queryable var peopleFromChapterPeople = queryable
.Where(sm => seriesIds.Contains(sm.SeriesId)) .Where(sm => seriesIds.Contains(sm.SeriesId))
.SelectMany(sm => sm.Series.Volumes) .SelectMany(sm => sm.Series.Volumes)
.SelectMany(v => v.Chapters) .SelectMany(v => v.Chapters)
.SelectMany(ch => ch.People) .SelectMany(ch => ch.People.Select(cp => cp.Person))
.Where(cp => cp.Person.Name != null && EF.Functions.Like(cp.Person.Name, $"%{searchQuery}%")) .Where(p =>
.Select(cp => cp.Person); EF.Functions.Like(p.Name, $"%{searchQuery}%") ||
p.Aliases.Any(pa => EF.Functions.Like(pa.Alias, $"%{searchQuery}%"))
);
// Combine both queries and ensure distinct results // Combine both queries and ensure distinct results
return peopleFromSeriesMetadata return peopleFromSeriesMetadata
.Union(peopleFromChapterPeople) .Union(peopleFromChapterPeople)
.Distinct() .Select(p => p)
.OrderBy(p => p.NormalizedName); .OrderBy(p => p.NormalizedName);
} }

View file

@ -1,7 +1,7 @@
using System.Linq; using System.Linq;
using API.Data.Repositories; using API.Data.Repositories;
using API.Entities; using API.Entities;
using API.Entities.Metadata; using API.Entities.Person;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace API.Extensions.QueryExtensions; namespace API.Extensions.QueryExtensions;
@ -321,4 +321,25 @@ public static class IncludesExtensions
return query.AsSplitQuery(); return query.AsSplitQuery();
} }
public static IQueryable<Person> Includes(this IQueryable<Person> queryable, PersonIncludes includeFlags)
{
if (includeFlags.HasFlag(PersonIncludes.Aliases))
{
queryable = queryable.Include(p => p.Aliases);
}
if (includeFlags.HasFlag(PersonIncludes.ChapterPeople))
{
queryable = queryable.Include(p => p.ChapterPeople);
}
if (includeFlags.HasFlag(PersonIncludes.SeriesPeople))
{
queryable = queryable.Include(p => p.SeriesMetadataPeople);
}
return queryable;
}
} }

View file

@ -15,6 +15,7 @@ using API.DTOs.KavitaPlus.Manage;
using API.DTOs.KavitaPlus.Metadata; using API.DTOs.KavitaPlus.Metadata;
using API.DTOs.MediaErrors; using API.DTOs.MediaErrors;
using API.DTOs.Metadata; using API.DTOs.Metadata;
using API.DTOs.Person;
using API.DTOs.Progress; using API.DTOs.Progress;
using API.DTOs.Reader; using API.DTOs.Reader;
using API.DTOs.ReadingLists; using API.DTOs.ReadingLists;
@ -68,7 +69,8 @@ public class AutoMapperProfiles : Profile
CreateMap<AppUserCollection, AppUserCollectionDto>() CreateMap<AppUserCollection, AppUserCollectionDto>()
.ForMember(dest => dest.Owner, opt => opt.MapFrom(src => src.AppUser.UserName)) .ForMember(dest => dest.Owner, opt => opt.MapFrom(src => src.AppUser.UserName))
.ForMember(dest => dest.ItemCount, opt => opt.MapFrom(src => src.Items.Count)); .ForMember(dest => dest.ItemCount, opt => opt.MapFrom(src => src.Items.Count));
CreateMap<Person, PersonDto>(); CreateMap<Person, PersonDto>()
.ForMember(dest => dest.Aliases, opt => opt.MapFrom(src => src.Aliases.Select(s => s.Alias)));
CreateMap<Genre, GenreTagDto>(); CreateMap<Genre, GenreTagDto>();
CreateMap<Tag, TagDto>(); CreateMap<Tag, TagDto>();
CreateMap<AgeRating, AgeRatingDto>(); CreateMap<AgeRating, AgeRatingDto>();

View file

@ -0,0 +1,19 @@
using API.Entities.Person;
using API.Extensions;
namespace API.Helpers.Builders;
public class PersonAliasBuilder : IEntityBuilder<PersonAlias>
{
private readonly PersonAlias _alias;
public PersonAlias Build() => _alias;
public PersonAliasBuilder(string name)
{
_alias = new PersonAlias()
{
Alias = name.Trim(),
NormalizedAlias = name.ToNormalized(),
};
}
}

View file

@ -1,7 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using API.Entities; using System.Linq;
using API.Entities.Enums;
using API.Entities.Metadata;
using API.Entities.Person; using API.Entities.Person;
using API.Extensions; using API.Extensions;
@ -34,6 +32,20 @@ public class PersonBuilder : IEntityBuilder<Person>
return this; return this;
} }
public PersonBuilder WithAlias(string alias)
{
if (_person.Aliases.Any(a => a.NormalizedAlias.Equals(alias.ToNormalized())))
{
return this;
}
_person.Aliases.Add(new PersonAliasBuilder(alias).Build());
return this;
}
public PersonBuilder WithSeriesMetadata(SeriesMetadataPeople seriesMetadataPeople) public PersonBuilder WithSeriesMetadata(SeriesMetadataPeople seriesMetadataPeople)
{ {
_person.SeriesMetadataPeople.Add(seriesMetadataPeople); _person.SeriesMetadataPeople.Add(seriesMetadataPeople);

View file

@ -17,6 +17,20 @@ namespace API.Helpers;
public static class PersonHelper public static class PersonHelper
{ {
public static Dictionary<string, Person> ConstructNameAndAliasDictionary(IList<Person> people)
{
var dict = new Dictionary<string, Person>();
foreach (var person in people)
{
dict.TryAdd(person.NormalizedName, person);
foreach (var alias in person.Aliases)
{
dict.TryAdd(alias.NormalizedAlias, person);
}
}
return dict;
}
public static async Task UpdateSeriesMetadataPeopleAsync(SeriesMetadata metadata, ICollection<SeriesMetadataPeople> metadataPeople, public static async Task UpdateSeriesMetadataPeopleAsync(SeriesMetadata metadata, ICollection<SeriesMetadataPeople> metadataPeople,
IEnumerable<ChapterPeople> chapterPeople, PersonRole role, IUnitOfWork unitOfWork) IEnumerable<ChapterPeople> chapterPeople, PersonRole role, IUnitOfWork unitOfWork)
{ {
@ -38,7 +52,9 @@ public static class PersonHelper
// Identify people to remove from metadataPeople // Identify people to remove from metadataPeople
var peopleToRemove = existingMetadataPeople var peopleToRemove = existingMetadataPeople
.Where(person => !peopleToAddSet.Contains(person.Person.NormalizedName)) .Where(person =>
!peopleToAddSet.Contains(person.Person.NormalizedName) &&
!person.Person.Aliases.Any(pa => peopleToAddSet.Contains(pa.NormalizedAlias)))
.ToList(); .ToList();
// Remove identified people from metadataPeople // Remove identified people from metadataPeople
@ -53,11 +69,7 @@ public static class PersonHelper
.GetPeopleByNames(peopleToAdd.Select(p => p.NormalizedName).ToList()); .GetPeopleByNames(peopleToAdd.Select(p => p.NormalizedName).ToList());
// Prepare a dictionary for quick lookup of existing people by normalized name // Prepare a dictionary for quick lookup of existing people by normalized name
var existingPeopleDict = new Dictionary<string, Person>(); var existingPeopleDict = ConstructNameAndAliasDictionary(existingPeopleInDb);
foreach (var person in existingPeopleInDb)
{
existingPeopleDict.TryAdd(person.NormalizedName, person);
}
// Track the people to attach (newly created people) // Track the people to attach (newly created people)
var peopleToAttach = new List<Person>(); var peopleToAttach = new List<Person>();
@ -129,15 +141,12 @@ public static class PersonHelper
var existingPeople = await unitOfWork.PersonRepository.GetPeopleByNames(normalizedPeople); var existingPeople = await unitOfWork.PersonRepository.GetPeopleByNames(normalizedPeople);
// Prepare a dictionary for quick lookup by normalized name // Prepare a dictionary for quick lookup by normalized name
var existingPeopleDict = new Dictionary<string, Person>(); var existingPeopleDict = ConstructNameAndAliasDictionary(existingPeople);
foreach (var person in existingPeople)
{
existingPeopleDict.TryAdd(person.NormalizedName, person);
}
// Identify people to remove (those present in ChapterPeople but not in the new list) // Identify people to remove (those present in ChapterPeople but not in the new list)
foreach (var existingChapterPerson in existingChapterPeople var toRemove = existingChapterPeople
.Where(existingChapterPerson => !normalizedPeople.Contains(existingChapterPerson.Person.NormalizedName))) .Where(existingChapterPerson => !normalizedPeople.Contains(existingChapterPerson.Person.NormalizedName));
foreach (var existingChapterPerson in toRemove)
{ {
chapter.People.Remove(existingChapterPerson); chapter.People.Remove(existingChapterPerson);
unitOfWork.PersonRepository.Remove(existingChapterPerson); unitOfWork.PersonRepository.Remove(existingChapterPerson);

View file

@ -207,5 +207,6 @@
"dashboard-stream-only-delete-smart-filter": "Z ovládacího panelu lze odstranit pouze streamy chytrých filtrů", "dashboard-stream-only-delete-smart-filter": "Z ovládacího panelu lze odstranit pouze streamy chytrých filtrů",
"smart-filter-name-required": "Vyžaduje se název chytrého filtru", "smart-filter-name-required": "Vyžaduje se název chytrého filtru",
"smart-filter-system-name": "Nelze použít název streamu poskytovaného systémem", "smart-filter-system-name": "Nelze použít název streamu poskytovaného systémem",
"sidenav-stream-only-delete-smart-filter": "Z postranní navigace lze odstranit pouze streamy chytrých filtrů" "sidenav-stream-only-delete-smart-filter": "Z postranní navigace lze odstranit pouze streamy chytrých filtrů",
"aliases-have-overlap": "Jeden nebo více aliasů se překrývají s jinými osobami, nelze je aktualizovat"
} }

View file

@ -212,6 +212,7 @@
"user-no-access-library-from-series": "User does not have access to the library this series belongs to", "user-no-access-library-from-series": "User does not have access to the library this series belongs to",
"series-restricted-age-restriction": "User is not allowed to view this series due to age restrictions", "series-restricted-age-restriction": "User is not allowed to view this series due to age restrictions",
"kavitaplus-restricted": "This is restricted to Kavita+ only", "kavitaplus-restricted": "This is restricted to Kavita+ only",
"aliases-have-overlap": "One or more of the aliases have overlap with other people, cannot update",
"volume-num": "Volume {0}", "volume-num": "Volume {0}",
"book-num": "Book {0}", "book-num": "Book {0}",

View file

@ -207,5 +207,6 @@
"smart-filter-system-name": "Ní féidir leat ainm srutha an chórais a sholáthair tú a úsáid", "smart-filter-system-name": "Ní féidir leat ainm srutha an chórais a sholáthair tú a úsáid",
"sidenav-stream-only-delete-smart-filter": "Ní féidir ach sruthanna cliste scagaire a scriosadh as an SideNav", "sidenav-stream-only-delete-smart-filter": "Ní féidir ach sruthanna cliste scagaire a scriosadh as an SideNav",
"dashboard-stream-only-delete-smart-filter": "Ní féidir ach sruthanna cliste scagaire a scriosadh ón deais", "dashboard-stream-only-delete-smart-filter": "Ní féidir ach sruthanna cliste scagaire a scriosadh ón deais",
"smart-filter-name-required": "Ainm Scagaire Cliste ag teastáil" "smart-filter-name-required": "Ainm Scagaire Cliste ag teastáil",
"aliases-have-overlap": "Tá forluí idir ceann amháin nó níos mó de na leasainmneacha agus daoine eile, ní féidir iad a nuashonrú"
} }

View file

@ -203,5 +203,9 @@
"person-name-unique": "개인 이름은 고유해야 합니다", "person-name-unique": "개인 이름은 고유해야 합니다",
"person-image-doesnt-exist": "CoversDB에 사람이 존재하지 않습니다", "person-image-doesnt-exist": "CoversDB에 사람이 존재하지 않습니다",
"kavitaplus-restricted": "Kavita+만 해당", "kavitaplus-restricted": "Kavita+만 해당",
"email-taken": "이미 사용중인 이메일" "email-taken": "이미 사용중인 이메일",
"dashboard-stream-only-delete-smart-filter": "대시보드에서 스마트 필터 스트림만 삭제할 수 있습니다",
"sidenav-stream-only-delete-smart-filter": "사이드 메뉴에서 스마트 필터 스트림만 삭제할 수 있습니다",
"smart-filter-name-required": "스마트 필터 이름이 필요합니다",
"smart-filter-system-name": "시스템 제공 스트림 이름은 사용할 수 없습니다"
} }

View file

@ -207,5 +207,6 @@
"smart-filter-name-required": "Nome do Filtro Inteligente obrigatório", "smart-filter-name-required": "Nome do Filtro Inteligente obrigatório",
"dashboard-stream-only-delete-smart-filter": "Somente fluxos de filtros inteligentes podem ser excluídos do painel", "dashboard-stream-only-delete-smart-filter": "Somente fluxos de filtros inteligentes podem ser excluídos do painel",
"smart-filter-system-name": "Você não pode usar o nome de um fluxo fornecido pelo sistema", "smart-filter-system-name": "Você não pode usar o nome de um fluxo fornecido pelo sistema",
"sidenav-stream-only-delete-smart-filter": "Somente fluxos de filtros inteligentes podem ser excluídos do Navegador Lateral" "sidenav-stream-only-delete-smart-filter": "Somente fluxos de filtros inteligentes podem ser excluídos do Navegador Lateral",
"aliases-have-overlap": "Um ou mais dos pseudônimos se sobrepõem a outras pessoas, não pode atualizar"
} }

View file

@ -121,7 +121,7 @@
"opds-disabled": "OPDS не включен на этом сервере", "opds-disabled": "OPDS не включен на этом сервере",
"stats-permission-denied": "Вы не имеете права просматривать статистику другого пользователя", "stats-permission-denied": "Вы не имеете права просматривать статистику другого пользователя",
"reading-list-restricted": "Список чтения не существует или у вас нет доступа", "reading-list-restricted": "Список чтения не существует или у вас нет доступа",
"favicon-doesnt-exist": "Фавикон не существует", "favicon-doesnt-exist": "Favicon не существует",
"external-source-already-in-use": "Существует поток с этим внешним источником", "external-source-already-in-use": "Существует поток с этим внешним источником",
"issue-num": "Вопрос {0}{1}", "issue-num": "Вопрос {0}{1}",
"generic-create-temp-archive": "Возникла проблема с созданием временного архива", "generic-create-temp-archive": "Возникла проблема с созданием временного архива",
@ -194,5 +194,13 @@
"backup": "Резервное копирование", "backup": "Резервное копирование",
"process-processed-scrobbling-events": "Обработка обработанных событий скроблинга", "process-processed-scrobbling-events": "Обработка обработанных событий скроблинга",
"scan-libraries": "Сканирование библиотек", "scan-libraries": "Сканирование библиотек",
"kavita+-data-refresh": "Обновление данных Kavita+" "kavita+-data-refresh": "Обновление данных Kavita+",
"kavitaplus-restricted": "Это доступно только для Kavita+",
"person-doesnt-exist": "Персона не существует",
"generic-cover-volume-save": "Не удается сохранить обложку для раздела",
"generic-cover-person-save": "Не удается сохранить изображение обложки для Персоны",
"person-name-unique": "Имя персоны должно быть уникальным",
"person-image-doesnt-exist": "Персона не существует в CoversDB",
"email-taken": "Почта уже используется",
"person-name-required": "Имя персоны обязательно и не может быть пустым"
} }

View file

@ -207,5 +207,6 @@
"dashboard-stream-only-delete-smart-filter": "只能从仪表板中删除智能筛选器流", "dashboard-stream-only-delete-smart-filter": "只能从仪表板中删除智能筛选器流",
"smart-filter-name-required": "需要智能筛选器名称", "smart-filter-name-required": "需要智能筛选器名称",
"smart-filter-system-name": "您不能使用系统提供的流名称", "smart-filter-system-name": "您不能使用系统提供的流名称",
"sidenav-stream-only-delete-smart-filter": "只能从侧边栏删除智能筛选器流" "sidenav-stream-only-delete-smart-filter": "只能从侧边栏删除智能筛选器流",
"aliases-have-overlap": "一个或多个别名与其他人有重叠,无法更新"
} }

View file

@ -1,5 +1,6 @@
using System; using System;
using System.IO; using System.IO;
using System.Linq;
using System.Net; using System.Net;
using System.Text.Json; using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -26,7 +27,7 @@ public class SecurityEventMiddleware(RequestDelegate next)
} }
catch (KavitaUnauthenticatedUserException ex) catch (KavitaUnauthenticatedUserException ex)
{ {
var ipAddress = context.Connection.RemoteIpAddress?.ToString(); var ipAddress = context.Request.Headers["X-Forwarded-For"].FirstOrDefault() ?? context.Connection.RemoteIpAddress?.ToString();
var requestMethod = context.Request.Method; var requestMethod = context.Request.Method;
var requestPath = context.Request.Path; var requestPath = context.Request.Path;
var userAgent = context.Request.Headers.UserAgent; var userAgent = context.Request.Headers.UserAgent;

View file

@ -1,4 +1,5 @@
using System; using System;
using System.IO;
using System.IO.Abstractions; using System.IO.Abstractions;
using System.Linq; using System.Linq;
using System.Security.Cryptography; using System.Security.Cryptography;
@ -48,15 +49,13 @@ public class Program
var directoryService = new DirectoryService(null!, new FileSystem()); var directoryService = new DirectoryService(null!, new FileSystem());
// Check if this is the first time running and if so, rename appsettings-init.json to appsettings.json
HandleFirstRunConfiguration();
// Before anything, check if JWT has been generated properly or if user still has default // Before anything, check if JWT has been generated properly or if user still has default
if (!Configuration.CheckIfJwtTokenSet() && EnsureJwtTokenKey();
Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") != Environments.Development)
{
Log.Logger.Information("Generating JWT TokenKey for encrypting user sessions...");
var rBytes = new byte[256];
RandomNumberGenerator.Create().GetBytes(rBytes);
Configuration.JwtToken = Convert.ToBase64String(rBytes).Replace("/", string.Empty);
}
try try
{ {
@ -70,6 +69,7 @@ public class Program
{ {
var logger = services.GetRequiredService<ILogger<Program>>(); var logger = services.GetRequiredService<ILogger<Program>>();
var context = services.GetRequiredService<DataContext>(); var context = services.GetRequiredService<DataContext>();
var pendingMigrations = await context.Database.GetPendingMigrationsAsync(); var pendingMigrations = await context.Database.GetPendingMigrationsAsync();
var isDbCreated = await context.Database.CanConnectAsync(); var isDbCreated = await context.Database.CanConnectAsync();
if (isDbCreated && pendingMigrations.Any()) if (isDbCreated && pendingMigrations.Any())
@ -157,6 +157,26 @@ public class Program
} }
} }
private static void EnsureJwtTokenKey()
{
if (Configuration.CheckIfJwtTokenSet() || Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development) return;
Log.Logger.Information("Generating JWT TokenKey for encrypting user sessions...");
var rBytes = new byte[256];
RandomNumberGenerator.Create().GetBytes(rBytes);
Configuration.JwtToken = Convert.ToBase64String(rBytes).Replace("/", string.Empty);
}
private static void HandleFirstRunConfiguration()
{
var firstRunConfigFilePath = Path.Join(Directory.GetCurrentDirectory(), "config/appsettings-init.json");
if (File.Exists(firstRunConfigFilePath) &&
!File.Exists(Path.Join(Directory.GetCurrentDirectory(), "config/appsettings.json")))
{
File.Move(firstRunConfigFilePath, Path.Join(Directory.GetCurrentDirectory(), "config/appsettings.json"));
}
}
private static async Task<string> GetMigrationDirectory(DataContext context, IDirectoryService directoryService) private static async Task<string> GetMigrationDirectory(DataContext context, IDirectoryService directoryService)
{ {
string? currentVersion = null; string? currentVersion = null;

View file

@ -0,0 +1,147 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.Entities.Person;
using API.Extensions;
using API.Helpers.Builders;
namespace API.Services;
public interface IPersonService
{
/// <summary>
/// Adds src as an alias to dst, this is a destructive operation
/// </summary>
/// <param name="src">Merged person</param>
/// <param name="dst">Remaining person</param>
/// <remarks>The entities passed as arguments **must** include all relations</remarks>
/// <returns></returns>
Task MergePeopleAsync(Person src, Person dst);
/// <summary>
/// Adds the alias to the person, requires that the aliases are not shared with anyone else
/// </summary>
/// <remarks>This method does NOT commit changes</remarks>
/// <param name="person"></param>
/// <param name="aliases"></param>
/// <returns></returns>
Task<bool> UpdatePersonAliasesAsync(Person person, IList<string> aliases);
}
public class PersonService(IUnitOfWork unitOfWork): IPersonService
{
public async Task MergePeopleAsync(Person src, Person dst)
{
if (dst.Id == src.Id) return;
if (string.IsNullOrWhiteSpace(dst.Description) && !string.IsNullOrWhiteSpace(src.Description))
{
dst.Description = src.Description;
}
if (dst.MalId == 0 && src.MalId != 0)
{
dst.MalId = src.MalId;
}
if (dst.AniListId == 0 && src.AniListId != 0)
{
dst.AniListId = src.AniListId;
}
if (dst.HardcoverId == null && src.HardcoverId != null)
{
dst.HardcoverId = src.HardcoverId;
}
if (dst.Asin == null && src.Asin != null)
{
dst.Asin = src.Asin;
}
if (dst.CoverImage == null && src.CoverImage != null)
{
dst.CoverImage = src.CoverImage;
}
MergeChapterPeople(dst, src);
MergeSeriesMetadataPeople(dst, src);
dst.Aliases.Add(new PersonAliasBuilder(src.Name).Build());
foreach (var alias in src.Aliases)
{
dst.Aliases.Add(alias);
}
unitOfWork.PersonRepository.Remove(src);
unitOfWork.PersonRepository.Update(dst);
await unitOfWork.CommitAsync();
}
private static void MergeChapterPeople(Person dst, Person src)
{
foreach (var chapter in src.ChapterPeople)
{
var alreadyPresent = dst.ChapterPeople
.Any(x => x.ChapterId == chapter.ChapterId && x.Role == chapter.Role);
if (alreadyPresent) continue;
dst.ChapterPeople.Add(new ChapterPeople
{
Role = chapter.Role,
ChapterId = chapter.ChapterId,
Person = dst,
KavitaPlusConnection = chapter.KavitaPlusConnection,
OrderWeight = chapter.OrderWeight,
});
}
}
private static void MergeSeriesMetadataPeople(Person dst, Person src)
{
foreach (var series in src.SeriesMetadataPeople)
{
var alreadyPresent = dst.SeriesMetadataPeople
.Any(x => x.SeriesMetadataId == series.SeriesMetadataId && x.Role == series.Role);
if (alreadyPresent) continue;
dst.SeriesMetadataPeople.Add(new SeriesMetadataPeople
{
SeriesMetadataId = series.SeriesMetadataId,
Role = series.Role,
Person = dst,
KavitaPlusConnection = series.KavitaPlusConnection,
OrderWeight = series.OrderWeight,
});
}
}
public async Task<bool> UpdatePersonAliasesAsync(Person person, IList<string> aliases)
{
var normalizedAliases = aliases
.Select(a => a.ToNormalized())
.Where(a => !string.IsNullOrEmpty(a) && a != person.NormalizedName)
.ToList();
if (normalizedAliases.Count == 0)
{
person.Aliases = [];
return true;
}
var others = await unitOfWork.PersonRepository.GetPeopleByNames(normalizedAliases);
others = others.Where(p => p.Id != person.Id).ToList();
if (others.Count != 0) return false;
person.Aliases = aliases.Select(a => new PersonAliasBuilder(a).Build()).ToList();
return true;
}
}

View file

@ -10,6 +10,7 @@ using API.DTOs.Collection;
using API.DTOs.KavitaPlus.ExternalMetadata; using API.DTOs.KavitaPlus.ExternalMetadata;
using API.DTOs.KavitaPlus.Metadata; using API.DTOs.KavitaPlus.Metadata;
using API.DTOs.Metadata.Matching; using API.DTOs.Metadata.Matching;
using API.DTOs.Person;
using API.DTOs.Recommendation; using API.DTOs.Recommendation;
using API.DTOs.Scrobbling; using API.DTOs.Scrobbling;
using API.DTOs.SeriesDetail; using API.DTOs.SeriesDetail;
@ -17,8 +18,10 @@ using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
using API.Entities.Metadata; using API.Entities.Metadata;
using API.Entities.MetadataMatching; using API.Entities.MetadataMatching;
using API.Entities.Person;
using API.Extensions; using API.Extensions;
using API.Helpers; using API.Helpers;
using API.Helpers.Builders;
using API.Services.Tasks.Metadata; using API.Services.Tasks.Metadata;
using API.Services.Tasks.Scanner.Parser; using API.Services.Tasks.Scanner.Parser;
using API.SignalR; using API.SignalR;
@ -223,7 +226,7 @@ public class ExternalMetadataService : IExternalMetadataService
AlternativeNames = altNames.Where(s => !string.IsNullOrEmpty(s)).ToList(), AlternativeNames = altNames.Where(s => !string.IsNullOrEmpty(s)).ToList(),
Year = series.Metadata.ReleaseYear, Year = series.Metadata.ReleaseYear,
AniListId = potentialAnilistId ?? ScrobblingService.GetAniListId(series), AniListId = potentialAnilistId ?? ScrobblingService.GetAniListId(series),
MalId = potentialMalId ?? ScrobblingService.GetMalId(series), MalId = potentialMalId ?? ScrobblingService.GetMalId(series)
}; };
var token = (await _unitOfWork.UserRepository.GetDefaultAdminUser()).AniListAccessToken; var token = (await _unitOfWork.UserRepository.GetDefaultAdminUser()).AniListAccessToken;
@ -614,12 +617,8 @@ public class ExternalMetadataService : IExternalMetadataService
madeModification = await UpdateTags(series, settings, externalMetadata, processedTags) || madeModification; madeModification = await UpdateTags(series, settings, externalMetadata, processedTags) || madeModification;
madeModification = UpdateAgeRating(series, settings, processedGenres.Concat(processedTags)) || madeModification; madeModification = UpdateAgeRating(series, settings, processedGenres.Concat(processedTags)) || madeModification;
var staff = (externalMetadata.Staff ?? []).Select(s => var staff = await SetNameAndAddAliases(settings, externalMetadata.Staff);
{
s.Name = settings.FirstLastPeopleNaming ? $"{s.FirstName} {s.LastName}" : $"{s.LastName} {s.FirstName}";
return s;
}).ToList();
madeModification = await UpdateWriters(series, settings, staff) || madeModification; madeModification = await UpdateWriters(series, settings, staff) || madeModification;
madeModification = await UpdateArtists(series, settings, staff) || madeModification; madeModification = await UpdateArtists(series, settings, staff) || madeModification;
madeModification = await UpdateCharacters(series, settings, externalMetadata.Characters) || madeModification; madeModification = await UpdateCharacters(series, settings, externalMetadata.Characters) || madeModification;
@ -632,6 +631,49 @@ public class ExternalMetadataService : IExternalMetadataService
return madeModification; return madeModification;
} }
private async Task<List<SeriesStaffDto>> SetNameAndAddAliases(MetadataSettingsDto settings, IList<SeriesStaffDto>? staff)
{
if (staff == null || staff.Count == 0) return [];
var nameMappings = staff.Select(s => new
{
Staff = s,
PreferredName = settings.FirstLastPeopleNaming ? $"{s.FirstName} {s.LastName}" : $"{s.LastName} {s.FirstName}",
AlternativeName = !settings.FirstLastPeopleNaming ? $"{s.FirstName} {s.LastName}" : $"{s.LastName} {s.FirstName}"
}).ToList();
var preferredNames = nameMappings.Select(n => n.PreferredName.ToNormalized()).Distinct().ToList();
var alternativeNames = nameMappings.Select(n => n.AlternativeName.ToNormalized()).Distinct().ToList();
var existingPeople = await _unitOfWork.PersonRepository.GetPeopleByNames(preferredNames.Union(alternativeNames).ToList());
var existingPeopleDictionary = PersonHelper.ConstructNameAndAliasDictionary(existingPeople);
var modified = false;
foreach (var mapping in nameMappings)
{
mapping.Staff.Name = mapping.PreferredName;
if (existingPeopleDictionary.ContainsKey(mapping.PreferredName.ToNormalized()))
{
continue;
}
if (existingPeopleDictionary.TryGetValue(mapping.AlternativeName.ToNormalized(), out var person))
{
modified = true;
person.Aliases.Add(new PersonAliasBuilder(mapping.PreferredName).Build());
}
}
if (modified)
{
await _unitOfWork.CommitAsync();
}
return [.. staff];
}
private static void GenerateGenreAndTagLists(ExternalSeriesDetailDto externalMetadata, MetadataSettingsDto settings, private static void GenerateGenreAndTagLists(ExternalSeriesDetailDto externalMetadata, MetadataSettingsDto settings,
ref List<string> processedTags, ref List<string> processedGenres) ref List<string> processedTags, ref List<string> processedGenres)
{ {
@ -750,7 +792,7 @@ public class ExternalMetadataService : IExternalMetadataService
var characters = externalCharacters var characters = externalCharacters
.Select(w => new PersonDto() .Select(w => new PersonDto()
{ {
Name = w.Name, Name = w.Name.Trim(),
AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListCharacterWebsite), AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListCharacterWebsite),
Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))), Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))),
}) })
@ -831,7 +873,7 @@ public class ExternalMetadataService : IExternalMetadataService
var artists = upstreamArtists var artists = upstreamArtists
.Select(w => new PersonDto() .Select(w => new PersonDto()
{ {
Name = w.Name, Name = w.Name.Trim(),
AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListStaffWebsite), AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListStaffWebsite),
Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))), Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))),
}) })
@ -887,7 +929,7 @@ public class ExternalMetadataService : IExternalMetadataService
var writers = upstreamWriters var writers = upstreamWriters
.Select(w => new PersonDto() .Select(w => new PersonDto()
{ {
Name = w.Name, Name = w.Name.Trim(),
AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListStaffWebsite), AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListStaffWebsite),
Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))), Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))),
}) })
@ -1311,7 +1353,7 @@ public class ExternalMetadataService : IExternalMetadataService
var people = staff! var people = staff!
.Select(w => new PersonDto() .Select(w => new PersonDto()
{ {
Name = w, Name = w.Trim(),
}) })
.Concat(chapter.People .Concat(chapter.People
.Where(p => p.Role == role) .Where(p => p.Role == role)

View file

@ -7,6 +7,7 @@ using API.Comparators;
using API.Data; using API.Data;
using API.Data.Repositories; using API.Data.Repositories;
using API.DTOs; using API.DTOs;
using API.DTOs.Person;
using API.DTOs.SeriesDetail; using API.DTOs.SeriesDetail;
using API.Entities; using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
@ -361,8 +362,7 @@ public class SeriesService : ISeriesService
var existingPeople = await unitOfWork.PersonRepository.GetPeopleByNames(normalizedNames); var existingPeople = await unitOfWork.PersonRepository.GetPeopleByNames(normalizedNames);
// Use a dictionary for quick lookups // Use a dictionary for quick lookups
var existingPeopleDictionary = existingPeople.DistinctBy(p => p.NormalizedName) var existingPeopleDictionary = PersonHelper.ConstructNameAndAliasDictionary(existingPeople);
.ToDictionary(p => p.NormalizedName, p => p);
// List to track people that will be added to the metadata // List to track people that will be added to the metadata
var peopleToAdd = new List<Person>(); var peopleToAdd = new List<Person>();

View file

@ -501,7 +501,7 @@ public class CoverDbService : ICoverDbService
else else
{ {
_directoryService.DeleteFiles([tempFullPath]); _directoryService.DeleteFiles([tempFullPath]);
person.CoverImage = Path.GetFileName(existingPath); return;
} }
} }
else else
@ -572,14 +572,20 @@ public class CoverDbService : ICoverDbService
var choseNewImage = string.Equals(betterImage, tempFullPath, StringComparison.OrdinalIgnoreCase); var choseNewImage = string.Equals(betterImage, tempFullPath, StringComparison.OrdinalIgnoreCase);
if (choseNewImage) if (choseNewImage)
{ {
_directoryService.DeleteFiles([existingPath]);
// Don't delete series cover, unless it's an override, otherwise the first chapter cover will be null
if (existingPath.Contains(ImageService.GetSeriesFormat(series.Id)))
{
_directoryService.DeleteFiles([existingPath]);
}
_directoryService.CopyFile(tempFullPath, finalFullPath); _directoryService.CopyFile(tempFullPath, finalFullPath);
series.CoverImage = finalFileName; series.CoverImage = finalFileName;
} }
else else
{ {
_directoryService.DeleteFiles([tempFullPath]); _directoryService.DeleteFiles([tempFullPath]);
series.CoverImage = Path.GetFileName(existingPath); return;
} }
} }
catch (Exception ex) catch (Exception ex)
@ -651,6 +657,7 @@ public class CoverDbService : ICoverDbService
else else
{ {
_directoryService.DeleteFiles([tempFullPath]); _directoryService.DeleteFiles([tempFullPath]);
return;
} }
chapter.CoverImage = finalFileName; chapter.CoverImage = finalFileName;

View file

@ -310,7 +310,7 @@ public class LibraryWatcher : ILibraryWatcher
if (rootFolder.Count == 0) return string.Empty; if (rootFolder.Count == 0) return string.Empty;
// Select the first folder and join with library folder, this should give us the folder to scan. // Select the first folder and join with library folder, this should give us the folder to scan.
return Parser.Parser.NormalizePath(_directoryService.FileSystem.Path.Join(libraryFolder, rootFolder[rootFolder.Count - 1])); return Parser.Parser.NormalizePath(_directoryService.FileSystem.Path.Join(libraryFolder, rootFolder[^1]));
} }

View file

@ -52,6 +52,7 @@ public interface IVersionUpdaterService
Task PushUpdate(UpdateNotificationDto update); Task PushUpdate(UpdateNotificationDto update);
Task<IList<UpdateNotificationDto>> GetAllReleases(int count = 0); Task<IList<UpdateNotificationDto>> GetAllReleases(int count = 0);
Task<int> GetNumberOfReleasesBehind(bool stableOnly = false); Task<int> GetNumberOfReleasesBehind(bool stableOnly = false);
void BustGithubCache();
} }
@ -384,7 +385,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService
if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc <= CacheDuration) if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc <= CacheDuration)
{ {
var cachedData = await File.ReadAllTextAsync(_cacheLatestReleaseFilePath); var cachedData = await File.ReadAllTextAsync(_cacheLatestReleaseFilePath);
return System.Text.Json.JsonSerializer.Deserialize<UpdateNotificationDto>(cachedData); return JsonSerializer.Deserialize<UpdateNotificationDto>(cachedData);
} }
return null; return null;
@ -407,7 +408,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService
{ {
try try
{ {
var json = System.Text.Json.JsonSerializer.Serialize(update, JsonOptions); var json = JsonSerializer.Serialize(update, JsonOptions);
await File.WriteAllTextAsync(_cacheLatestReleaseFilePath, json); await File.WriteAllTextAsync(_cacheLatestReleaseFilePath, json);
} }
catch (Exception ex) catch (Exception ex)
@ -446,6 +447,21 @@ public partial class VersionUpdaterService : IVersionUpdaterService
.Count(u => u.IsReleaseNewer); .Count(u => u.IsReleaseNewer);
} }
/// <summary>
/// Clears the Github cache
/// </summary>
public void BustGithubCache()
{
try
{
File.Delete(_cacheFilePath);
File.Delete(_cacheLatestReleaseFilePath);
} catch (Exception ex)
{
_logger.LogError(ex, "Failed to clear Github cache");
}
}
private UpdateNotificationDto? CreateDto(GithubReleaseMetadata? update) private UpdateNotificationDto? CreateDto(GithubReleaseMetadata? update)
{ {
if (update == null || string.IsNullOrEmpty(update.Tag_Name)) return null; if (update == null || string.IsNullOrEmpty(update.Tag_Name)) return null;

View file

@ -1,5 +1,6 @@
using System; using System;
using API.DTOs.Update; using API.DTOs.Update;
using API.Entities.Person;
using API.Extensions; using API.Extensions;
using API.Services.Plus; using API.Services.Plus;
@ -147,6 +148,10 @@ public static class MessageFactory
/// Volume is removed from server /// Volume is removed from server
/// </summary> /// </summary>
public const string VolumeRemoved = "VolumeRemoved"; public const string VolumeRemoved = "VolumeRemoved";
/// <summary>
/// A Person merged has been merged into another
/// </summary>
public const string PersonMerged = "PersonMerged";
public static SignalRMessage DashboardUpdateEvent(int userId) public static SignalRMessage DashboardUpdateEvent(int userId)
{ {
@ -661,4 +666,17 @@ public static class MessageFactory
EventType = ProgressEventType.Single, EventType = ProgressEventType.Single,
}; };
} }
public static SignalRMessage PersonMergedMessage(Person dst, Person src)
{
return new SignalRMessage()
{
Name = PersonMerged,
Body = new
{
srcId = src.Id,
dstName = dst.Name,
},
};
}
} }

View file

@ -55,6 +55,9 @@ public class Startup
{ {
_config = config; _config = config;
_env = env; _env = env;
// Disable Hangfire Automatic Retry
GlobalJobFilters.Filters.Add(new AutomaticRetryAttribute { Attempts = 0 });
} }
// This method gets called by the runtime. Use this method to add services to the container. // This method gets called by the runtime. Use this method to add services to the container.
@ -223,7 +226,7 @@ public class Startup
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline. // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IBackgroundJobClient backgroundJobs, IWebHostEnvironment env, public void Configure(IApplicationBuilder app, IBackgroundJobClient backgroundJobs, IWebHostEnvironment env,
IHostApplicationLifetime applicationLifetime, IServiceProvider serviceProvider, ICacheService cacheService, IHostApplicationLifetime applicationLifetime, IServiceProvider serviceProvider, ICacheService cacheService,
IDirectoryService directoryService, IUnitOfWork unitOfWork, IBackupService backupService, IImageService imageService) IDirectoryService directoryService, IUnitOfWork unitOfWork, IBackupService backupService, IImageService imageService, IVersionUpdaterService versionService)
{ {
var logger = serviceProvider.GetRequiredService<ILogger<Program>>(); var logger = serviceProvider.GetRequiredService<ILogger<Program>>();
@ -235,9 +238,10 @@ public class Startup
// Apply all migrations on startup // Apply all migrations on startup
var dataContext = serviceProvider.GetRequiredService<DataContext>(); var dataContext = serviceProvider.GetRequiredService<DataContext>();
logger.LogInformation("Running Migrations"); logger.LogInformation("Running Migrations");
#region Migrations
// v0.7.9 // v0.7.9
await MigrateUserLibrarySideNavStream.Migrate(unitOfWork, dataContext, logger); await MigrateUserLibrarySideNavStream.Migrate(unitOfWork, dataContext, logger);
@ -289,13 +293,23 @@ public class Startup
await ManualMigrateScrobbleSpecials.Migrate(dataContext, logger); await ManualMigrateScrobbleSpecials.Migrate(dataContext, logger);
await ManualMigrateScrobbleEventGen.Migrate(dataContext, logger); await ManualMigrateScrobbleEventGen.Migrate(dataContext, logger);
#endregion
// Update the version in the DB after all migrations are run // Update the version in the DB after all migrations are run
var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion); var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion);
var isVersionDifferent = installVersion.Value != BuildInfo.Version.ToString();
installVersion.Value = BuildInfo.Version.ToString(); installVersion.Value = BuildInfo.Version.ToString();
unitOfWork.SettingsRepository.Update(installVersion); unitOfWork.SettingsRepository.Update(installVersion);
await unitOfWork.CommitAsync(); await unitOfWork.CommitAsync();
logger.LogInformation("Running Migrations - complete"); logger.LogInformation("Running Migrations - complete");
if (isVersionDifferent)
{
// Clear the Github cache so update stuff shows correctly
versionService.BustGithubCache();
}
}).GetAwaiter() }).GetAwaiter()
.GetResult(); .GetResult();
} }

View file

@ -3,7 +3,7 @@
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<Company>kavitareader.com</Company> <Company>kavitareader.com</Company>
<Product>Kavita</Product> <Product>Kavita</Product>
<AssemblyVersion>0.8.6.8</AssemblyVersion> <AssemblyVersion>0.8.6.11</AssemblyVersion>
<NeutralLanguage>en</NeutralLanguage> <NeutralLanguage>en</NeutralLanguage>
<TieredPGO>true</TieredPGO> <TieredPGO>true</TieredPGO>
</PropertyGroup> </PropertyGroup>
@ -14,7 +14,7 @@
<PackageReference Include="Flurl.Http" Version="4.0.2" /> <PackageReference Include="Flurl.Http" Version="4.0.2" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.4" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.4" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.4" /> <PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.4" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.8.0.113526"> <PackageReference Include="SonarAnalyzer.CSharp" Version="10.9.0.115408">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>

View file

@ -26,7 +26,7 @@
font-size: 1.75rem; font-size: 1.75rem;
} }
.subtitle { .subtitle {
color: lightgrey; color: var(--detail-subtitle-color);;
font-weight: bold; font-weight: bold;
font-size: 0.8rem; font-size: 0.8rem;
} }

View file

@ -6,6 +6,9 @@ export enum LibraryType {
Book = 2, Book = 2,
Images = 3, Images = 3,
LightNovel = 4, LightNovel = 4,
/**
* Comic (Legacy)
*/
ComicVine = 5 ComicVine = 5
} }

View file

@ -22,6 +22,7 @@ export interface Person extends IHasCover {
id: number; id: number;
name: string; name: string;
description: string; description: string;
aliases: Array<string>;
coverImage?: string; coverImage?: string;
coverImageLocked: boolean; coverImageLocked: boolean;
malId?: number; malId?: number;

View file

@ -7,12 +7,13 @@ import {Library} from '../_models/library/library';
import {ReadingList} from '../_models/reading-list'; import {ReadingList} from '../_models/reading-list';
import {Series} from '../_models/series'; import {Series} from '../_models/series';
import {Volume} from '../_models/volume'; import {Volume} from '../_models/volume';
import {AccountService} from './account.service'; import {AccountService, Role} from './account.service';
import {DeviceService} from './device.service'; import {DeviceService} from './device.service';
import {SideNavStream} from "../_models/sidenav/sidenav-stream"; import {SideNavStream} from "../_models/sidenav/sidenav-stream";
import {SmartFilter} from "../_models/metadata/v2/smart-filter"; import {SmartFilter} from "../_models/metadata/v2/smart-filter";
import {translate} from "@jsverse/transloco"; import {translate} from "@jsverse/transloco";
import {Person} from "../_models/metadata/person"; import {Person} from "../_models/metadata/person";
import {User} from '../_models/user';
export enum Action { export enum Action {
Submenu = -1, Submenu = -1,
@ -106,7 +107,7 @@ export enum Action {
Promote = 24, Promote = 24,
UnPromote = 25, UnPromote = 25,
/** /**
* Invoke a refresh covers as false to generate colorscapes * Invoke refresh covers as false to generate colorscapes
*/ */
GenerateColorScape = 26, GenerateColorScape = 26,
/** /**
@ -116,20 +117,31 @@ export enum Action {
/** /**
* Match an entity with an upstream system * Match an entity with an upstream system
*/ */
Match = 28 Match = 28,
/**
* Merge two (or more?) entities
*/
Merge = 29,
} }
/** /**
* Callback for an action * Callback for an action
*/ */
export type ActionCallback<T> = (action: ActionItem<T>, data: T) => void; export type ActionCallback<T> = (action: ActionItem<T>, entity: T) => void;
export type ActionAllowedCallback<T> = (action: ActionItem<T>) => boolean; export type ActionShouldRenderFunc<T> = (action: ActionItem<T>, entity: T, user: User) => boolean;
export interface ActionItem<T> { export interface ActionItem<T> {
title: string; title: string;
description: string; description: string;
action: Action; action: Action;
callback: ActionCallback<T>; callback: ActionCallback<T>;
/**
* Roles required to be present for ActionItem to show. If empty, assumes anyone can see. At least one needs to apply.
*/
requiredRoles: Role[];
/**
* @deprecated Use required Roles instead
*/
requiresAdmin: boolean; requiresAdmin: boolean;
children: Array<ActionItem<T>>; children: Array<ActionItem<T>>;
/** /**
@ -145,94 +157,98 @@ export interface ActionItem<T> {
* Extra data that needs to be sent back from the card item. Used mainly for dynamicList. This will be the item from dyanamicList return * Extra data that needs to be sent back from the card item. Used mainly for dynamicList. This will be the item from dyanamicList return
*/ */
_extra?: {title: string, data: any}; _extra?: {title: string, data: any};
/**
* Will call on each action to determine if it should show for the appropriate entity based on state and user
*/
shouldRender: ActionShouldRenderFunc<T>;
} }
/**
* Entities that can be actioned upon
*/
export type ActionableEntity = Volume | Series | Chapter | ReadingList | UserCollection | Person | Library | SideNavStream | SmartFilter | null;
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class ActionFactoryService { export class ActionFactoryService {
libraryActions: Array<ActionItem<Library>> = []; private libraryActions: Array<ActionItem<Library>> = [];
private seriesActions: Array<ActionItem<Series>> = [];
seriesActions: Array<ActionItem<Series>> = []; private volumeActions: Array<ActionItem<Volume>> = [];
private chapterActions: Array<ActionItem<Chapter>> = [];
volumeActions: Array<ActionItem<Volume>> = []; private collectionTagActions: Array<ActionItem<UserCollection>> = [];
private readingListActions: Array<ActionItem<ReadingList>> = [];
chapterActions: Array<ActionItem<Chapter>> = []; private bookmarkActions: Array<ActionItem<Series>> = [];
collectionTagActions: Array<ActionItem<UserCollection>> = [];
readingListActions: Array<ActionItem<ReadingList>> = [];
bookmarkActions: Array<ActionItem<Series>> = [];
private personActions: Array<ActionItem<Person>> = []; private personActions: Array<ActionItem<Person>> = [];
private sideNavStreamActions: Array<ActionItem<SideNavStream>> = [];
sideNavStreamActions: Array<ActionItem<SideNavStream>> = []; private smartFilterActions: Array<ActionItem<SmartFilter>> = [];
smartFilterActions: Array<ActionItem<SmartFilter>> = []; private sideNavHomeActions: Array<ActionItem<void>> = [];
sideNavHomeActions: Array<ActionItem<void>> = [];
isAdmin = false;
constructor(private accountService: AccountService, private deviceService: DeviceService) { constructor(private accountService: AccountService, private deviceService: DeviceService) {
this.accountService.currentUser$.subscribe((user) => { this.accountService.currentUser$.subscribe((_) => {
if (user) {
this.isAdmin = this.accountService.hasAdminRole(user);
} else {
this._resetActions();
return; // If user is logged out, we don't need to do anything
}
this._resetActions(); this._resetActions();
}); });
} }
getLibraryActions(callback: ActionCallback<Library>) { getLibraryActions(callback: ActionCallback<Library>, shouldRenderFunc: ActionShouldRenderFunc<Library> = this.dummyShouldRender) {
return this.applyCallbackToList(this.libraryActions, callback); return this.applyCallbackToList(this.libraryActions, callback, shouldRenderFunc) as ActionItem<Library>[];
} }
getSeriesActions(callback: ActionCallback<Series>) { getSeriesActions(callback: ActionCallback<Series>, shouldRenderFunc: ActionShouldRenderFunc<Series> = this.basicReadRender) {
return this.applyCallbackToList(this.seriesActions, callback); return this.applyCallbackToList(this.seriesActions, callback, shouldRenderFunc);
} }
getSideNavStreamActions(callback: ActionCallback<SideNavStream>) { getSideNavStreamActions(callback: ActionCallback<SideNavStream>, shouldRenderFunc: ActionShouldRenderFunc<SideNavStream> = this.dummyShouldRender) {
return this.applyCallbackToList(this.sideNavStreamActions, callback); return this.applyCallbackToList(this.sideNavStreamActions, callback, shouldRenderFunc);
} }
getSmartFilterActions(callback: ActionCallback<SmartFilter>) { getSmartFilterActions(callback: ActionCallback<SmartFilter>, shouldRenderFunc: ActionShouldRenderFunc<SmartFilter> = this.dummyShouldRender) {
return this.applyCallbackToList(this.smartFilterActions, callback); return this.applyCallbackToList(this.smartFilterActions, callback, shouldRenderFunc);
} }
getVolumeActions(callback: ActionCallback<Volume>) { getVolumeActions(callback: ActionCallback<Volume>, shouldRenderFunc: ActionShouldRenderFunc<Volume> = this.basicReadRender) {
return this.applyCallbackToList(this.volumeActions, callback); return this.applyCallbackToList(this.volumeActions, callback, shouldRenderFunc);
} }
getChapterActions(callback: ActionCallback<Chapter>) { getChapterActions(callback: ActionCallback<Chapter>, shouldRenderFunc: ActionShouldRenderFunc<Chapter> = this.basicReadRender) {
return this.applyCallbackToList(this.chapterActions, callback); return this.applyCallbackToList(this.chapterActions, callback, shouldRenderFunc);
} }
getCollectionTagActions(callback: ActionCallback<UserCollection>) { getCollectionTagActions(callback: ActionCallback<UserCollection>, shouldRenderFunc: ActionShouldRenderFunc<UserCollection> = this.dummyShouldRender) {
return this.applyCallbackToList(this.collectionTagActions, callback); return this.applyCallbackToList(this.collectionTagActions, callback, shouldRenderFunc);
} }
getReadingListActions(callback: ActionCallback<ReadingList>) { getReadingListActions(callback: ActionCallback<ReadingList>, shouldRenderFunc: ActionShouldRenderFunc<ReadingList> = this.dummyShouldRender) {
return this.applyCallbackToList(this.readingListActions, callback); return this.applyCallbackToList(this.readingListActions, callback, shouldRenderFunc);
} }
getBookmarkActions(callback: ActionCallback<Series>) { getBookmarkActions(callback: ActionCallback<Series>, shouldRenderFunc: ActionShouldRenderFunc<Series> = this.dummyShouldRender) {
return this.applyCallbackToList(this.bookmarkActions, callback); return this.applyCallbackToList(this.bookmarkActions, callback, shouldRenderFunc);
} }
getPersonActions(callback: ActionCallback<Person>) { getPersonActions(callback: ActionCallback<Person>, shouldRenderFunc: ActionShouldRenderFunc<Person> = this.dummyShouldRender) {
return this.applyCallbackToList(this.personActions, callback); return this.applyCallbackToList(this.personActions, callback, shouldRenderFunc);
} }
getSideNavHomeActions(callback: ActionCallback<void>) { getSideNavHomeActions(callback: ActionCallback<void>, shouldRenderFunc: ActionShouldRenderFunc<void> = this.dummyShouldRender) {
return this.applyCallbackToList(this.sideNavHomeActions, callback); return this.applyCallbackToList(this.sideNavHomeActions, callback, shouldRenderFunc);
} }
dummyCallback(action: ActionItem<any>, data: any) {} dummyCallback(action: ActionItem<any>, entity: any) {}
dummyShouldRender(action: ActionItem<any>, entity: any, user: User) {return true;}
basicReadRender(action: ActionItem<any>, entity: any, user: User) {
if (entity === null || entity === undefined) return true;
if (!entity.hasOwnProperty('pagesRead') && !entity.hasOwnProperty('pages')) return true;
switch (action.action) {
case(Action.MarkAsRead):
return entity.pagesRead < entity.pages;
case(Action.MarkAsUnread):
return entity.pagesRead !== 0;
default:
return true;
}
}
filterSendToAction(actions: Array<ActionItem<Chapter>>, chapter: Chapter) { filterSendToAction(actions: Array<ActionItem<Chapter>>, chapter: Chapter) {
// if (chapter.files.filter(f => f.format === MangaFormat.EPUB || f.format === MangaFormat.PDF).length !== chapter.files.length) { // if (chapter.files.filter(f => f.format === MangaFormat.EPUB || f.format === MangaFormat.PDF).length !== chapter.files.length) {
@ -275,7 +291,7 @@ export class ActionFactoryService {
return tasks.filter(t => !blacklist.includes(t.action)); return tasks.filter(t => !blacklist.includes(t.action));
} }
getBulkLibraryActions(callback: ActionCallback<Library>) { getBulkLibraryActions(callback: ActionCallback<Library>, shouldRenderFunc: ActionShouldRenderFunc<Library> = this.dummyShouldRender) {
// Scan is currently not supported due to the backend not being able to handle it yet // Scan is currently not supported due to the backend not being able to handle it yet
const actions = this.flattenActions<Library>(this.libraryActions).filter(a => { const actions = this.flattenActions<Library>(this.libraryActions).filter(a => {
@ -289,11 +305,13 @@ export class ActionFactoryService {
dynamicList: undefined, dynamicList: undefined,
action: Action.CopySettings, action: Action.CopySettings,
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: shouldRenderFunc,
children: [], children: [],
requiredRoles: [Role.Admin],
requiresAdmin: true, requiresAdmin: true,
title: 'copy-settings' title: 'copy-settings'
}) })
return this.applyCallbackToList(actions, callback); return this.applyCallbackToList(actions, callback, shouldRenderFunc) as ActionItem<Library>[];
} }
flattenActions<T>(actions: Array<ActionItem<T>>): Array<ActionItem<T>> { flattenActions<T>(actions: Array<ActionItem<T>>): Array<ActionItem<T>> {
@ -319,7 +337,9 @@ export class ActionFactoryService {
title: 'scan-library', title: 'scan-library',
description: 'scan-library-tooltip', description: 'scan-library-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
{ {
@ -327,14 +347,18 @@ export class ActionFactoryService {
title: 'others', title: 'others',
description: '', description: '',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [ children: [
{ {
action: Action.RefreshMetadata, action: Action.RefreshMetadata,
title: 'refresh-covers', title: 'refresh-covers',
description: 'refresh-covers-tooltip', description: 'refresh-covers-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
{ {
@ -342,7 +366,9 @@ export class ActionFactoryService {
title: 'generate-colorscape', title: 'generate-colorscape',
description: 'generate-colorscape-tooltip', description: 'generate-colorscape-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
{ {
@ -350,7 +376,9 @@ export class ActionFactoryService {
title: 'analyze-files', title: 'analyze-files',
description: 'analyze-files-tooltip', description: 'analyze-files-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
{ {
@ -358,7 +386,9 @@ export class ActionFactoryService {
title: 'delete', title: 'delete',
description: 'delete-tooltip', description: 'delete-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
], ],
@ -368,7 +398,9 @@ export class ActionFactoryService {
title: 'settings', title: 'settings',
description: 'settings-tooltip', description: 'settings-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
]; ];
@ -379,7 +411,9 @@ export class ActionFactoryService {
title: 'edit', title: 'edit',
description: 'edit-tooltip', description: 'edit-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -387,7 +421,9 @@ export class ActionFactoryService {
title: 'delete', title: 'delete',
description: 'delete-tooltip', description: 'delete-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
class: 'danger', class: 'danger',
children: [], children: [],
}, },
@ -396,7 +432,9 @@ export class ActionFactoryService {
title: 'promote', title: 'promote',
description: 'promote-tooltip', description: 'promote-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -404,7 +442,9 @@ export class ActionFactoryService {
title: 'unpromote', title: 'unpromote',
description: 'unpromote-tooltip', description: 'unpromote-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
]; ];
@ -415,7 +455,9 @@ export class ActionFactoryService {
title: 'mark-as-read', title: 'mark-as-read',
description: 'mark-as-read-tooltip', description: 'mark-as-read-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -423,7 +465,9 @@ export class ActionFactoryService {
title: 'mark-as-unread', title: 'mark-as-unread',
description: 'mark-as-unread-tooltip', description: 'mark-as-unread-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -431,7 +475,9 @@ export class ActionFactoryService {
title: 'scan-series', title: 'scan-series',
description: 'scan-series-tooltip', description: 'scan-series-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
{ {
@ -439,14 +485,18 @@ export class ActionFactoryService {
title: 'add-to', title: 'add-to',
description: '', description: '',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [ children: [
{ {
action: Action.AddToWantToReadList, action: Action.AddToWantToReadList,
title: 'add-to-want-to-read', title: 'add-to-want-to-read',
description: 'add-to-want-to-read-tooltip', description: 'add-to-want-to-read-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -454,7 +504,9 @@ export class ActionFactoryService {
title: 'remove-from-want-to-read', title: 'remove-from-want-to-read',
description: 'remove-to-want-to-read-tooltip', description: 'remove-to-want-to-read-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -462,7 +514,9 @@ export class ActionFactoryService {
title: 'add-to-reading-list', title: 'add-to-reading-list',
description: 'add-to-reading-list-tooltip', description: 'add-to-reading-list-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -470,26 +524,11 @@ export class ActionFactoryService {
title: 'add-to-collection', title: 'add-to-collection',
description: 'add-to-collection-tooltip', description: 'add-to-collection-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
// {
// action: Action.AddToScrobbleHold,
// title: 'add-to-scrobble-hold',
// description: 'add-to-scrobble-hold-tooltip',
// callback: this.dummyCallback,
// requiresAdmin: true,
// children: [],
// },
// {
// action: Action.RemoveFromScrobbleHold,
// title: 'remove-from-scrobble-hold',
// description: 'remove-from-scrobble-hold-tooltip',
// callback: this.dummyCallback,
// requiresAdmin: true,
// children: [],
// },
], ],
}, },
{ {
@ -497,14 +536,18 @@ export class ActionFactoryService {
title: 'send-to', title: 'send-to',
description: 'send-to-tooltip', description: 'send-to-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [ children: [
{ {
action: Action.SendTo, action: Action.SendTo,
title: '', title: '',
description: '', description: '',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
dynamicList: this.deviceService.devices$.pipe(map((devices: Array<Device>) => devices.map(d => { dynamicList: this.deviceService.devices$.pipe(map((devices: Array<Device>) => devices.map(d => {
return {'title': d.name, 'data': d}; return {'title': d.name, 'data': d};
}), shareReplay())), }), shareReplay())),
@ -517,14 +560,18 @@ export class ActionFactoryService {
title: 'others', title: 'others',
description: '', description: '',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [],
children: [ children: [
{ {
action: Action.RefreshMetadata, action: Action.RefreshMetadata,
title: 'refresh-covers', title: 'refresh-covers',
description: 'refresh-covers-tooltip', description: 'refresh-covers-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
{ {
@ -532,7 +579,9 @@ export class ActionFactoryService {
title: 'generate-colorscape', title: 'generate-colorscape',
description: 'generate-colorscape-tooltip', description: 'generate-colorscape-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
{ {
@ -540,7 +589,9 @@ export class ActionFactoryService {
title: 'analyze-files', title: 'analyze-files',
description: 'analyze-files-tooltip', description: 'analyze-files-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
{ {
@ -548,7 +599,9 @@ export class ActionFactoryService {
title: 'delete', title: 'delete',
description: 'delete-tooltip', description: 'delete-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
class: 'danger', class: 'danger',
children: [], children: [],
}, },
@ -559,7 +612,9 @@ export class ActionFactoryService {
title: 'match', title: 'match',
description: 'match-tooltip', description: 'match-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
{ {
@ -567,7 +622,9 @@ export class ActionFactoryService {
title: 'download', title: 'download',
description: 'download-tooltip', description: 'download-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [Role.Download],
children: [], children: [],
}, },
{ {
@ -575,7 +632,9 @@ export class ActionFactoryService {
title: 'edit', title: 'edit',
description: 'edit-tooltip', description: 'edit-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
]; ];
@ -586,7 +645,9 @@ export class ActionFactoryService {
title: 'read-incognito', title: 'read-incognito',
description: 'read-incognito-tooltip', description: 'read-incognito-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -594,7 +655,9 @@ export class ActionFactoryService {
title: 'mark-as-read', title: 'mark-as-read',
description: 'mark-as-read-tooltip', description: 'mark-as-read-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -602,7 +665,9 @@ export class ActionFactoryService {
title: 'mark-as-unread', title: 'mark-as-unread',
description: 'mark-as-unread-tooltip', description: 'mark-as-unread-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -610,14 +675,18 @@ export class ActionFactoryService {
title: 'add-to', title: 'add-to',
description: '=', description: '=',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [ children: [
{ {
action: Action.AddToReadingList, action: Action.AddToReadingList,
title: 'add-to-reading-list', title: 'add-to-reading-list',
description: 'add-to-reading-list-tooltip', description: 'add-to-reading-list-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
} }
] ]
@ -627,14 +696,18 @@ export class ActionFactoryService {
title: 'send-to', title: 'send-to',
description: 'send-to-tooltip', description: 'send-to-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [ children: [
{ {
action: Action.SendTo, action: Action.SendTo,
title: '', title: '',
description: '', description: '',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
dynamicList: this.deviceService.devices$.pipe(map((devices: Array<Device>) => devices.map(d => { dynamicList: this.deviceService.devices$.pipe(map((devices: Array<Device>) => devices.map(d => {
return {'title': d.name, 'data': d}; return {'title': d.name, 'data': d};
}), shareReplay())), }), shareReplay())),
@ -647,14 +720,18 @@ export class ActionFactoryService {
title: 'others', title: 'others',
description: '', description: '',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [ children: [
{ {
action: Action.Delete, action: Action.Delete,
title: 'delete', title: 'delete',
description: 'delete-tooltip', description: 'delete-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
{ {
@ -662,7 +739,9 @@ export class ActionFactoryService {
title: 'download', title: 'download',
description: 'download-tooltip', description: 'download-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
] ]
@ -672,7 +751,9 @@ export class ActionFactoryService {
title: 'details', title: 'details',
description: 'edit-tooltip', description: 'edit-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
]; ];
@ -683,7 +764,9 @@ export class ActionFactoryService {
title: 'read-incognito', title: 'read-incognito',
description: 'read-incognito-tooltip', description: 'read-incognito-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -691,7 +774,9 @@ export class ActionFactoryService {
title: 'mark-as-read', title: 'mark-as-read',
description: 'mark-as-read-tooltip', description: 'mark-as-read-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -699,7 +784,9 @@ export class ActionFactoryService {
title: 'mark-as-unread', title: 'mark-as-unread',
description: 'mark-as-unread-tooltip', description: 'mark-as-unread-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -707,14 +794,18 @@ export class ActionFactoryService {
title: 'add-to', title: 'add-to',
description: '', description: '',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [ children: [
{ {
action: Action.AddToReadingList, action: Action.AddToReadingList,
title: 'add-to-reading-list', title: 'add-to-reading-list',
description: 'add-to-reading-list-tooltip', description: 'add-to-reading-list-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
} }
] ]
@ -724,14 +815,18 @@ export class ActionFactoryService {
title: 'send-to', title: 'send-to',
description: 'send-to-tooltip', description: 'send-to-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [ children: [
{ {
action: Action.SendTo, action: Action.SendTo,
title: '', title: '',
description: '', description: '',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
dynamicList: this.deviceService.devices$.pipe(map((devices: Array<Device>) => devices.map(d => { dynamicList: this.deviceService.devices$.pipe(map((devices: Array<Device>) => devices.map(d => {
return {'title': d.name, 'data': d}; return {'title': d.name, 'data': d};
}), shareReplay())), }), shareReplay())),
@ -745,14 +840,18 @@ export class ActionFactoryService {
title: 'others', title: 'others',
description: '', description: '',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [ children: [
{ {
action: Action.Delete, action: Action.Delete,
title: 'delete', title: 'delete',
description: 'delete-tooltip', description: 'delete-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
{ {
@ -760,7 +859,9 @@ export class ActionFactoryService {
title: 'download', title: 'download',
description: 'download-tooltip', description: 'download-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [Role.Download],
children: [], children: [],
}, },
] ]
@ -770,7 +871,9 @@ export class ActionFactoryService {
title: 'edit', title: 'edit',
description: 'edit-tooltip', description: 'edit-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
]; ];
@ -781,7 +884,9 @@ export class ActionFactoryService {
title: 'edit', title: 'edit',
description: 'edit-tooltip', description: 'edit-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -789,7 +894,9 @@ export class ActionFactoryService {
title: 'delete', title: 'delete',
description: 'delete-tooltip', description: 'delete-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
class: 'danger', class: 'danger',
children: [], children: [],
}, },
@ -798,7 +905,9 @@ export class ActionFactoryService {
title: 'promote', title: 'promote',
description: 'promote-tooltip', description: 'promote-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -806,7 +915,9 @@ export class ActionFactoryService {
title: 'unpromote', title: 'unpromote',
description: 'unpromote-tooltip', description: 'unpromote-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
]; ];
@ -817,7 +928,19 @@ export class ActionFactoryService {
title: 'edit', title: 'edit',
description: 'edit-person-tooltip', description: 'edit-person-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [],
},
{
action: Action.Merge,
title: 'merge',
description: 'merge-person-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
} }
]; ];
@ -828,7 +951,9 @@ export class ActionFactoryService {
title: 'view-series', title: 'view-series',
description: 'view-series-tooltip', description: 'view-series-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -836,7 +961,9 @@ export class ActionFactoryService {
title: 'download', title: 'download',
description: 'download-tooltip', description: 'download-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -844,8 +971,10 @@ export class ActionFactoryService {
title: 'clear', title: 'clear',
description: 'delete-tooltip', description: 'delete-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
class: 'danger', class: 'danger',
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
]; ];
@ -856,7 +985,9 @@ export class ActionFactoryService {
title: 'mark-visible', title: 'mark-visible',
description: 'mark-visible-tooltip', description: 'mark-visible-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -864,7 +995,9 @@ export class ActionFactoryService {
title: 'mark-invisible', title: 'mark-invisible',
description: 'mark-invisible-tooltip', description: 'mark-invisible-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
]; ];
@ -875,7 +1008,9 @@ export class ActionFactoryService {
title: 'rename', title: 'rename',
description: 'rename-tooltip', description: 'rename-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -883,7 +1018,9 @@ export class ActionFactoryService {
title: 'delete', title: 'delete',
description: 'delete-tooltip', description: 'delete-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
]; ];
@ -894,7 +1031,9 @@ export class ActionFactoryService {
title: 'reorder', title: 'reorder',
description: '', description: '',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
} }
] ]
@ -902,21 +1041,24 @@ export class ActionFactoryService {
} }
private applyCallback(action: ActionItem<any>, callback: (action: ActionItem<any>, data: any) => void) { private applyCallback(action: ActionItem<any>, callback: ActionCallback<any>, shouldRenderFunc: ActionShouldRenderFunc<any>) {
action.callback = callback; action.callback = callback;
action.shouldRender = shouldRenderFunc;
if (action.children === null || action.children?.length === 0) return; if (action.children === null || action.children?.length === 0) return;
action.children?.forEach((childAction) => { action.children?.forEach((childAction) => {
this.applyCallback(childAction, callback); this.applyCallback(childAction, callback, shouldRenderFunc);
}); });
} }
public applyCallbackToList(list: Array<ActionItem<any>>, callback: (action: ActionItem<any>, data: any) => void): Array<ActionItem<any>> { public applyCallbackToList(list: Array<ActionItem<any>>,
callback: ActionCallback<any>,
shouldRenderFunc: ActionShouldRenderFunc<any> = this.dummyShouldRender): Array<ActionItem<any>> {
const actions = list.map((a) => { const actions = list.map((a) => {
return { ...a }; return { ...a };
}); });
actions.forEach((action) => this.applyCallback(action, callback)); actions.forEach((action) => this.applyCallback(action, callback, shouldRenderFunc));
return actions; return actions;
} }

View file

@ -473,8 +473,7 @@ export class ActionService {
} }
async deleteMultipleVolumes(volumes: Array<Volume>, callback?: BooleanActionCallback) { async deleteMultipleVolumes(volumes: Array<Volume>, callback?: BooleanActionCallback) {
// TODO: Change translation key back to "toasts.confirm-delete-multiple-volumes" if (!await this.confirmService.confirm(translate('toasts.confirm-delete-multiple-volumes', {count: volumes.length}))) return;
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-multiple-chapters', {count: volumes.length}))) return;
this.volumeService.deleteMultipleVolumes(volumes.map(v => v.id)).subscribe((success) => { this.volumeService.deleteMultipleVolumes(volumes.map(v => v.id)).subscribe((success) => {
if (callback) { if (callback) {

View file

@ -109,7 +109,11 @@ export enum EVENTS {
/** /**
* A Progress event when a smart collection is synchronizing * A Progress event when a smart collection is synchronizing
*/ */
SmartCollectionSync = 'SmartCollectionSync' SmartCollectionSync = 'SmartCollectionSync',
/**
* A Person merged has been merged into another
*/
PersonMerged = 'PersonMerged',
} }
export interface Message<T> { export interface Message<T> {
@ -336,6 +340,13 @@ export class MessageHubService {
payload: resp.body payload: resp.body
}); });
}); });
this.hubConnection.on(EVENTS.PersonMerged, resp => {
this.messagesSource.next({
event: EVENTS.PersonMerged,
payload: resp.body
});
})
} }
stopHubConnection() { stopHubConnection() {

View file

@ -1,14 +1,12 @@
import { Injectable } from '@angular/core'; import {Injectable} from '@angular/core';
import { HttpClient, HttpParams } from "@angular/common/http"; import {HttpClient, HttpParams} from "@angular/common/http";
import {environment} from "../../environments/environment"; import {environment} from "../../environments/environment";
import {Person, PersonRole} from "../_models/metadata/person"; import {Person, PersonRole} from "../_models/metadata/person";
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
import {PaginatedResult} from "../_models/pagination"; import {PaginatedResult} from "../_models/pagination";
import {Series} from "../_models/series"; import {Series} from "../_models/series";
import {map} from "rxjs/operators"; import {map} from "rxjs/operators";
import {UtilityService} from "../shared/_services/utility.service"; import {UtilityService} from "../shared/_services/utility.service";
import {BrowsePerson} from "../_models/person/browse-person"; import {BrowsePerson} from "../_models/person/browse-person";
import {Chapter} from "../_models/chapter";
import {StandaloneChapter} from "../_models/standalone-chapter"; import {StandaloneChapter} from "../_models/standalone-chapter";
import {TextResonse} from "../_types/text-response"; import {TextResonse} from "../_types/text-response";
@ -29,6 +27,10 @@ export class PersonService {
return this.httpClient.get<Person | null>(this.baseUrl + `person?name=${name}`); return this.httpClient.get<Person | null>(this.baseUrl + `person?name=${name}`);
} }
searchPerson(name: string) {
return this.httpClient.get<Array<Person>>(this.baseUrl + `person/search?queryString=${encodeURIComponent(name)}`);
}
getRolesForPerson(personId: number) { getRolesForPerson(personId: number) {
return this.httpClient.get<Array<PersonRole>>(this.baseUrl + `person/roles?personId=${personId}`); return this.httpClient.get<Array<PersonRole>>(this.baseUrl + `person/roles?personId=${personId}`);
} }
@ -55,4 +57,15 @@ export class PersonService {
downloadCover(personId: number) { downloadCover(personId: number) {
return this.httpClient.post<string>(this.baseUrl + 'person/fetch-cover?personId=' + personId, {}, TextResonse); return this.httpClient.post<string>(this.baseUrl + 'person/fetch-cover?personId=' + personId, {}, TextResonse);
} }
isValidAlias(personId: number, alias: string) {
return this.httpClient.get<boolean>(this.baseUrl + `person/valid-alias?personId=${personId}&alias=${alias}`, TextResonse).pipe(
map(valid => valid + '' === 'true')
);
}
mergePerson(destId: number, srcId: number) {
return this.httpClient.post<Person>(this.baseUrl + 'person/merge', {destId, srcId});
}
} }

View file

@ -1,20 +1,19 @@
import { HttpClient } from '@angular/common/http'; import {HttpClient, HttpParams} from '@angular/common/http';
import {Inject, inject, Injectable} from '@angular/core'; import {Inject, inject, Injectable} from '@angular/core';
import { environment } from 'src/environments/environment'; import {environment} from 'src/environments/environment';
import { UserReadStatistics } from '../statistics/_models/user-read-statistics'; import {UserReadStatistics} from '../statistics/_models/user-read-statistics';
import { PublicationStatusPipe } from '../_pipes/publication-status.pipe'; import {PublicationStatusPipe} from '../_pipes/publication-status.pipe';
import {asyncScheduler, finalize, map, tap} from 'rxjs'; import {asyncScheduler, map} from 'rxjs';
import { MangaFormatPipe } from '../_pipes/manga-format.pipe'; import {MangaFormatPipe} from '../_pipes/manga-format.pipe';
import { FileExtensionBreakdown } from '../statistics/_models/file-breakdown'; import {FileExtensionBreakdown} from '../statistics/_models/file-breakdown';
import { TopUserRead } from '../statistics/_models/top-reads'; import {TopUserRead} from '../statistics/_models/top-reads';
import { ReadHistoryEvent } from '../statistics/_models/read-history-event'; import {ReadHistoryEvent} from '../statistics/_models/read-history-event';
import { ServerStatistics } from '../statistics/_models/server-statistics'; import {ServerStatistics} from '../statistics/_models/server-statistics';
import { StatCount } from '../statistics/_models/stat-count'; import {StatCount} from '../statistics/_models/stat-count';
import { PublicationStatus } from '../_models/metadata/publication-status'; import {PublicationStatus} from '../_models/metadata/publication-status';
import { MangaFormat } from '../_models/manga-format'; import {MangaFormat} from '../_models/manga-format';
import { TextResonse } from '../_types/text-response'; import {TextResonse} from '../_types/text-response';
import {TranslocoService} from "@jsverse/transloco"; import {TranslocoService} from "@jsverse/transloco";
import {KavitaPlusMetadataBreakdown} from "../statistics/_models/kavitaplus-metadata-breakdown";
import {throttleTime} from "rxjs/operators"; import {throttleTime} from "rxjs/operators";
import {DEBOUNCE_TIME} from "../shared/_services/download.service"; import {DEBOUNCE_TIME} from "../shared/_services/download.service";
import {download} from "../shared/_models/download"; import {download} from "../shared/_models/download";
@ -44,11 +43,14 @@ export class StatisticsService {
constructor(private httpClient: HttpClient, @Inject(SAVER) private save: Saver) { } constructor(private httpClient: HttpClient, @Inject(SAVER) private save: Saver) { }
getUserStatistics(userId: number, libraryIds: Array<number> = []) { getUserStatistics(userId: number, libraryIds: Array<number> = []) {
// TODO: Convert to httpParams object const url = `${this.baseUrl}stats/user/${userId}/read`;
let url = 'stats/user/' + userId + '/read';
if (libraryIds.length > 0) url += '?libraryIds=' + libraryIds.join(',');
return this.httpClient.get<UserReadStatistics>(this.baseUrl + url); let params = new HttpParams();
if (libraryIds.length > 0) {
params = params.set('libraryIds', libraryIds.join(','));
}
return this.httpClient.get<UserReadStatistics>(url, { params });
} }
getServerStatistics() { getServerStatistics() {
@ -59,7 +61,7 @@ export class StatisticsService {
return this.httpClient.get<StatCount<number>[]>(this.baseUrl + 'stats/server/count/year').pipe( return this.httpClient.get<StatCount<number>[]>(this.baseUrl + 'stats/server/count/year').pipe(
map(spreads => spreads.map(spread => { map(spreads => spreads.map(spread => {
return {name: spread.value + '', value: spread.count}; return {name: spread.value + '', value: spread.count};
}))); })));
} }
getTopYears() { getTopYears() {

View file

@ -1,7 +1,9 @@
<ng-container *transloco="let t; read: 'actionable'"> <ng-container *transloco="let t; read: 'actionable'">
<div class="modal-container"> <div class="modal-container">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title">{{t('title')}}</h4> <h4 class="modal-title">
{{t('title')}}
</h4>
<button type="button" class="btn-close" aria-label="close" (click)="modal.close()"></button> <button type="button" class="btn-close" aria-label="close" (click)="modal.close()"></button>
</div> </div>
<div class="modal-body scrollable-modal"> <div class="modal-body scrollable-modal">
@ -12,8 +14,6 @@
} }
<div class="d-grid gap-2"> <div class="d-grid gap-2">
@for (action of currentItems; track action.title) { @for (action of currentItems; track action.title) {
@if (willRenderAction(action)) { @if (willRenderAction(action)) {
<button class="btn btn-outline-primary text-start d-flex justify-content-between align-items-center w-100" <button class="btn btn-outline-primary text-start d-flex justify-content-between align-items-center w-100"

View file

@ -1,18 +1,18 @@
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, DestroyRef, Component,
DestroyRef,
EventEmitter, EventEmitter,
inject, inject,
Input, Input,
OnInit, OnInit,
Output Output
} from '@angular/core'; } from '@angular/core';
import {NgClass} from "@angular/common";
import {translate, TranslocoDirective} from "@jsverse/transloco"; import {translate, TranslocoDirective} from "@jsverse/transloco";
import {Breakpoint, UtilityService} from "../../shared/_services/utility.service"; import {Breakpoint, UtilityService} from "../../shared/_services/utility.service";
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap"; import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
import {Action, ActionItem} from "../../_services/action-factory.service"; import {ActionableEntity, ActionItem} from "../../_services/action-factory.service";
import {AccountService} from "../../_services/account.service"; import {AccountService} from "../../_services/account.service";
import {tap} from "rxjs"; import {tap} from "rxjs";
import {User} from "../../_models/user"; import {User} from "../../_models/user";
@ -36,6 +36,7 @@ export class ActionableModalComponent implements OnInit {
protected readonly destroyRef = inject(DestroyRef); protected readonly destroyRef = inject(DestroyRef);
protected readonly Breakpoint = Breakpoint; protected readonly Breakpoint = Breakpoint;
@Input() entity: ActionableEntity = null;
@Input() actions: ActionItem<any>[] = []; @Input() actions: ActionItem<any>[] = [];
@Input() willRenderAction!: (action: ActionItem<any>) => boolean; @Input() willRenderAction!: (action: ActionItem<any>) => boolean;
@Input() shouldRenderSubMenu!: (action: ActionItem<any>, dynamicList: null | Array<any>) => boolean; @Input() shouldRenderSubMenu!: (action: ActionItem<any>, dynamicList: null | Array<any>) => boolean;

View file

@ -1,51 +1,57 @@
<ng-container *transloco="let t; read: 'actionable'"> <ng-container *transloco="let t; read: 'actionable'">
@if (actions.length > 0) { @if (actions.length > 0) {
@if ((utilityService.activeBreakpoint$ | async)! <= Breakpoint.Tablet) { @if ((utilityService.activeBreakpoint$ | async)! <= Breakpoint.Tablet) {
<button [disabled]="disabled" class="btn {{btnClass}} px-3" id="actions-{{labelBy}}" <button [disabled]="disabled" class="btn {{btnClass}} px-3" id="actions-{{labelBy}}"
(click)="openMobileActionableMenu($event)"> (click)="openMobileActionableMenu($event)">
{{label}}
<i class="fa {{iconClass}}" aria-hidden="true"></i>
</button>
} @else {
<div ngbDropdown container="body" class="d-inline-block">
<button [disabled]="disabled" class="btn {{btnClass}} px-3" id="actions-{{labelBy}}" ngbDropdownToggle
(click)="preventEvent($event)">
{{label}} {{label}}
<i class="fa {{iconClass}}" aria-hidden="true"></i> <i class="fa {{iconClass}}" aria-hidden="true"></i>
</button> </button>
<div ngbDropdownMenu attr.aria-labelledby="actions-{{labelBy}}"> } @else {
<ng-container *ngTemplateOutlet="submenu; context: { list: actions }"></ng-container> <div ngbDropdown container="body" class="d-inline-block">
<button [disabled]="disabled" class="btn {{btnClass}} px-3" id="actions-{{labelBy}}" ngbDropdownToggle
(click)="preventEvent($event)">
{{label}}
<i class="fa {{iconClass}}" aria-hidden="true"></i>
</button>
<div ngbDropdownMenu attr.aria-labelledby="actions-{{labelBy}}">
<ng-container *ngTemplateOutlet="submenu; context: { list: actions }"></ng-container>
</div>
</div> </div>
</div> <ng-template #submenu let-list="list">
<ng-template #submenu let-list="list"> @for(action of list; track action.title) {
@for(action of list; track action.title) { <!-- Non Submenu items -->
<!-- Non Submenu items --> @if (action.children === undefined || action?.children?.length === 0 || action.dynamicList !== undefined) {
@if (action.children === undefined || action?.children?.length === 0 || action.dynamicList !== undefined) { @if (action.dynamicList !== undefined && (action.dynamicList | async | dynamicList); as dList) {
@if (action.dynamicList !== undefined && (action.dynamicList | async | dynamicList); as dList) { @for(dynamicItem of dList; track dynamicItem.title) {
@for(dynamicItem of dList; track dynamicItem.title) { <button ngbDropdownItem (click)="performDynamicClick($event, action, dynamicItem)">{{dynamicItem.title}}</button>
<button ngbDropdownItem (click)="performDynamicClick($event, action, dynamicItem)">{{dynamicItem.title}}</button>
}
} @else if (willRenderAction(action)) {
<button ngbDropdownItem (click)="performAction($event, action)" (mouseover)="closeAllSubmenus()">{{t(action.title)}}</button>
}
} @else {
@if (shouldRenderSubMenu(action, action.children?.[0].dynamicList | async)) {
<!-- Submenu items -->
<div ngbDropdown #subMenuHover="ngbDropdown" placement="right left"
(click)="preventEvent($event); openSubmenu(action.title, subMenuHover)"
(mouseover)="preventEvent($event); openSubmenu(action.title, subMenuHover)"
(mouseleave)="preventEvent($event)">
@if (willRenderAction(action)) {
<button id="actions-{{action.title}}" class="submenu-toggle" ngbDropdownToggle>{{t(action.title)}} <i class="fa-solid fa-angle-right submenu-icon"></i></button>
} }
<div ngbDropdownMenu attr.aria-labelledby="actions-{{action.title}}"> } @else if (willRenderAction(action, this.currentUser!)) {
<ng-container *ngTemplateOutlet="submenu; context: { list: action.children }"></ng-container> <button ngbDropdownItem (click)="performAction($event, action)">{{t(action.title)}}</button>
}
} @else {
@if (shouldRenderSubMenu(action, action.children?.[0].dynamicList | async) && hasRenderableChildren(action, this.currentUser!)) {
<!-- Submenu items -->
<div ngbDropdown #subMenuHover="ngbDropdown" placement="right left"
(click)="openSubmenu(action.title, subMenuHover)"
(mouseenter)="openSubmenu(action.title, subMenuHover)"
(mouseover)="preventEvent($event)"
class="submenu-wrapper">
<!-- Check to ensure the submenu has items -->
@if (willRenderAction(action, this.currentUser!)) {
<button id="actions-{{action.title}}" class="submenu-toggle" ngbDropdownToggle>
{{t(action.title)}} <i class="fa-solid fa-angle-right submenu-icon"></i>
</button>
}
<div ngbDropdownMenu attr.aria-labelledby="actions-{{action.title}}">
<ng-container *ngTemplateOutlet="submenu; context: { list: action.children }"></ng-container>
</div>
</div> </div>
</div> }
} }
} }
} </ng-template>
</ng-template> }
} }
} </ng-container>
</ng-container>

View file

@ -2,6 +2,22 @@
content: none !important; content: none !important;
} }
.submenu-wrapper {
position: relative;
&::after {
content: '';
position: absolute;
top: 0;
right: -10px;
width: 10px;
height: 100%;
background: transparent;
cursor: pointer;
pointer-events: auto;
}
}
.submenu-toggle { .submenu-toggle {
display: block; display: block;
width: 100%; width: 100%;
@ -30,9 +46,3 @@
.btn { .btn {
padding: 5px; padding: 5px;
} }
// Robbie added this but it broke most of the uses
//.dropdown-toggle {
// padding-top: 0;
// padding-bottom: 0;
//}

View file

@ -1,31 +1,39 @@
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, DestroyRef, Component,
DestroyRef,
EventEmitter, EventEmitter,
inject, inject,
Input, Input,
OnChanges,
OnDestroy,
OnInit, OnInit,
Output Output
} from '@angular/core'; } from '@angular/core';
import {NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle, NgbModal} from '@ng-bootstrap/ng-bootstrap'; import {NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle, NgbModal} from '@ng-bootstrap/ng-bootstrap';
import { AccountService } from 'src/app/_services/account.service'; import {AccountService} from 'src/app/_services/account.service';
import { Action, ActionItem } from 'src/app/_services/action-factory.service'; import {ActionableEntity, ActionItem} from 'src/app/_services/action-factory.service';
import {AsyncPipe, NgTemplateOutlet} from "@angular/common"; import {AsyncPipe, NgTemplateOutlet} from "@angular/common";
import {TranslocoDirective} from "@jsverse/transloco"; import {TranslocoDirective} from "@jsverse/transloco";
import {DynamicListPipe} from "./_pipes/dynamic-list.pipe"; import {DynamicListPipe} from "./_pipes/dynamic-list.pipe";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {Breakpoint, UtilityService} from "../../shared/_services/utility.service"; import {Breakpoint, UtilityService} from "../../shared/_services/utility.service";
import {ActionableModalComponent} from "../actionable-modal/actionable-modal.component"; import {ActionableModalComponent} from "../actionable-modal/actionable-modal.component";
import {User} from "../../_models/user";
@Component({ @Component({
selector: 'app-card-actionables', selector: 'app-card-actionables',
imports: [NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, DynamicListPipe, TranslocoDirective, AsyncPipe, NgTemplateOutlet], imports: [
templateUrl: './card-actionables.component.html', NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem,
styleUrls: ['./card-actionables.component.scss'], DynamicListPipe, TranslocoDirective, AsyncPipe, NgTemplateOutlet
changeDetection: ChangeDetectionStrategy.OnPush ],
templateUrl: './card-actionables.component.html',
styleUrls: ['./card-actionables.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class CardActionablesComponent implements OnInit { export class CardActionablesComponent implements OnInit, OnChanges, OnDestroy {
private readonly cdRef = inject(ChangeDetectorRef); private readonly cdRef = inject(ChangeDetectorRef);
private readonly accountService = inject(AccountService); private readonly accountService = inject(AccountService);
@ -37,58 +45,69 @@ export class CardActionablesComponent implements OnInit {
@Input() iconClass = 'fa-ellipsis-v'; @Input() iconClass = 'fa-ellipsis-v';
@Input() btnClass = ''; @Input() btnClass = '';
@Input() actions: ActionItem<any>[] = []; @Input() inputActions: ActionItem<any>[] = [];
@Input() labelBy = 'card'; @Input() labelBy = 'card';
/** /**
* Text to display as if actionable was a button * Text to display as if actionable was a button
*/ */
@Input() label = ''; @Input() label = '';
@Input() disabled: boolean = false; @Input() disabled: boolean = false;
@Input() entity: ActionableEntity = null;
/**
* This will only emit when the action is clicked and the entity is null. Otherwise, the entity callback handler will be invoked.
*/
@Output() actionHandler = new EventEmitter<ActionItem<any>>(); @Output() actionHandler = new EventEmitter<ActionItem<any>>();
isAdmin: boolean = false; actions: ActionItem<ActionableEntity>[] = [];
canDownload: boolean = false; currentUser: User | undefined = undefined;
canPromote: boolean = false;
submenu: {[key: string]: NgbDropdown} = {}; submenu: {[key: string]: NgbDropdown} = {};
private closeTimeout: any = null;
ngOnInit(): void { ngOnInit(): void {
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((user) => { this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((user) => {
if (!user) return; if (!user) return;
this.isAdmin = this.accountService.hasAdminRole(user); this.currentUser = user;
this.canDownload = this.accountService.hasDownloadRole(user); this.actions = this.inputActions.filter(a => this.willRenderAction(a, user!));
this.canPromote = this.accountService.hasPromoteRole(user);
// We want to avoid an empty menu when user doesn't have access to anything
if (!this.isAdmin && this.actions.filter(a => !a.requiresAdmin).length === 0) {
this.actions = [];
}
this.cdRef.markForCheck(); this.cdRef.markForCheck();
}); });
} }
ngOnChanges() {
this.actions = this.inputActions.filter(a => this.willRenderAction(a, this.currentUser!));
this.cdRef.markForCheck();
}
ngOnDestroy() {
this.cancelCloseSubmenus();
}
preventEvent(event: any) { preventEvent(event: any) {
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();
} }
performAction(event: any, action: ActionItem<any>) { performAction(event: any, action: ActionItem<ActionableEntity>) {
this.preventEvent(event); this.preventEvent(event);
if (typeof action.callback === 'function') { if (typeof action.callback === 'function') {
this.actionHandler.emit(action); if (this.entity === null) {
this.actionHandler.emit(action);
} else {
action.callback(action, this.entity);
}
} }
} }
willRenderAction(action: ActionItem<any>) { /**
return (action.requiresAdmin && this.isAdmin) * The user has required roles (or no roles defined) and action shouldRender returns true
|| (action.action === Action.Download && (this.canDownload || this.isAdmin)) * @param action
|| (!action.requiresAdmin && action.action !== Action.Download) * @param user
|| (action.action === Action.Promote && (this.canPromote || this.isAdmin)) */
|| (action.action === Action.UnPromote && (this.canPromote || this.isAdmin)) willRenderAction(action: ActionItem<ActionableEntity>, user: User) {
; return (!action.requiredRoles?.length || this.accountService.hasAnyRole(user, action.requiredRoles)) && action.shouldRender(action, this.entity, user);
} }
shouldRenderSubMenu(action: ActionItem<any>, dynamicList: null | Array<any>) { shouldRenderSubMenu(action: ActionItem<any>, dynamicList: null | Array<any>) {
@ -109,13 +128,41 @@ export class CardActionablesComponent implements OnInit {
} }
closeAllSubmenus() { closeAllSubmenus() {
Object.keys(this.submenu).forEach(key => { // Clear any existing timeout to avoid race conditions
this.submenu[key].close(); if (this.closeTimeout) {
clearTimeout(this.closeTimeout);
}
// Set a new timeout to close submenus after a short delay
this.closeTimeout = setTimeout(() => {
Object.keys(this.submenu).forEach(key => {
this.submenu[key].close();
delete this.submenu[key]; delete this.submenu[key];
}); });
}, 100); // Small delay to prevent premature closing (dropdown tunneling)
} }
performDynamicClick(event: any, action: ActionItem<any>, dynamicItem: any) { cancelCloseSubmenus() {
if (this.closeTimeout) {
clearTimeout(this.closeTimeout);
this.closeTimeout = null;
}
}
hasRenderableChildren(action: ActionItem<ActionableEntity>, user: User): boolean {
if (!action.children || action.children.length === 0) return false;
for (const child of action.children) {
const dynamicList = child.dynamicList;
if (dynamicList !== undefined) return true; // Dynamic list gets rendered if loaded
if (this.willRenderAction(child, user)) return true;
if (child.children?.length && this.hasRenderableChildren(child, user)) return true;
}
return false;
}
performDynamicClick(event: any, action: ActionItem<ActionableEntity>, dynamicItem: any) {
action._extra = dynamicItem; action._extra = dynamicItem;
this.performAction(event, action); this.performAction(event, action);
} }
@ -124,6 +171,7 @@ export class CardActionablesComponent implements OnInit {
this.preventEvent(event); this.preventEvent(event);
const ref = this.modalService.open(ActionableModalComponent, {fullscreen: true, centered: true}); const ref = this.modalService.open(ActionableModalComponent, {fullscreen: true, centered: true});
ref.componentInstance.entity = this.entity;
ref.componentInstance.actions = this.actions; ref.componentInstance.actions = this.actions;
ref.componentInstance.willRenderAction = this.willRenderAction.bind(this); ref.componentInstance.willRenderAction = this.willRenderAction.bind(this);
ref.componentInstance.shouldRenderSubMenu = this.shouldRenderSubMenu.bind(this); ref.componentInstance.shouldRenderSubMenu = this.shouldRenderSubMenu.bind(this);

View file

@ -483,7 +483,7 @@ export class EditChapterModalComponent implements OnInit {
}; };
personSettings.addTransformFn = ((title: string) => { personSettings.addTransformFn = ((title: string) => {
return {id: 0, name: title, role: role, description: '', coverImage: '', coverImageLocked: false, primaryColor: '', secondaryColor: '' }; return {id: 0, name: title, aliases: [], role: role, description: '', coverImage: '', coverImageLocked: false, primaryColor: '', secondaryColor: '' };
}); });
personSettings.trackByIdentityFn = (index, value) => value.name + (value.id + ''); personSettings.trackByIdentityFn = (index, value) => value.name + (value.id + '');

View file

@ -1,7 +1,8 @@
<ng-container *transloco="let t; read: 'manage-library'"> <ng-container *transloco="let t; read: 'manage-library'">
<div class="position-relative"> <div class="position-relative">
<div class="position-absolute custom-position-2"> <div class="position-absolute custom-position-2">
<app-card-actionables [actions]="bulkActions" btnClass="btn-outline-primary ms-1" [label]="t('bulk-action-label')" [disabled]="bulkMode" (actionHandler)="handleBulkAction($event, null)"> <app-card-actionables [inputActions]="bulkActions" btnClass="btn-outline-primary ms-1" [label]="t('bulk-action-label')"
[disabled]="bulkMode">
</app-card-actionables> </app-card-actionables>
</div> </div>
@ -72,11 +73,22 @@
<td> <td>
<!-- On Mobile we want to use ... for each row --> <!-- On Mobile we want to use ... for each row -->
@if (useActionables$ | async) { @if (useActionables$ | async) {
<app-card-actionables [actions]="actions" (actionHandler)="performAction($event, library)"></app-card-actionables> <app-card-actionables [entity]="library" [inputActions]="actions"></app-card-actionables>
} @else { } @else {
<button class="btn btn-secondary me-2 btn-sm" (click)="scanLibrary(library)" placement="top" [ngbTooltip]="t('scan-library')" [attr.aria-label]="t('scan-library')"><i class="fa fa-sync-alt" aria-hidden="true"></i></button> <button class="btn btn-secondary me-2 btn-sm" (click)="scanLibrary(library)" placement="top" [ngbTooltip]="t('scan-library')"
<button class="btn btn-danger me-2 btn-sm" [disabled]="deletionInProgress" (click)="deleteLibrary(library)"><i class="fa fa-trash" placement="top" [ngbTooltip]="t('delete-library')" [attr.aria-label]="t('delete-library-by-name', {name: library.name | sentenceCase})"></i></button> [attr.aria-label]="t('scan-library')">
<button class="btn btn-primary btn-sm" (click)="editLibrary(library)"><i class="fa fa-pen" placement="top" [ngbTooltip]="t('edit-library')" [attr.aria-label]="t('edit-library-by-name', {name: library.name | sentenceCase})"></i></button> <i class="fa fa-sync-alt" aria-hidden="true"></i>
</button>
<button class="btn btn-danger me-2 btn-sm" [disabled]="deletionInProgress" (click)="deleteLibrary(library)">
<i class="fa fa-trash" placement="top" [ngbTooltip]="t('delete-library')"
[attr.aria-label]="t('delete-library-by-name', {name: library.name | sentenceCase})"></i>
</button>
<button class="btn btn-primary btn-sm" (click)="editLibrary(library)">
<i class="fa fa-pen" placement="top" [ngbTooltip]="t('edit-library')"
[attr.aria-label]="t('edit-library-by-name', {name: library.name | sentenceCase})"></i>
</button>
} }
</td> </td>
</tr> </tr>

View file

@ -83,12 +83,12 @@ export class ManageLibraryComponent implements OnInit {
lastSelectedIndex: number | null = null; lastSelectedIndex: number | null = null;
@HostListener('document:keydown.shift', ['$event']) @HostListener('document:keydown.shift', ['$event'])
handleKeypress(event: KeyboardEvent) { handleKeypress(_: KeyboardEvent) {
this.isShiftDown = true; this.isShiftDown = true;
} }
@HostListener('document:keyup.shift', ['$event']) @HostListener('document:keyup.shift', ['$event'])
handleKeyUp(event: KeyboardEvent) { handleKeyUp(_: KeyboardEvent) {
this.isShiftDown = false; this.isShiftDown = false;
} }
@ -106,7 +106,7 @@ export class ManageLibraryComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.getLibraries(); this.getLibraries();
// when a progress event comes in, show it on the UI next to library // when a progress event comes in, show it on the UI next to the library
this.hubService.messages$.pipe(takeUntilDestroyed(this.destroyRef), this.hubService.messages$.pipe(takeUntilDestroyed(this.destroyRef),
filter(event => event.event === EVENTS.ScanSeries || event.event === EVENTS.NotificationProgress), filter(event => event.event === EVENTS.ScanSeries || event.event === EVENTS.NotificationProgress),
distinctUntilChanged((prev: Message<ScanSeriesEvent | NotificationProgressEvent>, curr: Message<ScanSeriesEvent | NotificationProgressEvent>) => distinctUntilChanged((prev: Message<ScanSeriesEvent | NotificationProgressEvent>, curr: Message<ScanSeriesEvent | NotificationProgressEvent>) =>
@ -270,7 +270,8 @@ export class ManageLibraryComponent implements OnInit {
} }
} }
async handleBulkAction(action: ActionItem<Library>, library : Library | null) { async handleBulkAction(action: ActionItem<Library>, _: Library) {
//Library is null for bulk actions
this.bulkAction = action.action; this.bulkAction = action.action;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
@ -284,7 +285,7 @@ export class ManageLibraryComponent implements OnInit {
break; break;
case (Action.CopySettings): case (Action.CopySettings):
// Prompt the user for the library then wait for them to manually trigger applyBulkAction // Prompt the user for the library, then wait for them to manually trigger applyBulkAction
const ref = this.modalService.open(CopySettingsFromLibraryModalComponent, {size: 'lg', fullscreen: 'md'}); const ref = this.modalService.open(CopySettingsFromLibraryModalComponent, {size: 'lg', fullscreen: 'md'});
ref.componentInstance.libraries = this.libraries; ref.componentInstance.libraries = this.libraries;
ref.closed.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((res: number | null) => { ref.closed.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((res: number | null) => {
@ -298,7 +299,6 @@ export class ManageLibraryComponent implements OnInit {
} }
} }
async handleAction(action: ActionItem<Library>, library: Library) { async handleAction(action: ActionItem<Library>, library: Library) {
switch (action.action) { switch (action.action) {
case(Action.Scan): case(Action.Scan):
@ -321,13 +321,6 @@ export class ManageLibraryComponent implements OnInit {
} }
} }
performAction(action: ActionItem<Library>, library: Library) {
if (typeof action.callback === 'function') {
action.callback(action, library);
}
}
setupSelections() { setupSelections() {
this.selections = new SelectionModel<Library>(false, this.libraries); this.selections = new SelectionModel<Library>(false, this.libraries);
this.cdRef.markForCheck(); this.cdRef.markForCheck();

View file

@ -1,11 +0,0 @@
@if (logs$ | async; as items) {
<virtual-scroller #scroll [items]="items" [bufferAmount]="1">
<div class="grid row g-0" #container>
@for (item of scroll.viewPortItems; track item.timestamp) {
<div class="card col-auto mt-2 mb-2">
{{item.timestamp | date}} [{{item.level}}] {{item.message}}
</div>
}
</div>
</virtual-scroller>
}

View file

@ -1,71 +0,0 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr';
import { BehaviorSubject, take } from 'rxjs';
import { AccountService } from 'src/app/_services/account.service';
import { environment } from 'src/environments/environment';
import { VirtualScrollerModule } from '@iharbeck/ngx-virtual-scroller';
import { AsyncPipe, DatePipe } from '@angular/common';
interface LogMessage {
timestamp: string;
level: 'Information' | 'Debug' | 'Warning' | 'Error';
message: string;
exception: string;
}
@Component({
selector: 'app-manage-logs',
templateUrl: './manage-logs.component.html',
styleUrls: ['./manage-logs.component.scss'],
standalone: true,
imports: [VirtualScrollerModule, AsyncPipe, DatePipe]
})
export class ManageLogsComponent implements OnInit, OnDestroy {
hubUrl = environment.hubUrl;
private hubConnection!: HubConnection;
logsSource = new BehaviorSubject<LogMessage[]>([]);
public logs$ = this.logsSource.asObservable();
constructor(private accountService: AccountService) { }
ngOnInit(): void {
// TODO: Come back and implement this one day
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (user) {
this.hubConnection = new HubConnectionBuilder()
.withUrl(this.hubUrl + 'logs', {
accessTokenFactory: () => user.token
})
.withAutomaticReconnect()
.build();
console.log('Starting log connection');
this.hubConnection
.start()
.catch(err => console.error(err));
this.hubConnection.on('SendLogAsObject', resp => {
const payload = resp.arguments[0] as LogMessage;
const logMessage = {timestamp: payload.timestamp, level: payload.level, message: payload.message, exception: payload.exception};
// NOTE: It might be better to just have a queue to show this
const values = this.logsSource.getValue();
values.push(logMessage);
this.logsSource.next(values);
});
}
});
}
ngOnDestroy(): void {
// unsubscribe from signalr connection
if (this.hubConnection) {
this.hubConnection.stop().catch(err => console.error(err));
console.log('Stopping log connection');
}
}
}

View file

@ -10,6 +10,4 @@ import {ChangeDetectionStrategy, Component, Input} from '@angular/core';
export class UpdateSectionComponent { export class UpdateSectionComponent {
@Input({required: true}) items: Array<string> = []; @Input({required: true}) items: Array<string> = [];
@Input({required: true}) title: string = ''; @Input({required: true}) title: string = '';
// TODO: Implement a read-more-list so that we by default show a configurable number
} }

View file

@ -521,7 +521,7 @@ export class EditSeriesModalComponent implements OnInit {
}; };
personSettings.addTransformFn = ((title: string) => { personSettings.addTransformFn = ((title: string) => {
return {id: 0, name: title, description: '', coverImageLocked: false, primaryColor: '', secondaryColor: '' }; return {id: 0, name: title, aliases: [], description: '', coverImageLocked: false, primaryColor: '', secondaryColor: '' };
}); });
personSettings.trackByIdentityFn = (index, value) => value.name + (value.id + ''); personSettings.trackByIdentityFn = (index, value) => value.name + (value.id + '');

View file

@ -23,7 +23,7 @@
<span class="visually-hidden">{{t('mark-as-read')}}</span> <span class="visually-hidden">{{t('mark-as-read')}}</span>
</button> </button>
} }
<app-card-actionables [actions]="actions" labelBy="bulk-actions-header" iconClass="fa-ellipsis-h" (actionHandler)="performAction($event)"></app-card-actionables> <app-card-actionables (actionHandler)="performAction($event)" [inputActions]="actions" labelBy="bulk-actions-header" iconClass="fa-ellipsis-h" />
</span> </span>
<span id="bulk-actions-header" class="visually-hidden">Bulk Actions</span> <span id="bulk-actions-header" class="visually-hidden">Bulk Actions</span>

View file

@ -2,13 +2,14 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
DestroyRef, HostListener, DestroyRef,
HostListener,
inject, inject,
Input, Input,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/action-factory.service'; import {Action, ActionFactoryService, ActionItem} from 'src/app/_services/action-factory.service';
import { BulkSelectionService } from '../bulk-selection.service'; import {BulkSelectionService} from '../bulk-selection.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {AsyncPipe, DecimalPipe, NgStyle} from "@angular/common"; import {AsyncPipe, DecimalPipe, NgStyle} from "@angular/common";
import {TranslocoModule} from "@jsverse/transloco"; import {TranslocoModule} from "@jsverse/transloco";
@ -17,18 +18,18 @@ import {CardActionablesComponent} from "../../_single-module/card-actionables/ca
import {KEY_CODES} from "../../shared/_services/utility.service"; import {KEY_CODES} from "../../shared/_services/utility.service";
@Component({ @Component({
selector: 'app-bulk-operations', selector: 'app-bulk-operations',
imports: [ imports: [
AsyncPipe, AsyncPipe,
CardActionablesComponent, CardActionablesComponent,
TranslocoModule, TranslocoModule,
NgbTooltip, NgbTooltip,
NgStyle, NgStyle,
DecimalPipe DecimalPipe
], ],
templateUrl: './bulk-operations.component.html', templateUrl: './bulk-operations.component.html',
styleUrls: ['./bulk-operations.component.scss'], styleUrls: ['./bulk-operations.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class BulkOperationsComponent implements OnInit { export class BulkOperationsComponent implements OnInit {

View file

@ -7,7 +7,7 @@
<h4> <h4>
@if (actions.length > 0) { @if (actions.length > 0) {
<span> <span>
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="header"></app-card-actionables>&nbsp; <app-card-actionables (actionHandler)="performAction($event)" [inputActions]="actions" [labelBy]="header"></app-card-actionables>&nbsp;
</span> </span>
} }

View file

@ -94,7 +94,7 @@
<span class="card-actions"> <span class="card-actions">
@if (actions && actions.length > 0) { @if (actions && actions.length > 0) {
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="title"></app-card-actionables> <app-card-actionables (actionHandler)="performAction($event)" [inputActions]="actions" [labelBy]="title"></app-card-actionables>
} }
</span> </span>
</div> </div>

View file

@ -344,10 +344,6 @@ export class CardItemComponent implements OnInit {
this.clicked.emit(this.title); this.clicked.emit(this.title);
} }
preventClick(event: any) {
event.stopPropagation();
event.preventDefault();
}
performAction(action: ActionItem<any>) { performAction(action: ActionItem<any>) {
if (action.action == Action.Download) { if (action.action == Action.Download) {

View file

@ -89,7 +89,7 @@
</span> </span>
<span class="card-actions"> <span class="card-actions">
@if (actions && actions.length > 0) { @if (actions && actions.length > 0) {
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="chapter.titleName"></app-card-actionables> <app-card-actionables [entity]="chapter" [inputActions]="actions" [labelBy]="chapter.titleName"></app-card-actionables>
} }
</span> </span>
</div> </div>

View file

@ -3,9 +3,11 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
DestroyRef, DestroyRef,
EventEmitter, HostListener, EventEmitter,
HostListener,
inject, inject,
Input, OnInit, Input,
OnInit,
Output Output
} from '@angular/core'; } from '@angular/core';
import {ImageService} from "../../_services/image.service"; import {ImageService} from "../../_services/image.service";
@ -14,7 +16,7 @@ import {DownloadEvent, DownloadService} from "../../shared/_services/download.se
import {EVENTS, MessageHubService} from "../../_services/message-hub.service"; import {EVENTS, MessageHubService} from "../../_services/message-hub.service";
import {AccountService} from "../../_services/account.service"; import {AccountService} from "../../_services/account.service";
import {ScrollService} from "../../_services/scroll.service"; import {ScrollService} from "../../_services/scroll.service";
import {Action, ActionFactoryService, ActionItem} from "../../_services/action-factory.service"; import {ActionItem} from "../../_services/action-factory.service";
import {Chapter} from "../../_models/chapter"; import {Chapter} from "../../_models/chapter";
import {Observable} from "rxjs"; import {Observable} from "rxjs";
import {User} from "../../_models/user"; import {User} from "../../_models/user";
@ -28,13 +30,10 @@ import {EntityTitleComponent} from "../entity-title/entity-title.component";
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component"; import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
import {Router, RouterLink} from "@angular/router"; import {Router, RouterLink} from "@angular/router";
import {TranslocoDirective} from "@jsverse/transloco"; import {TranslocoDirective} from "@jsverse/transloco";
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
import {filter, map} from "rxjs/operators"; import {filter, map} from "rxjs/operators";
import {UserProgressUpdateEvent} from "../../_models/events/user-progress-update-event"; import {UserProgressUpdateEvent} from "../../_models/events/user-progress-update-event";
import {ReaderService} from "../../_services/reader.service"; import {ReaderService} from "../../_services/reader.service";
import {LibraryType} from "../../_models/library/library"; import {LibraryType} from "../../_models/library/library";
import {Device} from "../../_models/device/device";
import {ActionService} from "../../_services/action.service";
import {MangaFormat} from "../../_models/manga-format"; import {MangaFormat} from "../../_models/manga-format";
@Component({ @Component({
@ -60,15 +59,16 @@ export class ChapterCardComponent implements OnInit {
public readonly imageService = inject(ImageService); public readonly imageService = inject(ImageService);
public readonly bulkSelectionService = inject(BulkSelectionService); public readonly bulkSelectionService = inject(BulkSelectionService);
private readonly downloadService = inject(DownloadService); private readonly downloadService = inject(DownloadService);
private readonly actionService = inject(ActionService);
private readonly messageHub = inject(MessageHubService); private readonly messageHub = inject(MessageHubService);
private readonly accountService = inject(AccountService); private readonly accountService = inject(AccountService);
private readonly scrollService = inject(ScrollService); private readonly scrollService = inject(ScrollService);
private readonly cdRef = inject(ChangeDetectorRef); private readonly cdRef = inject(ChangeDetectorRef);
private readonly actionFactoryService = inject(ActionFactoryService);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly readerService = inject(ReaderService); private readonly readerService = inject(ReaderService);
protected readonly LibraryType = LibraryType;
protected readonly MangaFormat = MangaFormat;
@Input({required: true}) libraryId: number = 0; @Input({required: true}) libraryId: number = 0;
@Input({required: true}) seriesId: number = 0; @Input({required: true}) seriesId: number = 0;
@Input({required: true}) chapter!: Chapter; @Input({required: true}) chapter!: Chapter;
@ -143,8 +143,6 @@ export class ChapterCardComponent implements OnInit {
} }
ngOnInit() { ngOnInit() {
this.filterSendTo();
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => { this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
this.user = user; this.user = user;
}); });
@ -172,30 +170,6 @@ export class ChapterCardComponent implements OnInit {
this.cdRef.detectChanges(); this.cdRef.detectChanges();
} }
filterSendTo() {
if (!this.actions || this.actions.length === 0) return;
this.actions = this.actionFactoryService.filterSendToAction(this.actions, this.chapter);
}
performAction(action: ActionItem<any>) {
if (action.action == Action.Download) {
this.downloadService.download('chapter', this.chapter);
return; // Don't propagate the download from a card
}
if (action.action == Action.SendTo) {
const device = (action._extra!.data as Device);
this.actionService.sendToDevice([this.chapter.id], device);
return;
}
if (typeof action.callback === 'function') {
action.callback(action, this.chapter);
}
}
handleClick(event: any) { handleClick(event: any) {
if (this.bulkSelectionService.hasSelections()) { if (this.bulkSelectionService.hasSelections()) {
this.handleSelection(event); this.handleSelection(event);
@ -209,8 +183,4 @@ export class ChapterCardComponent implements OnInit {
event.stopPropagation(); event.stopPropagation();
this.readerService.readChapter(this.libraryId, this.seriesId, this.chapter, false); this.readerService.readChapter(this.libraryId, this.seriesId, this.chapter, false);
} }
protected readonly LibraryType = LibraryType;
protected readonly MangaFormat = MangaFormat;
} }

View file

@ -32,7 +32,7 @@
</span> </span>
@if (actions && actions.length > 0) { @if (actions && actions.length > 0) {
<span class="card-actions float-end"> <span class="card-actions float-end">
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="title"></app-card-actionables> <app-card-actionables [entity]="entity" [inputActions]="actions" [labelBy]="title"></app-card-actionables>
</span> </span>
} }
</div> </div>

View file

@ -1,20 +1,20 @@
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, ContentChild, Component,
DestroyRef, EventEmitter, ContentChild,
DestroyRef,
EventEmitter,
HostListener, HostListener,
inject, inject,
Input, Output, TemplateRef Input,
Output,
TemplateRef
} from '@angular/core'; } from '@angular/core';
import {Action, ActionFactoryService, ActionItem} from "../../_services/action-factory.service"; import {ActionItem} from "../../_services/action-factory.service";
import {ImageService} from "../../_services/image.service"; import {ImageService} from "../../_services/image.service";
import {BulkSelectionService} from "../bulk-selection.service"; import {BulkSelectionService} from "../bulk-selection.service";
import {LibraryService} from "../../_services/library.service";
import {DownloadService} from "../../shared/_services/download.service";
import {UtilityService} from "../../shared/_services/utility.service";
import {MessageHubService} from "../../_services/message-hub.service"; import {MessageHubService} from "../../_services/message-hub.service";
import {AccountService} from "../../_services/account.service";
import {ScrollService} from "../../_services/scroll.service"; import {ScrollService} from "../../_services/scroll.service";
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component"; import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
@ -139,11 +139,6 @@ export class PersonCardComponent {
this.clicked.emit(this.title); this.clicked.emit(this.title);
} }
performAction(action: ActionItem<any>) {
if (typeof action.callback === 'function') {
action.callback(action, this.entity);
}
}
handleSelection(event?: any) { handleSelection(event?: any) {
if (event) { if (event) {

View file

@ -74,7 +74,7 @@
@if (actions && actions.length > 0) { @if (actions && actions.length > 0) {
<span class="card-actions"> <span class="card-actions">
<app-card-actionables (actionHandler)="handleSeriesActionCallback($event, series)" [actions]="actions" [labelBy]="series.name"></app-card-actionables> <app-card-actionables [entity]="series" [inputActions]="actions" [labelBy]="series.name"></app-card-actionables>
</span> </span>
} }
</div> </div>

View file

@ -22,7 +22,6 @@ import {ActionService} from 'src/app/_services/action.service';
import {EditSeriesModalComponent} from '../_modals/edit-series-modal/edit-series-modal.component'; import {EditSeriesModalComponent} from '../_modals/edit-series-modal/edit-series-modal.component';
import {RelationKind} from 'src/app/_models/series-detail/relation-kind'; import {RelationKind} from 'src/app/_models/series-detail/relation-kind';
import {DecimalPipe} from "@angular/common"; import {DecimalPipe} from "@angular/common";
import {CardItemComponent} from "../card-item/card-item.component";
import {RelationshipPipe} from "../../_pipes/relationship.pipe"; import {RelationshipPipe} from "../../_pipes/relationship.pipe";
import {Device} from "../../_models/device/device"; import {Device} from "../../_models/device/device";
import {translate, TranslocoDirective} from "@jsverse/transloco"; import {translate, TranslocoDirective} from "@jsverse/transloco";
@ -30,7 +29,6 @@ import {SeriesPreviewDrawerComponent} from "../../_single-module/series-preview-
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component"; import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
import {DownloadIndicatorComponent} from "../download-indicator/download-indicator.component"; import {DownloadIndicatorComponent} from "../download-indicator/download-indicator.component";
import {EntityTitleComponent} from "../entity-title/entity-title.component";
import {FormsModule} from "@angular/forms"; import {FormsModule} from "@angular/forms";
import {ImageComponent} from "../../shared/image/image.component"; import {ImageComponent} from "../../shared/image/image.component";
import {DownloadEvent, DownloadService} from "../../shared/_services/download.service"; import {DownloadEvent, DownloadService} from "../../shared/_services/download.service";
@ -39,7 +37,6 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {map} from "rxjs/operators"; import {map} from "rxjs/operators";
import {AccountService} from "../../_services/account.service"; import {AccountService} from "../../_services/account.service";
import {BulkSelectionService} from "../bulk-selection.service"; import {BulkSelectionService} from "../bulk-selection.service";
import {User} from "../../_models/user";
import {ScrollService} from "../../_services/scroll.service"; import {ScrollService} from "../../_services/scroll.service";
import {ReaderService} from "../../_services/reader.service"; import {ReaderService} from "../../_services/reader.service";
import {SeriesFormatComponent} from "../../shared/series-format/series-format.component"; import {SeriesFormatComponent} from "../../shared/series-format/series-format.component";
@ -147,8 +144,6 @@ export class SeriesCardComponent implements OnInit, OnChanges {
*/ */
prevOffset: number = 0; prevOffset: number = 0;
selectionInProgress: boolean = false; selectionInProgress: boolean = false;
private user: User | undefined;
@HostListener('touchmove', ['$event']) @HostListener('touchmove', ['$event'])
onTouchMove(event: TouchEvent) { onTouchMove(event: TouchEvent) {
@ -192,15 +187,15 @@ export class SeriesCardComponent implements OnInit, OnChanges {
ngOnChanges(changes: any) { ngOnChanges(changes: any) {
if (this.series) { if (this.series) {
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => { // this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
this.user = user; // this.user = user;
}); // });
this.download$ = this.downloadService.activeDownloads$.pipe(takeUntilDestroyed(this.destroyRef), map((events) => { this.download$ = this.downloadService.activeDownloads$.pipe(takeUntilDestroyed(this.destroyRef), map((events) => {
return this.downloadService.mapToEntityType(events, this.series); return this.downloadService.mapToEntityType(events, this.series);
})); }));
this.actions = [...this.actionFactoryService.getSeriesActions((action: ActionItem<Series>, series: Series) => this.handleSeriesActionCallback(action, series))]; this.actions = [...this.actionFactoryService.getSeriesActions(this.handleSeriesActionCallback.bind(this))];
if (this.isOnDeck) { if (this.isOnDeck) {
const othersIndex = this.actions.findIndex(obj => obj.title === 'others'); const othersIndex = this.actions.findIndex(obj => obj.title === 'others');
const othersAction = deepClone(this.actions[othersIndex]) as ActionItem<Series>; const othersAction = deepClone(this.actions[othersIndex]) as ActionItem<Series>;
@ -209,9 +204,11 @@ export class SeriesCardComponent implements OnInit, OnChanges {
action: Action.RemoveFromOnDeck, action: Action.RemoveFromOnDeck,
title: 'remove-from-on-deck', title: 'remove-from-on-deck',
description: '', description: '',
callback: (action: ActionItem<Series>, series: Series) => this.handleSeriesActionCallback(action, series), callback: this.handleSeriesActionCallback.bind(this),
class: 'danger', class: 'danger',
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
shouldRender: (_, _2, _3) => true,
children: [], children: [],
}); });
this.actions[othersIndex] = othersAction; this.actions[othersIndex] = othersAction;

View file

@ -81,7 +81,7 @@
@if (actions && actions.length > 0) { @if (actions && actions.length > 0) {
<span class="card-actions"> <span class="card-actions">
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="volume.name"></app-card-actionables> <app-card-actionables [entity]="volume" [inputActions]="actions" [labelBy]="volume.name"></app-card-actionables>
</span> </span>
} }
</div> </div>

View file

@ -23,7 +23,7 @@ import {DownloadEvent, DownloadService} from "../../shared/_services/download.se
import {EVENTS, MessageHubService} from "../../_services/message-hub.service"; import {EVENTS, MessageHubService} from "../../_services/message-hub.service";
import {AccountService} from "../../_services/account.service"; import {AccountService} from "../../_services/account.service";
import {ScrollService} from "../../_services/scroll.service"; import {ScrollService} from "../../_services/scroll.service";
import {Action, ActionItem} from "../../_services/action-factory.service"; import {ActionItem} from "../../_services/action-factory.service";
import {ReaderService} from "../../_services/reader.service"; import {ReaderService} from "../../_services/reader.service";
import {Observable} from "rxjs"; import {Observable} from "rxjs";
import {User} from "../../_models/user"; import {User} from "../../_models/user";
@ -33,7 +33,6 @@ import {UserProgressUpdateEvent} from "../../_models/events/user-progress-update
import {Volume} from "../../_models/volume"; import {Volume} from "../../_models/volume";
import {UtilityService} from "../../shared/_services/utility.service"; import {UtilityService} from "../../shared/_services/utility.service";
import {LibraryType} from "../../_models/library/library"; import {LibraryType} from "../../_models/library/library";
import {Device} from "../../_models/device/device";
import {ActionService} from "../../_services/action.service"; import {ActionService} from "../../_services/action.service";
import {FormsModule} from "@angular/forms"; import {FormsModule} from "@angular/forms";
@ -143,8 +142,6 @@ export class VolumeCardComponent implements OnInit {
} }
ngOnInit() { ngOnInit() {
this.filterSendTo();
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => { this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
this.user = user; this.user = user;
}); });
@ -180,30 +177,6 @@ export class VolumeCardComponent implements OnInit {
this.cdRef.detectChanges(); this.cdRef.detectChanges();
} }
filterSendTo() {
if (!this.actions || this.actions.length === 0) return;
// TODO: See if we can handle send to for volumes
//this.actions = this.actionFactoryService.filterSendToAction(this.actions, this.volume);
}
performAction(action: ActionItem<Volume>) {
if (action.action == Action.Download) {
this.downloadService.download('volume', this.volume);
return; // Don't propagate the download from a card
}
if (action.action == Action.SendTo) {
const device = (action._extra!.data as Device);
this.actionService.sendToDevice(this.volume.chapters.map(c => c.id), device);
return;
}
if (typeof action.callback === 'function') {
action.callback(action, this.volume);
}
}
handleClick(event: any) { handleClick(event: any) {
if (this.bulkSelectionService.hasSelections()) { if (this.bulkSelectionService.hasSelections()) {
this.handleSelection(event); this.handleSelection(event);

View file

@ -4,7 +4,7 @@
<div class="carousel-container mb-3"> <div class="carousel-container mb-3">
<div> <div>
@if (actionables.length > 0) { @if (actionables.length > 0) {
<app-card-actionables [actions]="actionables" (actionHandler)="performAction($event)"></app-card-actionables> <app-card-actionables [inputActions]="actionables" (actionHandler)="performAction($event)"></app-card-actionables>
} }
<h4 class="header" (click)="sectionClicked($event)" [ngClass]="{'non-selectable': !clickableTitle}"> <h4 class="header" (click)="sectionClicked($event)" [ngClass]="{'non-selectable': !clickableTitle}">
@if (titleLink !== '') { @if (titleLink !== '') {

View file

@ -74,7 +74,7 @@
<div class="col-auto ms-2k"> <div class="col-auto ms-2k">
<div class="card-actions" [ngbTooltip]="t('more-alt')"> <div class="card-actions" [ngbTooltip]="t('more-alt')">
<app-card-actionables (actionHandler)="performAction($event)" [actions]="chapterActions" [labelBy]="series.name + ' ' + chapter.minNumber" iconClass="fa-ellipsis-h" btnClass="btn-actions btn"></app-card-actionables> <app-card-actionables [entity]="chapter" [inputActions]="chapterActions" [labelBy]="series.name + ' ' + chapter.minNumber" iconClass="fa-ellipsis-h" btnClass="btn-actions btn"></app-card-actionables>
</div> </div>
</div> </div>

View file

@ -83,7 +83,7 @@ enum TabID {
} }
@Component({ @Component({
selector: 'app-chapter-detail', selector: 'app-chapter-detail',
imports: [ imports: [
AsyncPipe, AsyncPipe,
CardActionablesComponent, CardActionablesComponent,
@ -116,9 +116,9 @@ enum TabID {
ReviewsComponent, ReviewsComponent,
ExternalRatingComponent ExternalRatingComponent
], ],
templateUrl: './chapter-detail.component.html', templateUrl: './chapter-detail.component.html',
styleUrl: './chapter-detail.component.scss', styleUrl: './chapter-detail.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class ChapterDetailComponent implements OnInit { export class ChapterDetailComponent implements OnInit {
@ -339,10 +339,6 @@ export class ChapterDetailComponent implements OnInit {
this.location.replaceState(newUrl) this.location.replaceState(newUrl)
} }
openPerson(field: FilterField, value: number) {
this.filterUtilityService.applyFilter(['all-series'], field, FilterComparison.Equal, `${value}`).subscribe();
}
downloadChapter() { downloadChapter() {
if (this.downloadInProgress) return; if (this.downloadInProgress) return;
this.downloadService.download('chapter', this.chapter!, (d) => { this.downloadService.download('chapter', this.chapter!, (d) => {
@ -360,11 +356,6 @@ export class ChapterDetailComponent implements OnInit {
this.cdRef.markForCheck(); this.cdRef.markForCheck();
} }
performAction(action: ActionItem<Chapter>) {
if (typeof action.callback === 'function') {
action.callback(action, this.chapter!);
}
}
handleChapterActionCallback(action: ActionItem<Chapter>, chapter: Chapter) { handleChapterActionCallback(action: ActionItem<Chapter>, chapter: Chapter) {
switch (action.action) { switch (action.action) {

View file

@ -6,11 +6,9 @@
<ng-container title> <ng-container title>
@if (collectionTag) { @if (collectionTag) {
<h4> <h4>
{{collectionTag.title}} <app-promoted-icon [promoted]="collectionTag.promoted"></app-promoted-icon>
@if(collectionTag.promoted) { <span class="ms-2">{{collectionTag.title}}</span>
<span class="ms-1">(<i aria-hidden="true" class="fa fa-angle-double-up"></i>)</span> <app-card-actionables [entity]="collectionTag" [disabled]="actionInProgress" [inputActions]="collectionTagActions" [labelBy]="collectionTag.title" iconClass="fa-ellipsis-v"></app-card-actionables>
}
<app-card-actionables [disabled]="actionInProgress" (actionHandler)="performAction($event)" [actions]="collectionTagActions" [labelBy]="collectionTag.title" iconClass="fa-ellipsis-v"></app-card-actionables>
</h4> </h4>
} }
<h5 subtitle class="subtitle-with-actionables">{{t('item-count', {num: series.length})}}</h5> <h5 subtitle class="subtitle-with-actionables">{{t('item-count', {num: series.length})}}</h5>

View file

@ -61,6 +61,7 @@ import {
} from "../../../_single-module/smart-collection-drawer/smart-collection-drawer.component"; } from "../../../_single-module/smart-collection-drawer/smart-collection-drawer.component";
import {DefaultModalOptions} from "../../../_models/default-modal-options"; import {DefaultModalOptions} from "../../../_models/default-modal-options";
import {ScrobbleProviderNamePipe} from "../../../_pipes/scrobble-provider-name.pipe"; import {ScrobbleProviderNamePipe} from "../../../_pipes/scrobble-provider-name.pipe";
import {PromotedIconComponent} from "../../../shared/_components/promoted-icon/promoted-icon.component";
@Component({ @Component({
selector: 'app-collection-detail', selector: 'app-collection-detail',
@ -69,7 +70,7 @@ import {ScrobbleProviderNamePipe} from "../../../_pipes/scrobble-provider-name.p
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [SideNavCompanionBarComponent, CardActionablesComponent, ImageComponent, ReadMoreComponent, imports: [SideNavCompanionBarComponent, CardActionablesComponent, ImageComponent, ReadMoreComponent,
BulkOperationsComponent, CardDetailLayoutComponent, SeriesCardComponent, TranslocoDirective, NgbTooltip, BulkOperationsComponent, CardDetailLayoutComponent, SeriesCardComponent, TranslocoDirective, NgbTooltip,
DatePipe, DefaultDatePipe, ProviderImagePipe, AsyncPipe, ScrobbleProviderNamePipe] DatePipe, DefaultDatePipe, ProviderImagePipe, AsyncPipe, ScrobbleProviderNamePipe, PromotedIconComponent]
}) })
export class CollectionDetailComponent implements OnInit, AfterContentChecked { export class CollectionDetailComponent implements OnInit, AfterContentChecked {
@ -304,12 +305,6 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
} }
} }
performAction(action: ActionItem<any>) {
if (typeof action.callback === 'function') {
action.callback(action, this.collectionTag);
}
}
openEditCollectionTagModal(collectionTag: UserCollection) { openEditCollectionTagModal(collectionTag: UserCollection) {
const modalRef = this.modalService.open(EditCollectionTagsComponent, DefaultModalOptions); const modalRef = this.modalService.open(EditCollectionTagsComponent, DefaultModalOptions);
modalRef.componentInstance.tag = this.collectionTag; modalRef.componentInstance.tag = this.collectionTag;
@ -320,7 +315,6 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
} }
openSyncDetailDrawer() { openSyncDetailDrawer() {
const ref = this.offcanvasService.open(SmartCollectionDrawerComponent, {position: 'end', panelClass: ''}); const ref = this.offcanvasService.open(SmartCollectionDrawerComponent, {position: 'end', panelClass: ''});
ref.componentInstance.collection = this.collectionTag; ref.componentInstance.collection = this.collectionTag;
ref.componentInstance.series = this.series; ref.componentInstance.series = this.series;

View file

@ -3,7 +3,7 @@
<app-side-nav-companion-bar [hasFilter]="true" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive"> <app-side-nav-companion-bar [hasFilter]="true" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive">
<h4 title> <h4 title>
<span>{{libraryName}}</span> <span>{{libraryName}}</span>
<app-card-actionables [actions]="actions" (actionHandler)="performAction($event)"></app-card-actionables> <app-card-actionables [inputActions]="actions" (actionHandler)="performAction($event)"></app-card-actionables>
</h4> </h4>
@if (active.fragment === '') { @if (active.fragment === '') {
<h5 subtitle class="subtitle-with-actionables">{{t('common.series-count', {num: pagination.totalItems | number})}} </h5> <h5 subtitle class="subtitle-with-actionables">{{t('common.series-count', {num: pagination.totalItems | number})}} </h5>
@ -31,7 +31,6 @@
</ng-template> </ng-template>
<ng-template #noData> <ng-template #noData>
<!-- TODO: Come back and figure this out -->
{{t('common.no-data')}} {{t('common.no-data')}}
</ng-template> </ng-template>
</app-card-detail-layout> </app-card-detail-layout>

View file

@ -297,8 +297,6 @@ export class LibraryDetailComponent implements OnInit {
} }
} }
performAction(action: ActionItem<any>) { performAction(action: ActionItem<any>) {
if (typeof action.callback === 'function') { if (typeof action.callback === 'function') {
action.callback(action, undefined); action.callback(action, undefined);

View file

@ -15,6 +15,10 @@
} @else { } @else {
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="resetField()"></button> <button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="resetField()"></button>
} }
} @else {
<div class="input-hint">
Ctrl+K
</div>
} }
</div> </div>
</div> </div>

View file

@ -9,6 +9,17 @@
right: 5px; right: 5px;
} }
.input-hint {
font-size: 0.8rem;
margin-top: 3px;
margin-bottom: 3px;
margin-right: 9px;
border: 1px solid var(--input-hint-border-color, lightgrey);
color: var(--input-hint-text-color);
border-radius: 4px;
padding: 2px;
}
.typeahead-input { .typeahead-input {
border: 1px solid transparent; border: 1px solid transparent;

View file

@ -100,7 +100,9 @@ export class GroupedTypeaheadComponent implements OnInit {
hasFocus: boolean = false; hasFocus: boolean = false;
typeaheadForm: FormGroup = new FormGroup({}); typeaheadForm: FormGroup = new FormGroup({
typeahead: new FormControl('', []),
});
includeChapterAndFiles: boolean = false; includeChapterAndFiles: boolean = false;
prevSearchTerm: string = ''; prevSearchTerm: string = '';
searchSettingsForm = new FormGroup(({'includeExtras': new FormControl(false)})); searchSettingsForm = new FormGroup(({'includeExtras': new FormControl(false)}));
@ -121,22 +123,37 @@ export class GroupedTypeaheadComponent implements OnInit {
this.close(); this.close();
} }
@HostListener('window:keydown', ['$event']) @HostListener('document:keydown', ['$event'])
handleKeyPress(event: KeyboardEvent) { handleKeyPress(event: KeyboardEvent) {
if (!this.hasFocus) { return; }
const isCtrlOrMeta = event.ctrlKey || event.metaKey;
switch(event.key) { switch(event.key) {
case KEY_CODES.ESC_KEY: case KEY_CODES.ESC_KEY:
if (!this.hasFocus) { return; }
this.close(); this.close();
event.stopPropagation(); event.stopPropagation();
break; break;
case KEY_CODES.K:
if (isCtrlOrMeta) {
if (this.inputElem.nativeElement) {
event.preventDefault();
event.stopPropagation();
this.inputElem.nativeElement.focus();
this.inputElem.nativeElement.click();
}
}
break;
default: default:
break; break;
} }
} }
ngOnInit(): void { ngOnInit(): void {
this.typeaheadForm.addControl('typeahead', new FormControl(this.initialValue, [])); this.typeaheadForm.get('typeahead')?.setValue(this.initialValue);
this.cdRef.markForCheck(); this.cdRef.markForCheck();
this.searchSettingsForm.get('includeExtras')!.valueChanges.pipe( this.searchSettingsForm.get('includeExtras')!.valueChanges.pipe(

View file

@ -118,7 +118,14 @@
width="24px" [imageUrl]="imageService.getPersonImage(item.id)" [errorImage]="imageService.noPersonImage"></app-image> width="24px" [imageUrl]="imageService.getPersonImage(item.id)" [errorImage]="imageService.noPersonImage"></app-image>
</div> </div>
<div class="ms-1"> <div class="ms-1">
<div>{{item.name}}</div> <div>
{{item.name}}
</div>
@if (item.aliases.length > 0) {
<span class="small-text">
{{t('person-aka-status')}}
</span>
}
</div> </div>
</div> </div>
</ng-template> </ng-template>

Some files were not shown because too many files have changed in this diff Show more