mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
People Aliases and Merging (#3795)
Co-authored-by: Joseph Milazzo <josephmajora@gmail.com>
This commit is contained in:
parent
cd2a6af6f2
commit
7ce36bfc44
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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]
|
||||||
|
286
API.Tests/Services/PersonServiceTests.cs
Normal file
286
API.Tests/Services/PersonServiceTests.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
@ -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()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
@ -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());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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"));
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
@ -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
|
||||||
|
@ -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>
|
||||||
|
17
API/DTOs/Person/PersonMergeDto.cs
Normal file
17
API/DTOs/Person/PersonMergeDto.cs
Normal 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; }
|
||||||
|
}
|
@ -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; }
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using API.DTOs.Person;
|
||||||
|
|
||||||
namespace API.DTOs.ReadingLists;
|
namespace API.DTOs.ReadingLists;
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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!;
|
||||||
|
3571
API/Data/Migrations/20250507221026_PersonAliases.Designer.cs
generated
Normal file
3571
API/Data/Migrations/20250507221026_PersonAliases.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
47
API/Data/Migrations/20250507221026_PersonAliases.cs
Normal file
47
API/Data/Migrations/20250507221026_PersonAliases.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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");
|
||||||
|
@ -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)
|
||||||
|
@ -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;
|
||||||
|
@ -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)
|
||||||
|
@ -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()
|
||||||
|
11
API/Entities/Person/PersonAlias.cs
Normal file
11
API/Entities/Person/PersonAlias.cs
Normal 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; }
|
||||||
|
}
|
@ -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>();
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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>();
|
||||||
|
19
API/Helpers/Builders/PersonAliasBuilder.cs
Normal file
19
API/Helpers/Builders/PersonAliasBuilder.cs
Normal 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
|
@ -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);
|
||||||
|
@ -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}",
|
||||||
|
147
API/Services/PersonService.cs
Normal file
147
API/Services/PersonService.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
@ -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)
|
||||||
{
|
{
|
||||||
|
@ -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>();
|
||||||
|
@ -579,7 +579,7 @@ public class CoverDbService : ICoverDbService
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
_directoryService.DeleteFiles([tempFullPath]);
|
_directoryService.DeleteFiles([tempFullPath]);
|
||||||
series.CoverImage = Path.GetFileName(existingPath);
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -13,7 +13,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
.subtitle {
|
||||||
color: lightgrey;
|
color: var(--detail-subtitle-color);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
@ -116,7 +116,11 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -819,6 +823,14 @@ export class ActionFactoryService {
|
|||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
requiresAdmin: true,
|
requiresAdmin: true,
|
||||||
children: [],
|
children: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: Action.Merge,
|
||||||
|
title: 'merge',
|
||||||
|
description: 'merge-person-tooltip',
|
||||||
|
callback: this.dummyCallback,
|
||||||
|
requiresAdmin: true,
|
||||||
|
children: [],
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -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() {
|
||||||
|
@ -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});
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -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 + '');
|
||||||
|
@ -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 + '');
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
@ -138,3 +138,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.small-text {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
@ -96,6 +96,19 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<li [ngbNavItem]="TabID.Aliases">
|
||||||
|
<a ngbNavLink>{{t(TabID.Aliases)}}</a>
|
||||||
|
<ng-template ngbNavContent>
|
||||||
|
<h5>{{t('aliases-label')}}</h5>
|
||||||
|
<div class="text-muted mb-2">{{t('aliases-tooltip')}}</div>
|
||||||
|
<app-edit-list [items]="person.aliases"
|
||||||
|
[asyncValidators]="[aliasValidator()]"
|
||||||
|
(updateItems)="person.aliases = $event"
|
||||||
|
[errorMessage]="t('alias-overlap')"
|
||||||
|
[label]="t('aliases-label')"/>
|
||||||
|
</ng-template>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
|
||||||
<li [ngbNavItem]="TabID.CoverImage">
|
<li [ngbNavItem]="TabID.CoverImage">
|
||||||
<a ngbNavLink>{{t(TabID.CoverImage)}}</a>
|
<a ngbNavLink>{{t(TabID.CoverImage)}}</a>
|
||||||
|
@ -1,6 +1,14 @@
|
|||||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core';
|
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core';
|
||||||
import {Breakpoint, UtilityService} from "../../../shared/_services/utility.service";
|
import {Breakpoint, UtilityService} from "../../../shared/_services/utility.service";
|
||||||
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
|
import {
|
||||||
|
AbstractControl,
|
||||||
|
AsyncValidatorFn,
|
||||||
|
FormControl,
|
||||||
|
FormGroup,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
ValidationErrors,
|
||||||
|
Validators
|
||||||
|
} from "@angular/forms";
|
||||||
import {Person} from "../../../_models/metadata/person";
|
import {Person} from "../../../_models/metadata/person";
|
||||||
import {
|
import {
|
||||||
NgbActiveModal,
|
NgbActiveModal,
|
||||||
@ -14,14 +22,16 @@ import {
|
|||||||
import {PersonService} from "../../../_services/person.service";
|
import {PersonService} from "../../../_services/person.service";
|
||||||
import {translate, TranslocoDirective} from '@jsverse/transloco';
|
import {translate, TranslocoDirective} from '@jsverse/transloco';
|
||||||
import {CoverImageChooserComponent} from "../../../cards/cover-image-chooser/cover-image-chooser.component";
|
import {CoverImageChooserComponent} from "../../../cards/cover-image-chooser/cover-image-chooser.component";
|
||||||
import {forkJoin} from "rxjs";
|
import {forkJoin, map, of} from "rxjs";
|
||||||
import {UploadService} from "../../../_services/upload.service";
|
import {UploadService} from "../../../_services/upload.service";
|
||||||
import {SettingItemComponent} from "../../../settings/_components/setting-item/setting-item.component";
|
import {SettingItemComponent} from "../../../settings/_components/setting-item/setting-item.component";
|
||||||
import {AccountService} from "../../../_services/account.service";
|
import {AccountService} from "../../../_services/account.service";
|
||||||
import {ToastrService} from "ngx-toastr";
|
import {ToastrService} from "ngx-toastr";
|
||||||
|
import {EditListComponent} from "../../../shared/edit-list/edit-list.component";
|
||||||
|
|
||||||
enum TabID {
|
enum TabID {
|
||||||
General = 'general-tab',
|
General = 'general-tab',
|
||||||
|
Aliases = 'aliases-tab',
|
||||||
CoverImage = 'cover-image-tab',
|
CoverImage = 'cover-image-tab',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -37,7 +47,8 @@ enum TabID {
|
|||||||
NgbNavOutlet,
|
NgbNavOutlet,
|
||||||
CoverImageChooserComponent,
|
CoverImageChooserComponent,
|
||||||
SettingItemComponent,
|
SettingItemComponent,
|
||||||
NgbNavLink
|
NgbNavLink,
|
||||||
|
EditListComponent
|
||||||
],
|
],
|
||||||
templateUrl: './edit-person-modal.component.html',
|
templateUrl: './edit-person-modal.component.html',
|
||||||
styleUrl: './edit-person-modal.component.scss',
|
styleUrl: './edit-person-modal.component.scss',
|
||||||
@ -117,6 +128,7 @@ export class EditPersonModalComponent implements OnInit {
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
malId: this.editForm.get('malId')!.value === '' ? null : parseInt(this.editForm.get('malId').value, 10),
|
malId: this.editForm.get('malId')!.value === '' ? null : parseInt(this.editForm.get('malId').value, 10),
|
||||||
hardcoverId: this.editForm.get('hardcoverId')!.value || '',
|
hardcoverId: this.editForm.get('hardcoverId')!.value || '',
|
||||||
|
aliases: this.person.aliases,
|
||||||
};
|
};
|
||||||
apis.push(this.personService.updatePerson(person));
|
apis.push(this.personService.updatePerson(person));
|
||||||
|
|
||||||
@ -165,4 +177,21 @@ export class EditPersonModalComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
aliasValidator(): AsyncValidatorFn {
|
||||||
|
return (control: AbstractControl) => {
|
||||||
|
const name = control.value;
|
||||||
|
if (!name || name.trim().length === 0) {
|
||||||
|
return of(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.personService.isValidAlias(this.person.id, name).pipe(map(valid => {
|
||||||
|
if (valid) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { 'invalidAlias': {'alias': name} } as ValidationErrors;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,65 @@
|
|||||||
|
<ng-container *transloco="let t; prefix:'merge-person-modal'">
|
||||||
|
<div class="modal-container">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h4 class="modal-title">
|
||||||
|
{{t('title', {personName: this.person.name})}}</h4>
|
||||||
|
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body scrollable-modal d-flex flex-column" style="min-height: 300px;" >
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-12 col-md-12 pe-2">
|
||||||
|
<div class="mb-3">
|
||||||
|
|
||||||
|
<app-setting-item [title]="t('src')" [subtitle]="t('merge-warning')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||||
|
<ng-template #view>
|
||||||
|
<app-typeahead [settings]="typeAheadSettings" (selectedData)="updatePerson($event)" [unFocus]="typeAheadUnfocus">
|
||||||
|
<ng-template #badgeItem let-item let-position="idx">
|
||||||
|
{{item.name}}
|
||||||
|
</ng-template>
|
||||||
|
<ng-template #optionItem let-item let-position="idx">
|
||||||
|
{{item.name}}
|
||||||
|
</ng-template>
|
||||||
|
</app-typeahead>
|
||||||
|
</ng-template>
|
||||||
|
</app-setting-item>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (mergee) {
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-12 col-md-12 pe-2">
|
||||||
|
<div class="mb-3">
|
||||||
|
|
||||||
|
<h5>{{t('alias-title')}}</h5>
|
||||||
|
|
||||||
|
<app-badge-expander [items]="allNewAliases()">
|
||||||
|
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
|
||||||
|
{{item}}
|
||||||
|
</ng-template>
|
||||||
|
</app-badge-expander>
|
||||||
|
|
||||||
|
@if (knownFor$ | async; as knownFor) {
|
||||||
|
<h5 class="mt-2">{{t('known-for-title')}}</h5>
|
||||||
|
|
||||||
|
<app-badge-expander [items]="knownFor">
|
||||||
|
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
|
||||||
|
{{item.name}}
|
||||||
|
</ng-template>
|
||||||
|
</app-badge-expander>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" (click)="close()">{{t('close')}}</button>
|
||||||
|
<button type="submit" class="btn btn-primary" (click)="save()" [disabled]="mergee === null" >{{t('save')}}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</ng-container>
|
@ -0,0 +1,101 @@
|
|||||||
|
import {Component, DestroyRef, EventEmitter, inject, Input, OnInit} from '@angular/core';
|
||||||
|
import {Person} from "../../../_models/metadata/person";
|
||||||
|
import {PersonService} from "../../../_services/person.service";
|
||||||
|
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
|
||||||
|
import {ToastrService} from "ngx-toastr";
|
||||||
|
import {TranslocoDirective} from "@jsverse/transloco";
|
||||||
|
import {TypeaheadComponent} from "../../../typeahead/_components/typeahead.component";
|
||||||
|
import {TypeaheadSettings} from "../../../typeahead/_models/typeahead-settings";
|
||||||
|
import {map} from "rxjs/operators";
|
||||||
|
import {UtilityService} from "../../../shared/_services/utility.service";
|
||||||
|
import {SettingItemComponent} from "../../../settings/_components/setting-item/setting-item.component";
|
||||||
|
import {BadgeExpanderComponent} from "../../../shared/badge-expander/badge-expander.component";
|
||||||
|
import {FilterField} from "../../../_models/metadata/v2/filter-field";
|
||||||
|
import {Observable, of} from "rxjs";
|
||||||
|
import {Series} from "../../../_models/series";
|
||||||
|
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||||
|
import {AsyncPipe} from "@angular/common";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-merge-person-modal',
|
||||||
|
imports: [
|
||||||
|
TranslocoDirective,
|
||||||
|
TypeaheadComponent,
|
||||||
|
SettingItemComponent,
|
||||||
|
BadgeExpanderComponent,
|
||||||
|
AsyncPipe
|
||||||
|
],
|
||||||
|
templateUrl: './merge-person-modal.component.html',
|
||||||
|
styleUrl: './merge-person-modal.component.scss'
|
||||||
|
})
|
||||||
|
export class MergePersonModalComponent implements OnInit {
|
||||||
|
|
||||||
|
private readonly personService = inject(PersonService);
|
||||||
|
public readonly utilityService = inject(UtilityService);
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
private readonly modal = inject(NgbActiveModal);
|
||||||
|
protected readonly toastr = inject(ToastrService);
|
||||||
|
|
||||||
|
typeAheadSettings!: TypeaheadSettings<Person>;
|
||||||
|
typeAheadUnfocus = new EventEmitter<string>();
|
||||||
|
|
||||||
|
@Input({required: true}) person!: Person;
|
||||||
|
|
||||||
|
mergee: Person | null = null;
|
||||||
|
knownFor$: Observable<Series[]> | null = null;
|
||||||
|
|
||||||
|
save() {
|
||||||
|
if (!this.mergee) {
|
||||||
|
this.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.personService.mergePerson(this.person.id, this.mergee.id).subscribe(person => {
|
||||||
|
this.modal.close({success: true, person: person});
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.modal.close({success: false, person: this.person});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.typeAheadSettings = new TypeaheadSettings<Person>();
|
||||||
|
this.typeAheadSettings.minCharacters = 0;
|
||||||
|
this.typeAheadSettings.multiple = false;
|
||||||
|
this.typeAheadSettings.addIfNonExisting = false;
|
||||||
|
this.typeAheadSettings.id = "merge-person-modal-typeahead";
|
||||||
|
this.typeAheadSettings.compareFn = (options: Person[], filter: string) => {
|
||||||
|
return options.filter(m => this.utilityService.filter(m.name, filter));
|
||||||
|
}
|
||||||
|
this.typeAheadSettings.selectionCompareFn = (a: Person, b: Person) => {
|
||||||
|
return a.name == b.name;
|
||||||
|
}
|
||||||
|
this.typeAheadSettings.fetchFn = (filter: string) => {
|
||||||
|
if (filter.length == 0) return of([]);
|
||||||
|
|
||||||
|
return this.personService.searchPerson(filter).pipe(map(people => {
|
||||||
|
return people.filter(p => this.utilityService.filter(p.name, filter) && p.id != this.person.id);
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
this.typeAheadSettings.trackByIdentityFn = (index, value) => `${value.name}_${value.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePerson(people: Person[]) {
|
||||||
|
if (people.length == 0) return;
|
||||||
|
|
||||||
|
this.typeAheadUnfocus.emit(this.typeAheadSettings.id);
|
||||||
|
this.mergee = people[0];
|
||||||
|
this.knownFor$ = this.personService.getSeriesMostKnownFor(this.mergee.id)
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected readonly FilterField = FilterField;
|
||||||
|
|
||||||
|
allNewAliases() {
|
||||||
|
if (!this.mergee) return [];
|
||||||
|
|
||||||
|
return [this.mergee.name, ...this.mergee.aliases]
|
||||||
|
}
|
||||||
|
}
|
@ -43,15 +43,43 @@
|
|||||||
|
|
||||||
<div class="col-xl-10 col-lg-7 col-md-12 col-xs-12 col-sm-12 mt-2">
|
<div class="col-xl-10 col-lg-7 col-md-12 col-xs-12 col-sm-12 mt-2">
|
||||||
<div class="row g-0 mt-2">
|
<div class="row g-0 mt-2">
|
||||||
<app-read-more [text]="person.description || defaultSummaryText"></app-read-more>
|
<app-read-more [maxLength]="500" [text]="person.description || t('no-info')"></app-read-more>
|
||||||
|
|
||||||
|
|
||||||
|
@if (person.aliases.length > 0) {
|
||||||
|
<span class="fw-bold mt-2">{{t('aka-title')}}</span>
|
||||||
|
<div>
|
||||||
|
<app-badge-expander [items]="person.aliases"
|
||||||
|
[itemsTillExpander]="6">
|
||||||
|
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
|
||||||
|
<span>{{item}}</span>
|
||||||
|
</ng-template>
|
||||||
|
</app-badge-expander>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@if (roles$ | async; as roles) {
|
@if (roles$ | async; as roles) {
|
||||||
<div class="mt-1">
|
@if (roles.length > 0) {
|
||||||
<h5>{{t('all-roles')}}</h5>
|
<span class="fw-bold mt-2">{{t('all-roles')}}</span>
|
||||||
@for(role of roles; track role) {
|
<div>
|
||||||
<app-tag-badge [selectionMode]="TagBadgeCursor.Clickable" (click)="loadFilterByRole(role)">{{role | personRole}}</app-tag-badge>
|
<app-badge-expander [items]="roles"
|
||||||
}
|
[itemsTillExpander]="6">
|
||||||
</div>
|
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
|
||||||
|
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="loadFilterByRole(item)">{{item | personRole}}</a>
|
||||||
|
</ng-template>
|
||||||
|
</app-badge-expander>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
<!-- -->
|
||||||
|
<!-- <div class="mt-1">-->
|
||||||
|
<!-- <h5>{{t('all-roles')}}</h5>-->
|
||||||
|
<!-- @for(role of roles; track role) {-->
|
||||||
|
<!-- <app-tag-badge [selectionMode]="TagBadgeCursor.Clickable" (click)="loadFilterByRole(role)">{{role | personRole}}</app-tag-badge>-->
|
||||||
|
<!-- }-->
|
||||||
|
<!-- </div>-->
|
||||||
}
|
}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,31 +1,31 @@
|
|||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
ChangeDetectorRef,
|
ChangeDetectorRef,
|
||||||
Component, DestroyRef,
|
Component,
|
||||||
|
DestroyRef,
|
||||||
ElementRef,
|
ElementRef,
|
||||||
Inject,
|
inject,
|
||||||
inject, OnInit,
|
OnInit,
|
||||||
ViewChild
|
ViewChild
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import {ActivatedRoute, Router} from "@angular/router";
|
import {ActivatedRoute, Router} from "@angular/router";
|
||||||
import {PersonService} from "../_services/person.service";
|
import {PersonService} from "../_services/person.service";
|
||||||
import {BehaviorSubject, EMPTY, Observable, switchMap, tap} from "rxjs";
|
import {BehaviorSubject, EMPTY, Observable, switchMap, tap} from "rxjs";
|
||||||
import {Person, PersonRole} from "../_models/metadata/person";
|
import {Person, PersonRole} from "../_models/metadata/person";
|
||||||
import {AsyncPipe, NgStyle} from "@angular/common";
|
import {AsyncPipe} from "@angular/common";
|
||||||
import {ImageComponent} from "../shared/image/image.component";
|
import {ImageComponent} from "../shared/image/image.component";
|
||||||
import {ImageService} from "../_services/image.service";
|
import {ImageService} from "../_services/image.service";
|
||||||
import {
|
import {
|
||||||
SideNavCompanionBarComponent
|
SideNavCompanionBarComponent
|
||||||
} from "../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component";
|
} from "../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component";
|
||||||
import {ReadMoreComponent} from "../shared/read-more/read-more.component";
|
import {ReadMoreComponent} from "../shared/read-more/read-more.component";
|
||||||
import {TagBadgeComponent, TagBadgeCursor} from "../shared/tag-badge/tag-badge.component";
|
import {TagBadgeCursor} from "../shared/tag-badge/tag-badge.component";
|
||||||
import {PersonRolePipe} from "../_pipes/person-role.pipe";
|
import {PersonRolePipe} from "../_pipes/person-role.pipe";
|
||||||
import {CarouselReelComponent} from "../carousel/_components/carousel-reel/carousel-reel.component";
|
import {CarouselReelComponent} from "../carousel/_components/carousel-reel/carousel-reel.component";
|
||||||
import {SeriesCardComponent} from "../cards/series-card/series-card.component";
|
|
||||||
import {FilterComparison} from "../_models/metadata/v2/filter-comparison";
|
import {FilterComparison} from "../_models/metadata/v2/filter-comparison";
|
||||||
import {FilterUtilitiesService} from "../shared/_services/filter-utilities.service";
|
import {FilterUtilitiesService} from "../shared/_services/filter-utilities.service";
|
||||||
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
|
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
|
||||||
import {allPeople, personRoleForFilterField} from "../_models/metadata/v2/filter-field";
|
import {allPeople, FilterField, personRoleForFilterField} from "../_models/metadata/v2/filter-field";
|
||||||
import {Series} from "../_models/series";
|
import {Series} from "../_models/series";
|
||||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||||
import {FilterCombination} from "../_models/metadata/v2/filter-combination";
|
import {FilterCombination} from "../_models/metadata/v2/filter-combination";
|
||||||
@ -42,28 +42,38 @@ import {DefaultModalOptions} from "../_models/default-modal-options";
|
|||||||
import {ToastrService} from "ngx-toastr";
|
import {ToastrService} from "ngx-toastr";
|
||||||
import {LicenseService} from "../_services/license.service";
|
import {LicenseService} from "../_services/license.service";
|
||||||
import {SafeUrlPipe} from "../_pipes/safe-url.pipe";
|
import {SafeUrlPipe} from "../_pipes/safe-url.pipe";
|
||||||
|
import {MergePersonModalComponent} from "./_modal/merge-person-modal/merge-person-modal.component";
|
||||||
|
import {EVENTS, MessageHubService} from "../_services/message-hub.service";
|
||||||
|
import {BadgeExpanderComponent} from "../shared/badge-expander/badge-expander.component";
|
||||||
|
|
||||||
|
interface PersonMergeEvent {
|
||||||
|
srcId: number,
|
||||||
|
dstId: number,
|
||||||
|
dstName: number,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-person-detail',
|
selector: 'app-person-detail',
|
||||||
imports: [
|
imports: [
|
||||||
AsyncPipe,
|
AsyncPipe,
|
||||||
ImageComponent,
|
ImageComponent,
|
||||||
SideNavCompanionBarComponent,
|
SideNavCompanionBarComponent,
|
||||||
ReadMoreComponent,
|
ReadMoreComponent,
|
||||||
TagBadgeComponent,
|
PersonRolePipe,
|
||||||
PersonRolePipe,
|
CarouselReelComponent,
|
||||||
CarouselReelComponent,
|
CardItemComponent,
|
||||||
CardItemComponent,
|
CardActionablesComponent,
|
||||||
CardActionablesComponent,
|
TranslocoDirective,
|
||||||
TranslocoDirective,
|
ChapterCardComponent,
|
||||||
ChapterCardComponent,
|
SafeUrlPipe,
|
||||||
SafeUrlPipe
|
BadgeExpanderComponent
|
||||||
],
|
],
|
||||||
templateUrl: './person-detail.component.html',
|
templateUrl: './person-detail.component.html',
|
||||||
styleUrl: './person-detail.component.scss',
|
styleUrl: './person-detail.component.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class PersonDetailComponent {
|
export class PersonDetailComponent implements OnInit {
|
||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly filterUtilityService = inject(FilterUtilitiesService);
|
private readonly filterUtilityService = inject(FilterUtilitiesService);
|
||||||
@ -77,6 +87,7 @@ export class PersonDetailComponent {
|
|||||||
protected readonly licenseService = inject(LicenseService);
|
protected readonly licenseService = inject(LicenseService);
|
||||||
private readonly themeService = inject(ThemeService);
|
private readonly themeService = inject(ThemeService);
|
||||||
private readonly toastr = inject(ToastrService);
|
private readonly toastr = inject(ToastrService);
|
||||||
|
private readonly messageHubService = inject(MessageHubService)
|
||||||
|
|
||||||
protected readonly TagBadgeCursor = TagBadgeCursor;
|
protected readonly TagBadgeCursor = TagBadgeCursor;
|
||||||
|
|
||||||
@ -88,11 +99,11 @@ export class PersonDetailComponent {
|
|||||||
roles$: Observable<PersonRole[]> | null = null;
|
roles$: Observable<PersonRole[]> | null = null;
|
||||||
roles: PersonRole[] | null = null;
|
roles: PersonRole[] | null = null;
|
||||||
works$: Observable<Series[]> | null = null;
|
works$: Observable<Series[]> | null = null;
|
||||||
defaultSummaryText = 'No information about this Person';
|
|
||||||
filter: SeriesFilterV2 | null = null;
|
filter: SeriesFilterV2 | null = null;
|
||||||
personActions: Array<ActionItem<Person>> = this.actionService.getPersonActions(this.handleAction.bind(this));
|
personActions: Array<ActionItem<Person>> = this.actionService.getPersonActions(this.handleAction.bind(this));
|
||||||
chaptersByRole: any = {};
|
chaptersByRole: any = {};
|
||||||
anilistUrl: string = '';
|
anilistUrl: string = '';
|
||||||
|
|
||||||
private readonly personSubject = new BehaviorSubject<Person | null>(null);
|
private readonly personSubject = new BehaviorSubject<Person | null>(null);
|
||||||
protected readonly person$ = this.personSubject.asObservable().pipe(tap(p => {
|
protected readonly person$ = this.personSubject.asObservable().pipe(tap(p => {
|
||||||
if (p?.aniListId) {
|
if (p?.aniListId) {
|
||||||
@ -118,43 +129,58 @@ export class PersonDetailComponent {
|
|||||||
return this.personService.get(personName);
|
return this.personService.get(personName);
|
||||||
}),
|
}),
|
||||||
tap((person) => {
|
tap((person) => {
|
||||||
|
|
||||||
if (person == null) {
|
if (person == null) {
|
||||||
this.toastr.error(translate('toasts.unauthorized-1'));
|
this.toastr.error(translate('toasts.unauthorized-1'));
|
||||||
this.router.navigateByUrl('/home');
|
this.router.navigateByUrl('/home');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.person = person;
|
this.setPerson(person);
|
||||||
this.personSubject.next(person); // emit the person data for subscribers
|
|
||||||
this.themeService.setColorScape(person.primaryColor || '', person.secondaryColor);
|
|
||||||
|
|
||||||
// Fetch roles and process them
|
|
||||||
this.roles$ = this.personService.getRolesForPerson(this.person.id).pipe(
|
|
||||||
tap(roles => {
|
|
||||||
this.roles = roles;
|
|
||||||
this.filter = this.createFilter(roles);
|
|
||||||
this.chaptersByRole = {}; // Reset chaptersByRole for each person
|
|
||||||
|
|
||||||
// Populate chapters by role
|
|
||||||
roles.forEach(role => {
|
|
||||||
this.chaptersByRole[role] = this.personService.getChaptersByRole(person.id, role)
|
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef));
|
|
||||||
});
|
|
||||||
this.cdRef.markForCheck();
|
|
||||||
}),
|
|
||||||
takeUntilDestroyed(this.destroyRef)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Fetch series known for this person
|
|
||||||
this.works$ = this.personService.getSeriesMostKnownFor(person.id).pipe(
|
|
||||||
takeUntilDestroyed(this.destroyRef)
|
|
||||||
);
|
|
||||||
}),
|
}),
|
||||||
takeUntilDestroyed(this.destroyRef)
|
takeUntilDestroyed(this.destroyRef)
|
||||||
).subscribe();
|
).subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.messageHubService.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(message => {
|
||||||
|
if (message.event !== EVENTS.PersonMerged) return;
|
||||||
|
|
||||||
|
const event = message.payload as PersonMergeEvent;
|
||||||
|
if (event.srcId !== this.person?.id) return;
|
||||||
|
|
||||||
|
this.router.navigate(['person', event.dstName]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private setPerson(person: Person) {
|
||||||
|
this.person = person;
|
||||||
|
this.personSubject.next(person); // emit the person data for subscribers
|
||||||
|
this.themeService.setColorScape(person.primaryColor || '', person.secondaryColor);
|
||||||
|
|
||||||
|
// Fetch roles and process them
|
||||||
|
this.roles$ = this.personService.getRolesForPerson(this.person.id).pipe(
|
||||||
|
tap(roles => {
|
||||||
|
this.roles = roles;
|
||||||
|
this.filter = this.createFilter(roles);
|
||||||
|
this.chaptersByRole = {}; // Reset chaptersByRole for each person
|
||||||
|
|
||||||
|
// Populate chapters by role
|
||||||
|
roles.forEach(role => {
|
||||||
|
this.chaptersByRole[role] = this.personService.getChaptersByRole(person.id, role)
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef));
|
||||||
|
});
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
}),
|
||||||
|
takeUntilDestroyed(this.destroyRef)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch series known for this person
|
||||||
|
this.works$ = this.personService.getSeriesMostKnownFor(person.id).pipe(
|
||||||
|
takeUntilDestroyed(this.destroyRef)
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
createFilter(roles: PersonRole[]) {
|
createFilter(roles: PersonRole[]) {
|
||||||
const filter: SeriesFilterV2 = this.filterUtilityService.createSeriesV2Filter();
|
const filter: SeriesFilterV2 = this.filterUtilityService.createSeriesV2Filter();
|
||||||
filter.combination = FilterCombination.Or;
|
filter.combination = FilterCombination.Or;
|
||||||
@ -229,14 +255,34 @@ export class PersonDetailComponent {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
case (Action.Merge):
|
||||||
|
this.mergePersonAction();
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private mergePersonAction() {
|
||||||
|
const ref = this.modalService.open(MergePersonModalComponent, DefaultModalOptions);
|
||||||
|
ref.componentInstance.person = this.person;
|
||||||
|
|
||||||
|
ref.closed.subscribe(r => {
|
||||||
|
if (r.success) {
|
||||||
|
// Reload the person data, as relations may have changed
|
||||||
|
this.personService.get(r.person.name).subscribe(person => {
|
||||||
|
this.setPerson(person!);
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
performAction(action: ActionItem<any>) {
|
performAction(action: ActionItem<any>) {
|
||||||
if (typeof action.callback === 'function') {
|
if (typeof action.callback === 'function') {
|
||||||
action.callback(action, this.person);
|
action.callback(action, this.person);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected readonly FilterField = FilterField;
|
||||||
}
|
}
|
||||||
|
@ -130,7 +130,7 @@
|
|||||||
<span class="fw-bold">{{t('publication-status-title')}}</span>
|
<span class="fw-bold">{{t('publication-status-title')}}</span>
|
||||||
<div>
|
<div>
|
||||||
@if (seriesMetadata.publicationStatus | publicationStatus; as pubStatus) {
|
@if (seriesMetadata.publicationStatus | publicationStatus; as pubStatus) {
|
||||||
<a class="dark-exempt btn-icon" (click)="openFilter(FilterField.PublicationStatus, seriesMetadata.publicationStatus)"
|
<a class="dark-exempt btn-icon font-size" (click)="openFilter(FilterField.PublicationStatus, seriesMetadata!.publicationStatus)"
|
||||||
href="javascript:void(0);"
|
href="javascript:void(0);"
|
||||||
[ngbTooltip]="t('publication-status-tooltip') + (seriesMetadata.totalCount === 0 ? '' : ' (' + seriesMetadata.maxCount + ' / ' + seriesMetadata.totalCount + ')')">
|
[ngbTooltip]="t('publication-status-tooltip') + (seriesMetadata.totalCount === 0 ? '' : ' (' + seriesMetadata.maxCount + ' / ' + seriesMetadata.totalCount + ')')">
|
||||||
{{pubStatus}}
|
{{pubStatus}}
|
||||||
|
@ -30,3 +30,7 @@
|
|||||||
:host ::ng-deep .card-actions.btn-actions .btn {
|
:host ::ng-deep .card-actions.btn-actions .btn {
|
||||||
padding: 0.375rem 0.75rem;
|
padding: 0.375rem 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.font-size {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
@ -6,3 +6,10 @@
|
|||||||
height: 35px;
|
height: 35px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
::ng-deep .badge-expander .content {
|
||||||
|
a,
|
||||||
|
span {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
<form [formGroup]="form" *transloco="let t">
|
<form [formGroup]="form" *transloco="let t">
|
||||||
<div formArrayName="items">
|
<div formArrayName="items">
|
||||||
@for(item of ItemsArray.controls; let i = $index; track i) {
|
<!-- We are tracking items, as the index will not always point towards the same item. -->
|
||||||
|
@for(item of ItemsArray.controls; let i = $index; track item; let last = $last) {
|
||||||
<div class="row g-0 mb-3">
|
<div class="row g-0 mb-3">
|
||||||
<div class="col-lg-10 col-md-12 pe-2">
|
<div class="col-lg-10 col-md-12 pe-2">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@ -11,21 +12,30 @@
|
|||||||
[formControlName]="i"
|
[formControlName]="i"
|
||||||
id="item--{{i}}"
|
id="item--{{i}}"
|
||||||
>
|
>
|
||||||
|
@if (item.dirty && item.touched && errorMessage) {
|
||||||
|
@if (item.status === "INVALID") {
|
||||||
|
<div id="item--{{i}}-error" class="invalid-feedback" style="display: inline-block">
|
||||||
|
{{errorMessage}}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-2">
|
<div class="col-lg-2">
|
||||||
<button class="btn btn-secondary me-1" (click)="add()">
|
|
||||||
<i class="fa-solid fa-plus" aria-hidden="true"></i>
|
|
||||||
<span class="visually-hidden">{{t('common.add')}}</span>
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
class="btn btn-secondary"
|
class="btn btn-danger me-2"
|
||||||
(click)="remove(i)"
|
(click)="remove(i)"
|
||||||
[disabled]="ItemsArray.length === 1"
|
[disabled]="ItemsArray.length === 1"
|
||||||
>
|
>
|
||||||
<i class="fa-solid fa-xmark" aria-hidden="true"></i>
|
<i class="fa-solid fa-trash" aria-hidden="true"></i>
|
||||||
<span class="visually-hidden">{{t('common.remove')}}</span>
|
<span class="visually-hidden">{{t('common.remove')}}</span>
|
||||||
</button>
|
</button>
|
||||||
|
@if (last){
|
||||||
|
<button class="btn btn-secondary " (click)="add()">
|
||||||
|
<i class="fa-solid fa-plus" aria-hidden="true"></i>
|
||||||
|
<span class="visually-hidden">{{t('common.add')}}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@ -9,7 +9,7 @@ import {
|
|||||||
OnInit,
|
OnInit,
|
||||||
Output
|
Output
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import {FormArray, FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
|
import {AsyncValidatorFn, FormArray, FormControl, FormGroup, ReactiveFormsModule, ValidatorFn} from "@angular/forms";
|
||||||
import {TranslocoDirective} from "@jsverse/transloco";
|
import {TranslocoDirective} from "@jsverse/transloco";
|
||||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||||
import {debounceTime, distinctUntilChanged, tap} from "rxjs/operators";
|
import {debounceTime, distinctUntilChanged, tap} from "rxjs/operators";
|
||||||
@ -28,6 +28,10 @@ export class EditListComponent implements OnInit {
|
|||||||
|
|
||||||
@Input({required: true}) items: Array<string> = [];
|
@Input({required: true}) items: Array<string> = [];
|
||||||
@Input({required: true}) label = '';
|
@Input({required: true}) label = '';
|
||||||
|
@Input() validators: ValidatorFn[] = []
|
||||||
|
@Input() asyncValidators: AsyncValidatorFn[] = [];
|
||||||
|
// TODO: Make this more dynamic based on which validator failed
|
||||||
|
@Input() errorMessage: string | null = null;
|
||||||
@Output() updateItems = new EventEmitter<Array<string>>();
|
@Output() updateItems = new EventEmitter<Array<string>>();
|
||||||
|
|
||||||
form: FormGroup = new FormGroup({items: new FormArray([])});
|
form: FormGroup = new FormGroup({items: new FormArray([])});
|
||||||
@ -39,6 +43,9 @@ export class EditListComponent implements OnInit {
|
|||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.items.forEach(item => this.addItem(item));
|
this.items.forEach(item => this.addItem(item));
|
||||||
|
if (this.items.length === 0) {
|
||||||
|
this.addItem("");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
this.form.valueChanges.pipe(
|
this.form.valueChanges.pipe(
|
||||||
@ -51,7 +58,7 @@ export class EditListComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createItemControl(value: string = ''): FormControl {
|
createItemControl(value: string = ''): FormControl {
|
||||||
return new FormControl(value, []);
|
return new FormControl(value, this.validators, this.asyncValidators);
|
||||||
}
|
}
|
||||||
|
|
||||||
add() {
|
add() {
|
||||||
@ -69,6 +76,7 @@ export class EditListComponent implements OnInit {
|
|||||||
if (this.ItemsArray.length === 1) {
|
if (this.ItemsArray.length === 1) {
|
||||||
this.ItemsArray.at(0).setValue('');
|
this.ItemsArray.at(0).setValue('');
|
||||||
this.emit();
|
this.emit();
|
||||||
|
this.cdRef.markForCheck();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -130,7 +130,8 @@ export class LibrarySettingsModalComponent implements OnInit {
|
|||||||
|
|
||||||
get IsMetadataDownloadEligible() {
|
get IsMetadataDownloadEligible() {
|
||||||
const libType = parseInt(this.libraryForm.get('type')?.value + '', 10) as LibraryType;
|
const libType = parseInt(this.libraryForm.get('type')?.value + '', 10) as LibraryType;
|
||||||
return libType === LibraryType.Manga || libType === LibraryType.LightNovel || libType === LibraryType.ComicVine;
|
return libType === LibraryType.Manga || libType === LibraryType.LightNovel
|
||||||
|
|| libType === LibraryType.ComicVine || libType === LibraryType.Comic;
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
@ -72,6 +72,10 @@ export class TypeaheadComponent implements OnInit {
|
|||||||
* When triggered, will focus the input if the passed string matches the id
|
* When triggered, will focus the input if the passed string matches the id
|
||||||
*/
|
*/
|
||||||
@Input() focus: EventEmitter<string> | undefined;
|
@Input() focus: EventEmitter<string> | undefined;
|
||||||
|
/**
|
||||||
|
* When triggered, will unfocus the input if the passed string matches the id
|
||||||
|
*/
|
||||||
|
@Input() unFocus: EventEmitter<string> | undefined;
|
||||||
@Output() selectedData = new EventEmitter<any[] | any>();
|
@Output() selectedData = new EventEmitter<any[] | any>();
|
||||||
@Output() newItemAdded = new EventEmitter<any[] | any>();
|
@Output() newItemAdded = new EventEmitter<any[] | any>();
|
||||||
// eslint-disable-next-line @angular-eslint/no-output-on-prefix
|
// eslint-disable-next-line @angular-eslint/no-output-on-prefix
|
||||||
@ -113,6 +117,13 @@ export class TypeaheadComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.unFocus) {
|
||||||
|
this.unFocus.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((id: string) => {
|
||||||
|
if (this.settings.id !== id) return;
|
||||||
|
this.hasFocus = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1003,7 +1003,7 @@
|
|||||||
"save": "{{common.save}}",
|
"save": "{{common.save}}",
|
||||||
"no-results": "Unable to find a match. Try adding the url from a supported provider and retry.",
|
"no-results": "Unable to find a match. Try adding the url from a supported provider and retry.",
|
||||||
"query-label": "Query",
|
"query-label": "Query",
|
||||||
"query-tooltip": "Enter series name, AniList/MyAnimeList url. Urls will use a direct lookup.",
|
"query-tooltip": "Enter series name, AniList/MyAnimeList/ComicBookRoundup url. Urls will use a direct lookup.",
|
||||||
"dont-match-label": "Do not Match",
|
"dont-match-label": "Do not Match",
|
||||||
"dont-match-tooltip": "Opt this series from matching and scrobbling",
|
"dont-match-tooltip": "Opt this series from matching and scrobbling",
|
||||||
"search": "Search"
|
"search": "Search"
|
||||||
@ -1103,12 +1103,14 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
"person-detail": {
|
"person-detail": {
|
||||||
|
"aka-title": "Also known as ",
|
||||||
"known-for-title": "Known For",
|
"known-for-title": "Known For",
|
||||||
"individual-role-title": "As a {{role}}",
|
"individual-role-title": "As a {{role}}",
|
||||||
"browse-person-title": "All Works of {{name}}",
|
"browse-person-title": "All Works of {{name}}",
|
||||||
"browse-person-by-role-title": "All Works of {{name}} as a {{role}}",
|
"browse-person-by-role-title": "All Works of {{name}} as a {{role}}",
|
||||||
"all-roles": "Roles",
|
"all-roles": "Roles",
|
||||||
"anilist-url": "{{edit-person-modal.anilist-tooltip}}"
|
"anilist-url": "{{edit-person-modal.anilist-tooltip}}",
|
||||||
|
"no-info": "No information about this Person"
|
||||||
},
|
},
|
||||||
|
|
||||||
"library-settings-modal": {
|
"library-settings-modal": {
|
||||||
@ -1857,7 +1859,8 @@
|
|||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
"all-filters": "Smart Filters",
|
"all-filters": "Smart Filters",
|
||||||
"nav-link-header": "Navigation Options",
|
"nav-link-header": "Navigation Options",
|
||||||
"close": "{{common.close}}"
|
"close": "{{common.close}}",
|
||||||
|
"person-aka-status": "Matches an alias"
|
||||||
},
|
},
|
||||||
|
|
||||||
"promoted-icon": {
|
"promoted-icon": {
|
||||||
@ -2246,6 +2249,7 @@
|
|||||||
"title": "{{personName}} Details",
|
"title": "{{personName}} Details",
|
||||||
"general-tab": "{{edit-series-modal.general-tab}}",
|
"general-tab": "{{edit-series-modal.general-tab}}",
|
||||||
"cover-image-tab": "{{edit-series-modal.cover-image-tab}}",
|
"cover-image-tab": "{{edit-series-modal.cover-image-tab}}",
|
||||||
|
"aliases-tab": "Aliases",
|
||||||
"loading": "{{common.loading}}",
|
"loading": "{{common.loading}}",
|
||||||
"close": "{{common.close}}",
|
"close": "{{common.close}}",
|
||||||
"name-label": "{{edit-series-modal.name-label}}",
|
"name-label": "{{edit-series-modal.name-label}}",
|
||||||
@ -2263,7 +2267,20 @@
|
|||||||
"cover-image-description": "{{edit-series-modal.cover-image-description}}",
|
"cover-image-description": "{{edit-series-modal.cover-image-description}}",
|
||||||
"cover-image-description-extra": "Alternatively you can download a cover from CoversDB if available.",
|
"cover-image-description-extra": "Alternatively you can download a cover from CoversDB if available.",
|
||||||
"save": "{{common.save}}",
|
"save": "{{common.save}}",
|
||||||
"download-coversdb": "Download from CoversDB"
|
"download-coversdb": "Download from CoversDB",
|
||||||
|
"aliases-label": "Edit aliases",
|
||||||
|
"alias-overlap": "This alias already points towards another person or is the name of this person, consider merging them.",
|
||||||
|
"aliases-tooltip": "When a series is tagged with an alias of a person, the person is assigned rather than creating a new person. When deleting an alias, you'll have to rescan the series for the change to be picked up."
|
||||||
|
},
|
||||||
|
|
||||||
|
"merge-person-modal": {
|
||||||
|
"title": "{{personName}}",
|
||||||
|
"close": "{{common.close}}",
|
||||||
|
"save": "{{common.save}}",
|
||||||
|
"src": "Merge Person",
|
||||||
|
"merge-warning": "If you proceed, the selected person will be removed. The selected person's name will be added as an alias, and all their roles will be transferred.",
|
||||||
|
"alias-title": "New aliases",
|
||||||
|
"known-for-title": "Known for"
|
||||||
},
|
},
|
||||||
|
|
||||||
"day-breakdown": {
|
"day-breakdown": {
|
||||||
@ -2781,7 +2798,8 @@
|
|||||||
"match-tooltip": "Match Series with Kavita+ manually",
|
"match-tooltip": "Match Series with Kavita+ manually",
|
||||||
"reorder": "Reorder",
|
"reorder": "Reorder",
|
||||||
"rename": "Rename",
|
"rename": "Rename",
|
||||||
"rename-tooltip": "Rename the Smart Filter"
|
"rename-tooltip": "Rename the Smart Filter",
|
||||||
|
"merge": "Merge"
|
||||||
},
|
},
|
||||||
|
|
||||||
"preferences": {
|
"preferences": {
|
||||||
|
@ -436,4 +436,7 @@
|
|||||||
--login-input-font-family: 'League Spartan', sans-serif;
|
--login-input-font-family: 'League Spartan', sans-serif;
|
||||||
--login-input-placeholder-opacity: 0.5;
|
--login-input-placeholder-opacity: 0.5;
|
||||||
--login-input-placeholder-color: #fff;
|
--login-input-placeholder-color: #fff;
|
||||||
|
|
||||||
|
/** Series Detail **/
|
||||||
|
--detail-subtitle-color: lightgrey;
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user