Merge remote-tracking branch 'upstream/develop' into feature/ux-content-page-mobile-redesign
This commit is contained in:
commit
14df0139c9
158 changed files with 6590 additions and 1253 deletions
|
|
@ -9,10 +9,10 @@
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.4" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.4" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
|
||||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||||
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="22.0.13" />
|
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="22.0.14" />
|
||||||
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="22.0.13" />
|
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="22.0.14" />
|
||||||
<PackageReference Include="xunit" Version="2.9.3" />
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CsvHelper" Version="33.0.1" />
|
<PackageReference Include="CsvHelper" Version="33.0.1" />
|
||||||
<PackageReference Include="MailKit" Version="4.11.0" />
|
<PackageReference Include="MailKit" Version="4.12.0" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.4">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.4">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
|
@ -66,7 +66,7 @@
|
||||||
<PackageReference Include="Hangfire.InMemory" Version="1.0.0" />
|
<PackageReference Include="Hangfire.InMemory" Version="1.0.0" />
|
||||||
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
|
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
|
||||||
<PackageReference Include="Hangfire.Storage.SQLite" Version="0.4.2" />
|
<PackageReference Include="Hangfire.Storage.SQLite" Version="0.4.2" />
|
||||||
<PackageReference Include="HtmlAgilityPack" Version="1.12.0" />
|
<PackageReference Include="HtmlAgilityPack" Version="1.12.1" />
|
||||||
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
|
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
|
||||||
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.18" />
|
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.18" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
|
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
|
||||||
|
|
@ -78,7 +78,7 @@
|
||||||
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
|
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
|
||||||
<PackageReference Include="MimeTypeMapOfficial" Version="1.0.17" />
|
<PackageReference Include="MimeTypeMapOfficial" Version="1.0.17" />
|
||||||
<PackageReference Include="Nager.ArticleNumber" Version="1.0.7" />
|
<PackageReference Include="Nager.ArticleNumber" Version="1.0.7" />
|
||||||
<PackageReference Include="NetVips" Version="3.0.0" />
|
<PackageReference Include="NetVips" Version="3.0.1" />
|
||||||
<PackageReference Include="NetVips.Native" Version="8.16.1" />
|
<PackageReference Include="NetVips.Native" Version="8.16.1" />
|
||||||
<PackageReference Include="Serilog" Version="4.2.0" />
|
<PackageReference Include="Serilog" Version="4.2.0" />
|
||||||
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
|
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
|
||||||
|
|
@ -87,20 +87,20 @@
|
||||||
<PackageReference Include="Serilog.Settings.Configuration" Version="9.0.0" />
|
<PackageReference Include="Serilog.Settings.Configuration" Version="9.0.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.AspNetCore.SignalR" Version="0.4.0" />
|
<PackageReference Include="Serilog.Sinks.AspNetCore.SignalR" Version="0.4.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.SignalR.Core" Version="0.1.2" />
|
<PackageReference Include="Serilog.Sinks.SignalR.Core" Version="0.1.2" />
|
||||||
<PackageReference Include="SharpCompress" Version="0.39.0" />
|
<PackageReference Include="SharpCompress" Version="0.39.0" />
|
||||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.7" />
|
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.8" />
|
||||||
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.8.0.113526">
|
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.9.0.115408">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.1" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.1" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.2" />
|
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.3" />
|
||||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.8.0" />
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.9.0" />
|
||||||
<PackageReference Include="System.IO.Abstractions" Version="22.0.13" />
|
<PackageReference Include="System.IO.Abstractions" Version="22.0.14" />
|
||||||
<PackageReference Include="System.Drawing.Common" Version="9.0.4" />
|
<PackageReference Include="System.Drawing.Common" Version="9.0.4" />
|
||||||
<PackageReference Include="VersOne.Epub" Version="3.3.3" />
|
<PackageReference Include="VersOne.Epub" Version="3.3.4" />
|
||||||
<PackageReference Include="YamlDotNet" Version="16.3.0" />
|
<PackageReference Include="YamlDotNet" Version="16.3.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
@ -111,17 +111,16 @@
|
||||||
|
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Remove="Hangfire-log.db" />
|
|
||||||
<None Remove="obj\**" />
|
<None Remove="obj\**" />
|
||||||
<None Remove="cache\**" />
|
<None Remove="cache\**" />
|
||||||
<None Remove="cache-long\**" />
|
|
||||||
<None Remove="backups\**" />
|
<None Remove="backups\**" />
|
||||||
<None Remove="logs\**" />
|
<None Remove="logs\**" />
|
||||||
<None Remove="temp\**" />
|
<None Remove="temp\**" />
|
||||||
<None Remove="kavita.log" />
|
<None Remove="config\kavita.log" />
|
||||||
<None Remove="kavita.db" />
|
<None Remove="config\kavita.db" />
|
||||||
<None Remove="covers\**" />
|
<None Remove="config\covers\**" />
|
||||||
<None Remove="wwwroot\**" />
|
<None Remove="wwwroot\**" />
|
||||||
|
<None Remove="cache\cache-long\**" />
|
||||||
<None Remove="config\cache\**" />
|
<None Remove="config\cache\**" />
|
||||||
<None Remove="config\logs\**" />
|
<None Remove="config\logs\**" />
|
||||||
<None Remove="config\covers\**" />
|
<None Remove="config\covers\**" />
|
||||||
|
|
@ -139,6 +138,7 @@
|
||||||
<Compile Remove="covers\**" />
|
<Compile Remove="covers\**" />
|
||||||
<Compile Remove="wwwroot\**" />
|
<Compile Remove="wwwroot\**" />
|
||||||
<Compile Remove="config\cache\**" />
|
<Compile Remove="config\cache\**" />
|
||||||
|
<Compile Remove="cache\cache-long\**" />
|
||||||
<Compile Remove="config\logs\**" />
|
<Compile Remove="config\logs\**" />
|
||||||
<Compile Remove="config\covers\**" />
|
<Compile Remove="config\covers\**" />
|
||||||
<Compile Remove="config\bookmarks\**" />
|
<Compile Remove="config\bookmarks\**" />
|
||||||
|
|
@ -188,7 +188,6 @@
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Folder Include="config\cache-long\" />
|
|
||||||
<Folder Include="config\themes" />
|
<Folder Include="config\themes" />
|
||||||
<Content Include="EmailTemplates\**">
|
<Content Include="EmailTemplates\**">
|
||||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,18 @@ using API.DTOs.Scrobbling;
|
||||||
namespace API.DTOs.KavitaPlus.ExternalMetadata;
|
namespace API.DTOs.KavitaPlus.ExternalMetadata;
|
||||||
#nullable enable
|
#nullable enable
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a request to match some series from Kavita to an external id which K+ uses.
|
||||||
|
/// </summary>
|
||||||
internal sealed record MatchSeriesRequestDto
|
internal sealed record MatchSeriesRequestDto
|
||||||
{
|
{
|
||||||
public string SeriesName { get; set; }
|
public required string SeriesName { get; set; }
|
||||||
public ICollection<string> AlternativeNames { get; set; }
|
public ICollection<string> AlternativeNames { get; set; } = [];
|
||||||
public int Year { get; set; } = 0;
|
public int Year { get; set; } = 0;
|
||||||
public string Query { get; set; }
|
public string? Query { get; set; }
|
||||||
public int? AniListId { get; set; }
|
public int? AniListId { get; set; }
|
||||||
public long? MalId { get; set; }
|
public long? MalId { get; set; }
|
||||||
public string? HardcoverId { get; set; }
|
public string? HardcoverId { get; set; }
|
||||||
|
public int? CbrId { get; set; }
|
||||||
public PlusMediaFormat Format { get; set; }
|
public PlusMediaFormat Format { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -207,5 +207,6 @@
|
||||||
"dashboard-stream-only-delete-smart-filter": "Z ovládacího panelu lze odstranit pouze streamy chytrých filtrů",
|
"dashboard-stream-only-delete-smart-filter": "Z ovládacího panelu lze odstranit pouze streamy chytrých filtrů",
|
||||||
"smart-filter-name-required": "Vyžaduje se název chytrého filtru",
|
"smart-filter-name-required": "Vyžaduje se název chytrého filtru",
|
||||||
"smart-filter-system-name": "Nelze použít název streamu poskytovaného systémem",
|
"smart-filter-system-name": "Nelze použít název streamu poskytovaného systémem",
|
||||||
"sidenav-stream-only-delete-smart-filter": "Z postranní navigace lze odstranit pouze streamy chytrých filtrů"
|
"sidenav-stream-only-delete-smart-filter": "Z postranní navigace lze odstranit pouze streamy chytrých filtrů",
|
||||||
|
"aliases-have-overlap": "Jeden nebo více aliasů se překrývají s jinými osobami, nelze je aktualizovat"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}",
|
||||||
|
|
|
||||||
|
|
@ -207,5 +207,6 @@
|
||||||
"smart-filter-system-name": "Ní féidir leat ainm srutha an chórais a sholáthair tú a úsáid",
|
"smart-filter-system-name": "Ní féidir leat ainm srutha an chórais a sholáthair tú a úsáid",
|
||||||
"sidenav-stream-only-delete-smart-filter": "Ní féidir ach sruthanna cliste scagaire a scriosadh as an SideNav",
|
"sidenav-stream-only-delete-smart-filter": "Ní féidir ach sruthanna cliste scagaire a scriosadh as an SideNav",
|
||||||
"dashboard-stream-only-delete-smart-filter": "Ní féidir ach sruthanna cliste scagaire a scriosadh ón deais",
|
"dashboard-stream-only-delete-smart-filter": "Ní féidir ach sruthanna cliste scagaire a scriosadh ón deais",
|
||||||
"smart-filter-name-required": "Ainm Scagaire Cliste ag teastáil"
|
"smart-filter-name-required": "Ainm Scagaire Cliste ag teastáil",
|
||||||
|
"aliases-have-overlap": "Tá forluí idir ceann amháin nó níos mó de na leasainmneacha agus daoine eile, ní féidir iad a nuashonrú"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -203,5 +203,9 @@
|
||||||
"person-name-unique": "개인 이름은 고유해야 합니다",
|
"person-name-unique": "개인 이름은 고유해야 합니다",
|
||||||
"person-image-doesnt-exist": "CoversDB에 사람이 존재하지 않습니다",
|
"person-image-doesnt-exist": "CoversDB에 사람이 존재하지 않습니다",
|
||||||
"kavitaplus-restricted": "Kavita+만 해당",
|
"kavitaplus-restricted": "Kavita+만 해당",
|
||||||
"email-taken": "이미 사용중인 이메일"
|
"email-taken": "이미 사용중인 이메일",
|
||||||
|
"dashboard-stream-only-delete-smart-filter": "대시보드에서 스마트 필터 스트림만 삭제할 수 있습니다",
|
||||||
|
"sidenav-stream-only-delete-smart-filter": "사이드 메뉴에서 스마트 필터 스트림만 삭제할 수 있습니다",
|
||||||
|
"smart-filter-name-required": "스마트 필터 이름이 필요합니다",
|
||||||
|
"smart-filter-system-name": "시스템 제공 스트림 이름은 사용할 수 없습니다"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -207,5 +207,6 @@
|
||||||
"smart-filter-name-required": "Nome do Filtro Inteligente obrigatório",
|
"smart-filter-name-required": "Nome do Filtro Inteligente obrigatório",
|
||||||
"dashboard-stream-only-delete-smart-filter": "Somente fluxos de filtros inteligentes podem ser excluídos do painel",
|
"dashboard-stream-only-delete-smart-filter": "Somente fluxos de filtros inteligentes podem ser excluídos do painel",
|
||||||
"smart-filter-system-name": "Você não pode usar o nome de um fluxo fornecido pelo sistema",
|
"smart-filter-system-name": "Você não pode usar o nome de um fluxo fornecido pelo sistema",
|
||||||
"sidenav-stream-only-delete-smart-filter": "Somente fluxos de filtros inteligentes podem ser excluídos do Navegador Lateral"
|
"sidenav-stream-only-delete-smart-filter": "Somente fluxos de filtros inteligentes podem ser excluídos do Navegador Lateral",
|
||||||
|
"aliases-have-overlap": "Um ou mais dos pseudônimos se sobrepõem a outras pessoas, não pode atualizar"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,7 @@
|
||||||
"opds-disabled": "OPDS не включен на этом сервере",
|
"opds-disabled": "OPDS не включен на этом сервере",
|
||||||
"stats-permission-denied": "Вы не имеете права просматривать статистику другого пользователя",
|
"stats-permission-denied": "Вы не имеете права просматривать статистику другого пользователя",
|
||||||
"reading-list-restricted": "Список чтения не существует или у вас нет доступа",
|
"reading-list-restricted": "Список чтения не существует или у вас нет доступа",
|
||||||
"favicon-doesnt-exist": "Фавикон не существует",
|
"favicon-doesnt-exist": "Favicon не существует",
|
||||||
"external-source-already-in-use": "Существует поток с этим внешним источником",
|
"external-source-already-in-use": "Существует поток с этим внешним источником",
|
||||||
"issue-num": "Вопрос {0}{1}",
|
"issue-num": "Вопрос {0}{1}",
|
||||||
"generic-create-temp-archive": "Возникла проблема с созданием временного архива",
|
"generic-create-temp-archive": "Возникла проблема с созданием временного архива",
|
||||||
|
|
@ -194,5 +194,13 @@
|
||||||
"backup": "Резервное копирование",
|
"backup": "Резервное копирование",
|
||||||
"process-processed-scrobbling-events": "Обработка обработанных событий скроблинга",
|
"process-processed-scrobbling-events": "Обработка обработанных событий скроблинга",
|
||||||
"scan-libraries": "Сканирование библиотек",
|
"scan-libraries": "Сканирование библиотек",
|
||||||
"kavita+-data-refresh": "Обновление данных Kavita+"
|
"kavita+-data-refresh": "Обновление данных Kavita+",
|
||||||
|
"kavitaplus-restricted": "Это доступно только для Kavita+",
|
||||||
|
"person-doesnt-exist": "Персона не существует",
|
||||||
|
"generic-cover-volume-save": "Не удается сохранить обложку для раздела",
|
||||||
|
"generic-cover-person-save": "Не удается сохранить изображение обложки для Персоны",
|
||||||
|
"person-name-unique": "Имя персоны должно быть уникальным",
|
||||||
|
"person-image-doesnt-exist": "Персона не существует в CoversDB",
|
||||||
|
"email-taken": "Почта уже используется",
|
||||||
|
"person-name-required": "Имя персоны обязательно и не может быть пустым"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -207,5 +207,6 @@
|
||||||
"dashboard-stream-only-delete-smart-filter": "只能从仪表板中删除智能筛选器流",
|
"dashboard-stream-only-delete-smart-filter": "只能从仪表板中删除智能筛选器流",
|
||||||
"smart-filter-name-required": "需要智能筛选器名称",
|
"smart-filter-name-required": "需要智能筛选器名称",
|
||||||
"smart-filter-system-name": "您不能使用系统提供的流名称",
|
"smart-filter-system-name": "您不能使用系统提供的流名称",
|
||||||
"sidenav-stream-only-delete-smart-filter": "只能从侧边栏删除智能筛选器流"
|
"sidenav-stream-only-delete-smart-filter": "只能从侧边栏删除智能筛选器流",
|
||||||
|
"aliases-have-overlap": "一个或多个别名与其他人有重叠,无法更新"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
@ -26,7 +27,7 @@ public class SecurityEventMiddleware(RequestDelegate next)
|
||||||
}
|
}
|
||||||
catch (KavitaUnauthenticatedUserException ex)
|
catch (KavitaUnauthenticatedUserException ex)
|
||||||
{
|
{
|
||||||
var ipAddress = context.Connection.RemoteIpAddress?.ToString();
|
var ipAddress = context.Request.Headers["X-Forwarded-For"].FirstOrDefault() ?? context.Connection.RemoteIpAddress?.ToString();
|
||||||
var requestMethod = context.Request.Method;
|
var requestMethod = context.Request.Method;
|
||||||
var requestPath = context.Request.Path;
|
var requestPath = context.Request.Path;
|
||||||
var userAgent = context.Request.Headers.UserAgent;
|
var userAgent = context.Request.Headers.UserAgent;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System;
|
using System;
|
||||||
|
using System.IO;
|
||||||
using System.IO.Abstractions;
|
using System.IO.Abstractions;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
|
@ -48,15 +49,13 @@ public class Program
|
||||||
|
|
||||||
var directoryService = new DirectoryService(null!, new FileSystem());
|
var directoryService = new DirectoryService(null!, new FileSystem());
|
||||||
|
|
||||||
|
|
||||||
|
// Check if this is the first time running and if so, rename appsettings-init.json to appsettings.json
|
||||||
|
HandleFirstRunConfiguration();
|
||||||
|
|
||||||
|
|
||||||
// Before anything, check if JWT has been generated properly or if user still has default
|
// Before anything, check if JWT has been generated properly or if user still has default
|
||||||
if (!Configuration.CheckIfJwtTokenSet() &&
|
EnsureJwtTokenKey();
|
||||||
Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") != Environments.Development)
|
|
||||||
{
|
|
||||||
Log.Logger.Information("Generating JWT TokenKey for encrypting user sessions...");
|
|
||||||
var rBytes = new byte[256];
|
|
||||||
RandomNumberGenerator.Create().GetBytes(rBytes);
|
|
||||||
Configuration.JwtToken = Convert.ToBase64String(rBytes).Replace("/", string.Empty);
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -70,6 +69,7 @@ public class Program
|
||||||
{
|
{
|
||||||
var logger = services.GetRequiredService<ILogger<Program>>();
|
var logger = services.GetRequiredService<ILogger<Program>>();
|
||||||
var context = services.GetRequiredService<DataContext>();
|
var context = services.GetRequiredService<DataContext>();
|
||||||
|
|
||||||
var pendingMigrations = await context.Database.GetPendingMigrationsAsync();
|
var pendingMigrations = await context.Database.GetPendingMigrationsAsync();
|
||||||
var isDbCreated = await context.Database.CanConnectAsync();
|
var isDbCreated = await context.Database.CanConnectAsync();
|
||||||
if (isDbCreated && pendingMigrations.Any())
|
if (isDbCreated && pendingMigrations.Any())
|
||||||
|
|
@ -157,6 +157,26 @@ public class Program
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void EnsureJwtTokenKey()
|
||||||
|
{
|
||||||
|
if (Configuration.CheckIfJwtTokenSet() || Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development) return;
|
||||||
|
|
||||||
|
Log.Logger.Information("Generating JWT TokenKey for encrypting user sessions...");
|
||||||
|
var rBytes = new byte[256];
|
||||||
|
RandomNumberGenerator.Create().GetBytes(rBytes);
|
||||||
|
Configuration.JwtToken = Convert.ToBase64String(rBytes).Replace("/", string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void HandleFirstRunConfiguration()
|
||||||
|
{
|
||||||
|
var firstRunConfigFilePath = Path.Join(Directory.GetCurrentDirectory(), "config/appsettings-init.json");
|
||||||
|
if (File.Exists(firstRunConfigFilePath) &&
|
||||||
|
!File.Exists(Path.Join(Directory.GetCurrentDirectory(), "config/appsettings.json")))
|
||||||
|
{
|
||||||
|
File.Move(firstRunConfigFilePath, Path.Join(Directory.GetCurrentDirectory(), "config/appsettings.json"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<string> GetMigrationDirectory(DataContext context, IDirectoryService directoryService)
|
private static async Task<string> GetMigrationDirectory(DataContext context, IDirectoryService directoryService)
|
||||||
{
|
{
|
||||||
string? currentVersion = null;
|
string? currentVersion = null;
|
||||||
|
|
|
||||||
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;
|
||||||
|
|
@ -223,7 +226,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||||
AlternativeNames = altNames.Where(s => !string.IsNullOrEmpty(s)).ToList(),
|
AlternativeNames = altNames.Where(s => !string.IsNullOrEmpty(s)).ToList(),
|
||||||
Year = series.Metadata.ReleaseYear,
|
Year = series.Metadata.ReleaseYear,
|
||||||
AniListId = potentialAnilistId ?? ScrobblingService.GetAniListId(series),
|
AniListId = potentialAnilistId ?? ScrobblingService.GetAniListId(series),
|
||||||
MalId = potentialMalId ?? ScrobblingService.GetMalId(series),
|
MalId = potentialMalId ?? ScrobblingService.GetMalId(series)
|
||||||
};
|
};
|
||||||
|
|
||||||
var token = (await _unitOfWork.UserRepository.GetDefaultAdminUser()).AniListAccessToken;
|
var token = (await _unitOfWork.UserRepository.GetDefaultAdminUser()).AniListAccessToken;
|
||||||
|
|
@ -614,12 +617,8 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||||
madeModification = await UpdateTags(series, settings, externalMetadata, processedTags) || madeModification;
|
madeModification = await UpdateTags(series, settings, externalMetadata, processedTags) || madeModification;
|
||||||
madeModification = UpdateAgeRating(series, settings, processedGenres.Concat(processedTags)) || madeModification;
|
madeModification = UpdateAgeRating(series, settings, processedGenres.Concat(processedTags)) || madeModification;
|
||||||
|
|
||||||
var staff = (externalMetadata.Staff ?? []).Select(s =>
|
var staff = await SetNameAndAddAliases(settings, externalMetadata.Staff);
|
||||||
{
|
|
||||||
s.Name = settings.FirstLastPeopleNaming ? $"{s.FirstName} {s.LastName}" : $"{s.LastName} {s.FirstName}";
|
|
||||||
|
|
||||||
return s;
|
|
||||||
}).ToList();
|
|
||||||
madeModification = await UpdateWriters(series, settings, staff) || madeModification;
|
madeModification = await UpdateWriters(series, settings, staff) || madeModification;
|
||||||
madeModification = await UpdateArtists(series, settings, staff) || madeModification;
|
madeModification = await UpdateArtists(series, settings, staff) || madeModification;
|
||||||
madeModification = await UpdateCharacters(series, settings, externalMetadata.Characters) || madeModification;
|
madeModification = await UpdateCharacters(series, settings, externalMetadata.Characters) || madeModification;
|
||||||
|
|
@ -632,6 +631,49 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||||
return madeModification;
|
return madeModification;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<List<SeriesStaffDto>> SetNameAndAddAliases(MetadataSettingsDto settings, IList<SeriesStaffDto>? staff)
|
||||||
|
{
|
||||||
|
if (staff == null || staff.Count == 0) return [];
|
||||||
|
|
||||||
|
var nameMappings = staff.Select(s => new
|
||||||
|
{
|
||||||
|
Staff = s,
|
||||||
|
PreferredName = settings.FirstLastPeopleNaming ? $"{s.FirstName} {s.LastName}" : $"{s.LastName} {s.FirstName}",
|
||||||
|
AlternativeName = !settings.FirstLastPeopleNaming ? $"{s.FirstName} {s.LastName}" : $"{s.LastName} {s.FirstName}"
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
var preferredNames = nameMappings.Select(n => n.PreferredName.ToNormalized()).Distinct().ToList();
|
||||||
|
var alternativeNames = nameMappings.Select(n => n.AlternativeName.ToNormalized()).Distinct().ToList();
|
||||||
|
|
||||||
|
var existingPeople = await _unitOfWork.PersonRepository.GetPeopleByNames(preferredNames.Union(alternativeNames).ToList());
|
||||||
|
var existingPeopleDictionary = PersonHelper.ConstructNameAndAliasDictionary(existingPeople);
|
||||||
|
|
||||||
|
var modified = false;
|
||||||
|
foreach (var mapping in nameMappings)
|
||||||
|
{
|
||||||
|
mapping.Staff.Name = mapping.PreferredName;
|
||||||
|
|
||||||
|
if (existingPeopleDictionary.ContainsKey(mapping.PreferredName.ToNormalized()))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (existingPeopleDictionary.TryGetValue(mapping.AlternativeName.ToNormalized(), out var person))
|
||||||
|
{
|
||||||
|
modified = true;
|
||||||
|
person.Aliases.Add(new PersonAliasBuilder(mapping.PreferredName).Build());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modified)
|
||||||
|
{
|
||||||
|
await _unitOfWork.CommitAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
return [.. staff];
|
||||||
|
}
|
||||||
|
|
||||||
private static void GenerateGenreAndTagLists(ExternalSeriesDetailDto externalMetadata, MetadataSettingsDto settings,
|
private static void GenerateGenreAndTagLists(ExternalSeriesDetailDto externalMetadata, MetadataSettingsDto settings,
|
||||||
ref List<string> processedTags, ref List<string> processedGenres)
|
ref List<string> processedTags, ref List<string> processedGenres)
|
||||||
{
|
{
|
||||||
|
|
@ -750,7 +792,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||||
var characters = externalCharacters
|
var characters = externalCharacters
|
||||||
.Select(w => new PersonDto()
|
.Select(w => new PersonDto()
|
||||||
{
|
{
|
||||||
Name = w.Name,
|
Name = w.Name.Trim(),
|
||||||
AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListCharacterWebsite),
|
AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListCharacterWebsite),
|
||||||
Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))),
|
Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))),
|
||||||
})
|
})
|
||||||
|
|
@ -831,7 +873,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||||
var artists = upstreamArtists
|
var artists = upstreamArtists
|
||||||
.Select(w => new PersonDto()
|
.Select(w => new PersonDto()
|
||||||
{
|
{
|
||||||
Name = w.Name,
|
Name = w.Name.Trim(),
|
||||||
AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListStaffWebsite),
|
AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListStaffWebsite),
|
||||||
Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))),
|
Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))),
|
||||||
})
|
})
|
||||||
|
|
@ -887,7 +929,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||||
var writers = upstreamWriters
|
var writers = upstreamWriters
|
||||||
.Select(w => new PersonDto()
|
.Select(w => new PersonDto()
|
||||||
{
|
{
|
||||||
Name = w.Name,
|
Name = w.Name.Trim(),
|
||||||
AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListStaffWebsite),
|
AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListStaffWebsite),
|
||||||
Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))),
|
Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))),
|
||||||
})
|
})
|
||||||
|
|
@ -1311,7 +1353,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||||
var people = staff!
|
var people = staff!
|
||||||
.Select(w => new PersonDto()
|
.Select(w => new PersonDto()
|
||||||
{
|
{
|
||||||
Name = w,
|
Name = w.Trim(),
|
||||||
})
|
})
|
||||||
.Concat(chapter.People
|
.Concat(chapter.People
|
||||||
.Where(p => p.Role == role)
|
.Where(p => p.Role == role)
|
||||||
|
|
|
||||||
|
|
@ -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>();
|
||||||
|
|
|
||||||
|
|
@ -501,7 +501,7 @@ public class CoverDbService : ICoverDbService
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_directoryService.DeleteFiles([tempFullPath]);
|
_directoryService.DeleteFiles([tempFullPath]);
|
||||||
person.CoverImage = Path.GetFileName(existingPath);
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|
@ -571,15 +571,21 @@ public class CoverDbService : ICoverDbService
|
||||||
|
|
||||||
var choseNewImage = string.Equals(betterImage, tempFullPath, StringComparison.OrdinalIgnoreCase);
|
var choseNewImage = string.Equals(betterImage, tempFullPath, StringComparison.OrdinalIgnoreCase);
|
||||||
if (choseNewImage)
|
if (choseNewImage)
|
||||||
|
{
|
||||||
|
|
||||||
|
// Don't delete series cover, unless it's an override, otherwise the first chapter cover will be null
|
||||||
|
if (existingPath.Contains(ImageService.GetSeriesFormat(series.Id)))
|
||||||
{
|
{
|
||||||
_directoryService.DeleteFiles([existingPath]);
|
_directoryService.DeleteFiles([existingPath]);
|
||||||
|
}
|
||||||
|
|
||||||
_directoryService.CopyFile(tempFullPath, finalFullPath);
|
_directoryService.CopyFile(tempFullPath, finalFullPath);
|
||||||
series.CoverImage = finalFileName;
|
series.CoverImage = finalFileName;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_directoryService.DeleteFiles([tempFullPath]);
|
_directoryService.DeleteFiles([tempFullPath]);
|
||||||
series.CoverImage = Path.GetFileName(existingPath);
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|
@ -651,6 +657,7 @@ public class CoverDbService : ICoverDbService
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_directoryService.DeleteFiles([tempFullPath]);
|
_directoryService.DeleteFiles([tempFullPath]);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
chapter.CoverImage = finalFileName;
|
chapter.CoverImage = finalFileName;
|
||||||
|
|
|
||||||
|
|
@ -310,7 +310,7 @@ public class LibraryWatcher : ILibraryWatcher
|
||||||
if (rootFolder.Count == 0) return string.Empty;
|
if (rootFolder.Count == 0) return string.Empty;
|
||||||
|
|
||||||
// Select the first folder and join with library folder, this should give us the folder to scan.
|
// Select the first folder and join with library folder, this should give us the folder to scan.
|
||||||
return Parser.Parser.NormalizePath(_directoryService.FileSystem.Path.Join(libraryFolder, rootFolder[rootFolder.Count - 1]));
|
return Parser.Parser.NormalizePath(_directoryService.FileSystem.Path.Join(libraryFolder, rootFolder[^1]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ public interface IVersionUpdaterService
|
||||||
Task PushUpdate(UpdateNotificationDto update);
|
Task PushUpdate(UpdateNotificationDto update);
|
||||||
Task<IList<UpdateNotificationDto>> GetAllReleases(int count = 0);
|
Task<IList<UpdateNotificationDto>> GetAllReleases(int count = 0);
|
||||||
Task<int> GetNumberOfReleasesBehind(bool stableOnly = false);
|
Task<int> GetNumberOfReleasesBehind(bool stableOnly = false);
|
||||||
|
void BustGithubCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -384,7 +385,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService
|
||||||
if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc <= CacheDuration)
|
if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc <= CacheDuration)
|
||||||
{
|
{
|
||||||
var cachedData = await File.ReadAllTextAsync(_cacheLatestReleaseFilePath);
|
var cachedData = await File.ReadAllTextAsync(_cacheLatestReleaseFilePath);
|
||||||
return System.Text.Json.JsonSerializer.Deserialize<UpdateNotificationDto>(cachedData);
|
return JsonSerializer.Deserialize<UpdateNotificationDto>(cachedData);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -407,7 +408,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var json = System.Text.Json.JsonSerializer.Serialize(update, JsonOptions);
|
var json = JsonSerializer.Serialize(update, JsonOptions);
|
||||||
await File.WriteAllTextAsync(_cacheLatestReleaseFilePath, json);
|
await File.WriteAllTextAsync(_cacheLatestReleaseFilePath, json);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|
@ -446,6 +447,21 @@ public partial class VersionUpdaterService : IVersionUpdaterService
|
||||||
.Count(u => u.IsReleaseNewer);
|
.Count(u => u.IsReleaseNewer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clears the Github cache
|
||||||
|
/// </summary>
|
||||||
|
public void BustGithubCache()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Delete(_cacheFilePath);
|
||||||
|
File.Delete(_cacheLatestReleaseFilePath);
|
||||||
|
} catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to clear Github cache");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private UpdateNotificationDto? CreateDto(GithubReleaseMetadata? update)
|
private UpdateNotificationDto? CreateDto(GithubReleaseMetadata? update)
|
||||||
{
|
{
|
||||||
if (update == null || string.IsNullOrEmpty(update.Tag_Name)) return null;
|
if (update == null || string.IsNullOrEmpty(update.Tag_Name)) return null;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,9 @@ public class Startup
|
||||||
{
|
{
|
||||||
_config = config;
|
_config = config;
|
||||||
_env = env;
|
_env = env;
|
||||||
|
|
||||||
|
// Disable Hangfire Automatic Retry
|
||||||
|
GlobalJobFilters.Filters.Add(new AutomaticRetryAttribute { Attempts = 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// This method gets called by the runtime. Use this method to add services to the container.
|
// This method gets called by the runtime. Use this method to add services to the container.
|
||||||
|
|
@ -223,7 +226,7 @@ public class Startup
|
||||||
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
|
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
|
||||||
public void Configure(IApplicationBuilder app, IBackgroundJobClient backgroundJobs, IWebHostEnvironment env,
|
public void Configure(IApplicationBuilder app, IBackgroundJobClient backgroundJobs, IWebHostEnvironment env,
|
||||||
IHostApplicationLifetime applicationLifetime, IServiceProvider serviceProvider, ICacheService cacheService,
|
IHostApplicationLifetime applicationLifetime, IServiceProvider serviceProvider, ICacheService cacheService,
|
||||||
IDirectoryService directoryService, IUnitOfWork unitOfWork, IBackupService backupService, IImageService imageService)
|
IDirectoryService directoryService, IUnitOfWork unitOfWork, IBackupService backupService, IImageService imageService, IVersionUpdaterService versionService)
|
||||||
{
|
{
|
||||||
|
|
||||||
var logger = serviceProvider.GetRequiredService<ILogger<Program>>();
|
var logger = serviceProvider.GetRequiredService<ILogger<Program>>();
|
||||||
|
|
@ -235,9 +238,10 @@ public class Startup
|
||||||
// Apply all migrations on startup
|
// Apply all migrations on startup
|
||||||
var dataContext = serviceProvider.GetRequiredService<DataContext>();
|
var dataContext = serviceProvider.GetRequiredService<DataContext>();
|
||||||
|
|
||||||
|
|
||||||
logger.LogInformation("Running Migrations");
|
logger.LogInformation("Running Migrations");
|
||||||
|
|
||||||
|
#region Migrations
|
||||||
|
|
||||||
// v0.7.9
|
// v0.7.9
|
||||||
await MigrateUserLibrarySideNavStream.Migrate(unitOfWork, dataContext, logger);
|
await MigrateUserLibrarySideNavStream.Migrate(unitOfWork, dataContext, logger);
|
||||||
|
|
||||||
|
|
@ -289,13 +293,23 @@ public class Startup
|
||||||
await ManualMigrateScrobbleSpecials.Migrate(dataContext, logger);
|
await ManualMigrateScrobbleSpecials.Migrate(dataContext, logger);
|
||||||
await ManualMigrateScrobbleEventGen.Migrate(dataContext, logger);
|
await ManualMigrateScrobbleEventGen.Migrate(dataContext, logger);
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
// Update the version in the DB after all migrations are run
|
// Update the version in the DB after all migrations are run
|
||||||
var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion);
|
var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion);
|
||||||
|
var isVersionDifferent = installVersion.Value != BuildInfo.Version.ToString();
|
||||||
installVersion.Value = BuildInfo.Version.ToString();
|
installVersion.Value = BuildInfo.Version.ToString();
|
||||||
unitOfWork.SettingsRepository.Update(installVersion);
|
unitOfWork.SettingsRepository.Update(installVersion);
|
||||||
await unitOfWork.CommitAsync();
|
await unitOfWork.CommitAsync();
|
||||||
|
|
||||||
logger.LogInformation("Running Migrations - complete");
|
logger.LogInformation("Running Migrations - complete");
|
||||||
|
|
||||||
|
if (isVersionDifferent)
|
||||||
|
{
|
||||||
|
// Clear the Github cache so update stuff shows correctly
|
||||||
|
versionService.BustGithubCache();
|
||||||
|
}
|
||||||
|
|
||||||
}).GetAwaiter()
|
}).GetAwaiter()
|
||||||
.GetResult();
|
.GetResult();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<Company>kavitareader.com</Company>
|
<Company>kavitareader.com</Company>
|
||||||
<Product>Kavita</Product>
|
<Product>Kavita</Product>
|
||||||
<AssemblyVersion>0.8.6.8</AssemblyVersion>
|
<AssemblyVersion>0.8.6.11</AssemblyVersion>
|
||||||
<NeutralLanguage>en</NeutralLanguage>
|
<NeutralLanguage>en</NeutralLanguage>
|
||||||
<TieredPGO>true</TieredPGO>
|
<TieredPGO>true</TieredPGO>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
<PackageReference Include="Flurl.Http" Version="4.0.2" />
|
<PackageReference Include="Flurl.Http" Version="4.0.2" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.4" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.4" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.4" />
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.4" />
|
||||||
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.8.0.113526">
|
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.9.0.115408">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
font-size: 1.75rem;
|
font-size: 1.75rem;
|
||||||
}
|
}
|
||||||
.subtitle {
|
.subtitle {
|
||||||
color: lightgrey;
|
color: var(--detail-subtitle-color);;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,13 @@ import {Library} from '../_models/library/library';
|
||||||
import {ReadingList} from '../_models/reading-list';
|
import {ReadingList} from '../_models/reading-list';
|
||||||
import {Series} from '../_models/series';
|
import {Series} from '../_models/series';
|
||||||
import {Volume} from '../_models/volume';
|
import {Volume} from '../_models/volume';
|
||||||
import {AccountService} from './account.service';
|
import {AccountService, Role} from './account.service';
|
||||||
import {DeviceService} from './device.service';
|
import {DeviceService} from './device.service';
|
||||||
import {SideNavStream} from "../_models/sidenav/sidenav-stream";
|
import {SideNavStream} from "../_models/sidenav/sidenav-stream";
|
||||||
import {SmartFilter} from "../_models/metadata/v2/smart-filter";
|
import {SmartFilter} from "../_models/metadata/v2/smart-filter";
|
||||||
import {translate} from "@jsverse/transloco";
|
import {translate} from "@jsverse/transloco";
|
||||||
import {Person} from "../_models/metadata/person";
|
import {Person} from "../_models/metadata/person";
|
||||||
|
import {User} from '../_models/user';
|
||||||
|
|
||||||
export enum Action {
|
export enum Action {
|
||||||
Submenu = -1,
|
Submenu = -1,
|
||||||
|
|
@ -106,7 +107,7 @@ export enum Action {
|
||||||
Promote = 24,
|
Promote = 24,
|
||||||
UnPromote = 25,
|
UnPromote = 25,
|
||||||
/**
|
/**
|
||||||
* Invoke a refresh covers as false to generate colorscapes
|
* Invoke refresh covers as false to generate colorscapes
|
||||||
*/
|
*/
|
||||||
GenerateColorScape = 26,
|
GenerateColorScape = 26,
|
||||||
/**
|
/**
|
||||||
|
|
@ -116,20 +117,31 @@ export enum Action {
|
||||||
/**
|
/**
|
||||||
* Match an entity with an upstream system
|
* Match an entity with an upstream system
|
||||||
*/
|
*/
|
||||||
Match = 28
|
Match = 28,
|
||||||
|
/**
|
||||||
|
* Merge two (or more?) entities
|
||||||
|
*/
|
||||||
|
Merge = 29,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Callback for an action
|
* Callback for an action
|
||||||
*/
|
*/
|
||||||
export type ActionCallback<T> = (action: ActionItem<T>, data: T) => void;
|
export type ActionCallback<T> = (action: ActionItem<T>, entity: T) => void;
|
||||||
export type ActionAllowedCallback<T> = (action: ActionItem<T>) => boolean;
|
export type ActionShouldRenderFunc<T> = (action: ActionItem<T>, entity: T, user: User) => boolean;
|
||||||
|
|
||||||
export interface ActionItem<T> {
|
export interface ActionItem<T> {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
action: Action;
|
action: Action;
|
||||||
callback: ActionCallback<T>;
|
callback: ActionCallback<T>;
|
||||||
|
/**
|
||||||
|
* Roles required to be present for ActionItem to show. If empty, assumes anyone can see. At least one needs to apply.
|
||||||
|
*/
|
||||||
|
requiredRoles: Role[];
|
||||||
|
/**
|
||||||
|
* @deprecated Use required Roles instead
|
||||||
|
*/
|
||||||
requiresAdmin: boolean;
|
requiresAdmin: boolean;
|
||||||
children: Array<ActionItem<T>>;
|
children: Array<ActionItem<T>>;
|
||||||
/**
|
/**
|
||||||
|
|
@ -145,94 +157,98 @@ export interface ActionItem<T> {
|
||||||
* Extra data that needs to be sent back from the card item. Used mainly for dynamicList. This will be the item from dyanamicList return
|
* Extra data that needs to be sent back from the card item. Used mainly for dynamicList. This will be the item from dyanamicList return
|
||||||
*/
|
*/
|
||||||
_extra?: {title: string, data: any};
|
_extra?: {title: string, data: any};
|
||||||
|
/**
|
||||||
|
* Will call on each action to determine if it should show for the appropriate entity based on state and user
|
||||||
|
*/
|
||||||
|
shouldRender: ActionShouldRenderFunc<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entities that can be actioned upon
|
||||||
|
*/
|
||||||
|
export type ActionableEntity = Volume | Series | Chapter | ReadingList | UserCollection | Person | Library | SideNavStream | SmartFilter | null;
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class ActionFactoryService {
|
export class ActionFactoryService {
|
||||||
libraryActions: Array<ActionItem<Library>> = [];
|
private libraryActions: Array<ActionItem<Library>> = [];
|
||||||
|
private seriesActions: Array<ActionItem<Series>> = [];
|
||||||
seriesActions: Array<ActionItem<Series>> = [];
|
private volumeActions: Array<ActionItem<Volume>> = [];
|
||||||
|
private chapterActions: Array<ActionItem<Chapter>> = [];
|
||||||
volumeActions: Array<ActionItem<Volume>> = [];
|
private collectionTagActions: Array<ActionItem<UserCollection>> = [];
|
||||||
|
private readingListActions: Array<ActionItem<ReadingList>> = [];
|
||||||
chapterActions: Array<ActionItem<Chapter>> = [];
|
private bookmarkActions: Array<ActionItem<Series>> = [];
|
||||||
|
|
||||||
collectionTagActions: Array<ActionItem<UserCollection>> = [];
|
|
||||||
|
|
||||||
readingListActions: Array<ActionItem<ReadingList>> = [];
|
|
||||||
|
|
||||||
bookmarkActions: Array<ActionItem<Series>> = [];
|
|
||||||
|
|
||||||
private personActions: Array<ActionItem<Person>> = [];
|
private personActions: Array<ActionItem<Person>> = [];
|
||||||
|
private sideNavStreamActions: Array<ActionItem<SideNavStream>> = [];
|
||||||
sideNavStreamActions: Array<ActionItem<SideNavStream>> = [];
|
private smartFilterActions: Array<ActionItem<SmartFilter>> = [];
|
||||||
smartFilterActions: Array<ActionItem<SmartFilter>> = [];
|
private sideNavHomeActions: Array<ActionItem<void>> = [];
|
||||||
|
|
||||||
sideNavHomeActions: Array<ActionItem<void>> = [];
|
|
||||||
|
|
||||||
isAdmin = false;
|
|
||||||
|
|
||||||
|
|
||||||
constructor(private accountService: AccountService, private deviceService: DeviceService) {
|
constructor(private accountService: AccountService, private deviceService: DeviceService) {
|
||||||
this.accountService.currentUser$.subscribe((user) => {
|
this.accountService.currentUser$.subscribe((_) => {
|
||||||
if (user) {
|
|
||||||
this.isAdmin = this.accountService.hasAdminRole(user);
|
|
||||||
} else {
|
|
||||||
this._resetActions();
|
|
||||||
return; // If user is logged out, we don't need to do anything
|
|
||||||
}
|
|
||||||
|
|
||||||
this._resetActions();
|
this._resetActions();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getLibraryActions(callback: ActionCallback<Library>) {
|
getLibraryActions(callback: ActionCallback<Library>, shouldRenderFunc: ActionShouldRenderFunc<Library> = this.dummyShouldRender) {
|
||||||
return this.applyCallbackToList(this.libraryActions, callback);
|
return this.applyCallbackToList(this.libraryActions, callback, shouldRenderFunc) as ActionItem<Library>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
getSeriesActions(callback: ActionCallback<Series>) {
|
getSeriesActions(callback: ActionCallback<Series>, shouldRenderFunc: ActionShouldRenderFunc<Series> = this.basicReadRender) {
|
||||||
return this.applyCallbackToList(this.seriesActions, callback);
|
return this.applyCallbackToList(this.seriesActions, callback, shouldRenderFunc);
|
||||||
}
|
}
|
||||||
|
|
||||||
getSideNavStreamActions(callback: ActionCallback<SideNavStream>) {
|
getSideNavStreamActions(callback: ActionCallback<SideNavStream>, shouldRenderFunc: ActionShouldRenderFunc<SideNavStream> = this.dummyShouldRender) {
|
||||||
return this.applyCallbackToList(this.sideNavStreamActions, callback);
|
return this.applyCallbackToList(this.sideNavStreamActions, callback, shouldRenderFunc);
|
||||||
}
|
}
|
||||||
|
|
||||||
getSmartFilterActions(callback: ActionCallback<SmartFilter>) {
|
getSmartFilterActions(callback: ActionCallback<SmartFilter>, shouldRenderFunc: ActionShouldRenderFunc<SmartFilter> = this.dummyShouldRender) {
|
||||||
return this.applyCallbackToList(this.smartFilterActions, callback);
|
return this.applyCallbackToList(this.smartFilterActions, callback, shouldRenderFunc);
|
||||||
}
|
}
|
||||||
|
|
||||||
getVolumeActions(callback: ActionCallback<Volume>) {
|
getVolumeActions(callback: ActionCallback<Volume>, shouldRenderFunc: ActionShouldRenderFunc<Volume> = this.basicReadRender) {
|
||||||
return this.applyCallbackToList(this.volumeActions, callback);
|
return this.applyCallbackToList(this.volumeActions, callback, shouldRenderFunc);
|
||||||
}
|
}
|
||||||
|
|
||||||
getChapterActions(callback: ActionCallback<Chapter>) {
|
getChapterActions(callback: ActionCallback<Chapter>, shouldRenderFunc: ActionShouldRenderFunc<Chapter> = this.basicReadRender) {
|
||||||
return this.applyCallbackToList(this.chapterActions, callback);
|
return this.applyCallbackToList(this.chapterActions, callback, shouldRenderFunc);
|
||||||
}
|
}
|
||||||
|
|
||||||
getCollectionTagActions(callback: ActionCallback<UserCollection>) {
|
getCollectionTagActions(callback: ActionCallback<UserCollection>, shouldRenderFunc: ActionShouldRenderFunc<UserCollection> = this.dummyShouldRender) {
|
||||||
return this.applyCallbackToList(this.collectionTagActions, callback);
|
return this.applyCallbackToList(this.collectionTagActions, callback, shouldRenderFunc);
|
||||||
}
|
}
|
||||||
|
|
||||||
getReadingListActions(callback: ActionCallback<ReadingList>) {
|
getReadingListActions(callback: ActionCallback<ReadingList>, shouldRenderFunc: ActionShouldRenderFunc<ReadingList> = this.dummyShouldRender) {
|
||||||
return this.applyCallbackToList(this.readingListActions, callback);
|
return this.applyCallbackToList(this.readingListActions, callback, shouldRenderFunc);
|
||||||
}
|
}
|
||||||
|
|
||||||
getBookmarkActions(callback: ActionCallback<Series>) {
|
getBookmarkActions(callback: ActionCallback<Series>, shouldRenderFunc: ActionShouldRenderFunc<Series> = this.dummyShouldRender) {
|
||||||
return this.applyCallbackToList(this.bookmarkActions, callback);
|
return this.applyCallbackToList(this.bookmarkActions, callback, shouldRenderFunc);
|
||||||
}
|
}
|
||||||
|
|
||||||
getPersonActions(callback: ActionCallback<Person>) {
|
getPersonActions(callback: ActionCallback<Person>, shouldRenderFunc: ActionShouldRenderFunc<Person> = this.dummyShouldRender) {
|
||||||
return this.applyCallbackToList(this.personActions, callback);
|
return this.applyCallbackToList(this.personActions, callback, shouldRenderFunc);
|
||||||
}
|
}
|
||||||
|
|
||||||
getSideNavHomeActions(callback: ActionCallback<void>) {
|
getSideNavHomeActions(callback: ActionCallback<void>, shouldRenderFunc: ActionShouldRenderFunc<void> = this.dummyShouldRender) {
|
||||||
return this.applyCallbackToList(this.sideNavHomeActions, callback);
|
return this.applyCallbackToList(this.sideNavHomeActions, callback, shouldRenderFunc);
|
||||||
}
|
}
|
||||||
|
|
||||||
dummyCallback(action: ActionItem<any>, data: any) {}
|
dummyCallback(action: ActionItem<any>, entity: any) {}
|
||||||
|
dummyShouldRender(action: ActionItem<any>, entity: any, user: User) {return true;}
|
||||||
|
basicReadRender(action: ActionItem<any>, entity: any, user: User) {
|
||||||
|
if (entity === null || entity === undefined) return true;
|
||||||
|
if (!entity.hasOwnProperty('pagesRead') && !entity.hasOwnProperty('pages')) return true;
|
||||||
|
|
||||||
|
switch (action.action) {
|
||||||
|
case(Action.MarkAsRead):
|
||||||
|
return entity.pagesRead < entity.pages;
|
||||||
|
case(Action.MarkAsUnread):
|
||||||
|
return entity.pagesRead !== 0;
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
filterSendToAction(actions: Array<ActionItem<Chapter>>, chapter: Chapter) {
|
filterSendToAction(actions: Array<ActionItem<Chapter>>, chapter: Chapter) {
|
||||||
// if (chapter.files.filter(f => f.format === MangaFormat.EPUB || f.format === MangaFormat.PDF).length !== chapter.files.length) {
|
// if (chapter.files.filter(f => f.format === MangaFormat.EPUB || f.format === MangaFormat.PDF).length !== chapter.files.length) {
|
||||||
|
|
@ -275,7 +291,7 @@ export class ActionFactoryService {
|
||||||
return tasks.filter(t => !blacklist.includes(t.action));
|
return tasks.filter(t => !blacklist.includes(t.action));
|
||||||
}
|
}
|
||||||
|
|
||||||
getBulkLibraryActions(callback: ActionCallback<Library>) {
|
getBulkLibraryActions(callback: ActionCallback<Library>, shouldRenderFunc: ActionShouldRenderFunc<Library> = this.dummyShouldRender) {
|
||||||
|
|
||||||
// Scan is currently not supported due to the backend not being able to handle it yet
|
// Scan is currently not supported due to the backend not being able to handle it yet
|
||||||
const actions = this.flattenActions<Library>(this.libraryActions).filter(a => {
|
const actions = this.flattenActions<Library>(this.libraryActions).filter(a => {
|
||||||
|
|
@ -289,11 +305,13 @@ export class ActionFactoryService {
|
||||||
dynamicList: undefined,
|
dynamicList: undefined,
|
||||||
action: Action.CopySettings,
|
action: Action.CopySettings,
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: shouldRenderFunc,
|
||||||
children: [],
|
children: [],
|
||||||
|
requiredRoles: [Role.Admin],
|
||||||
requiresAdmin: true,
|
requiresAdmin: true,
|
||||||
title: 'copy-settings'
|
title: 'copy-settings'
|
||||||
})
|
})
|
||||||
return this.applyCallbackToList(actions, callback);
|
return this.applyCallbackToList(actions, callback, shouldRenderFunc) as ActionItem<Library>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
flattenActions<T>(actions: Array<ActionItem<T>>): Array<ActionItem<T>> {
|
flattenActions<T>(actions: Array<ActionItem<T>>): Array<ActionItem<T>> {
|
||||||
|
|
@ -319,7 +337,9 @@ export class ActionFactoryService {
|
||||||
title: 'scan-library',
|
title: 'scan-library',
|
||||||
description: 'scan-library-tooltip',
|
description: 'scan-library-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: true,
|
requiresAdmin: true,
|
||||||
|
requiredRoles: [Role.Admin],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -327,14 +347,18 @@ export class ActionFactoryService {
|
||||||
title: 'others',
|
title: 'others',
|
||||||
description: '',
|
description: '',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: true,
|
requiresAdmin: true,
|
||||||
|
requiredRoles: [Role.Admin],
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
action: Action.RefreshMetadata,
|
action: Action.RefreshMetadata,
|
||||||
title: 'refresh-covers',
|
title: 'refresh-covers',
|
||||||
description: 'refresh-covers-tooltip',
|
description: 'refresh-covers-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: true,
|
requiresAdmin: true,
|
||||||
|
requiredRoles: [Role.Admin],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -342,7 +366,9 @@ export class ActionFactoryService {
|
||||||
title: 'generate-colorscape',
|
title: 'generate-colorscape',
|
||||||
description: 'generate-colorscape-tooltip',
|
description: 'generate-colorscape-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: true,
|
requiresAdmin: true,
|
||||||
|
requiredRoles: [Role.Admin],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -350,7 +376,9 @@ export class ActionFactoryService {
|
||||||
title: 'analyze-files',
|
title: 'analyze-files',
|
||||||
description: 'analyze-files-tooltip',
|
description: 'analyze-files-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: true,
|
requiresAdmin: true,
|
||||||
|
requiredRoles: [Role.Admin],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -358,7 +386,9 @@ export class ActionFactoryService {
|
||||||
title: 'delete',
|
title: 'delete',
|
||||||
description: 'delete-tooltip',
|
description: 'delete-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: true,
|
requiresAdmin: true,
|
||||||
|
requiredRoles: [Role.Admin],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -368,7 +398,9 @@ export class ActionFactoryService {
|
||||||
title: 'settings',
|
title: 'settings',
|
||||||
description: 'settings-tooltip',
|
description: 'settings-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: true,
|
requiresAdmin: true,
|
||||||
|
requiredRoles: [Role.Admin],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
@ -379,7 +411,9 @@ export class ActionFactoryService {
|
||||||
title: 'edit',
|
title: 'edit',
|
||||||
description: 'edit-tooltip',
|
description: 'edit-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -387,7 +421,9 @@ export class ActionFactoryService {
|
||||||
title: 'delete',
|
title: 'delete',
|
||||||
description: 'delete-tooltip',
|
description: 'delete-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
class: 'danger',
|
class: 'danger',
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
|
|
@ -396,7 +432,9 @@ export class ActionFactoryService {
|
||||||
title: 'promote',
|
title: 'promote',
|
||||||
description: 'promote-tooltip',
|
description: 'promote-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -404,7 +442,9 @@ export class ActionFactoryService {
|
||||||
title: 'unpromote',
|
title: 'unpromote',
|
||||||
description: 'unpromote-tooltip',
|
description: 'unpromote-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
@ -415,7 +455,9 @@ export class ActionFactoryService {
|
||||||
title: 'mark-as-read',
|
title: 'mark-as-read',
|
||||||
description: 'mark-as-read-tooltip',
|
description: 'mark-as-read-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -423,7 +465,9 @@ export class ActionFactoryService {
|
||||||
title: 'mark-as-unread',
|
title: 'mark-as-unread',
|
||||||
description: 'mark-as-unread-tooltip',
|
description: 'mark-as-unread-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -431,7 +475,9 @@ export class ActionFactoryService {
|
||||||
title: 'scan-series',
|
title: 'scan-series',
|
||||||
description: 'scan-series-tooltip',
|
description: 'scan-series-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: true,
|
requiresAdmin: true,
|
||||||
|
requiredRoles: [Role.Admin],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -439,14 +485,18 @@ export class ActionFactoryService {
|
||||||
title: 'add-to',
|
title: 'add-to',
|
||||||
description: '',
|
description: '',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
action: Action.AddToWantToReadList,
|
action: Action.AddToWantToReadList,
|
||||||
title: 'add-to-want-to-read',
|
title: 'add-to-want-to-read',
|
||||||
description: 'add-to-want-to-read-tooltip',
|
description: 'add-to-want-to-read-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -454,7 +504,9 @@ export class ActionFactoryService {
|
||||||
title: 'remove-from-want-to-read',
|
title: 'remove-from-want-to-read',
|
||||||
description: 'remove-to-want-to-read-tooltip',
|
description: 'remove-to-want-to-read-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -462,7 +514,9 @@ export class ActionFactoryService {
|
||||||
title: 'add-to-reading-list',
|
title: 'add-to-reading-list',
|
||||||
description: 'add-to-reading-list-tooltip',
|
description: 'add-to-reading-list-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -470,26 +524,11 @@ export class ActionFactoryService {
|
||||||
title: 'add-to-collection',
|
title: 'add-to-collection',
|
||||||
description: 'add-to-collection-tooltip',
|
description: 'add-to-collection-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
|
|
||||||
// {
|
|
||||||
// action: Action.AddToScrobbleHold,
|
|
||||||
// title: 'add-to-scrobble-hold',
|
|
||||||
// description: 'add-to-scrobble-hold-tooltip',
|
|
||||||
// callback: this.dummyCallback,
|
|
||||||
// requiresAdmin: true,
|
|
||||||
// children: [],
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// action: Action.RemoveFromScrobbleHold,
|
|
||||||
// title: 'remove-from-scrobble-hold',
|
|
||||||
// description: 'remove-from-scrobble-hold-tooltip',
|
|
||||||
// callback: this.dummyCallback,
|
|
||||||
// requiresAdmin: true,
|
|
||||||
// children: [],
|
|
||||||
// },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -497,14 +536,18 @@ export class ActionFactoryService {
|
||||||
title: 'send-to',
|
title: 'send-to',
|
||||||
description: 'send-to-tooltip',
|
description: 'send-to-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
action: Action.SendTo,
|
action: Action.SendTo,
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
dynamicList: this.deviceService.devices$.pipe(map((devices: Array<Device>) => devices.map(d => {
|
dynamicList: this.deviceService.devices$.pipe(map((devices: Array<Device>) => devices.map(d => {
|
||||||
return {'title': d.name, 'data': d};
|
return {'title': d.name, 'data': d};
|
||||||
}), shareReplay())),
|
}), shareReplay())),
|
||||||
|
|
@ -517,14 +560,18 @@ export class ActionFactoryService {
|
||||||
title: 'others',
|
title: 'others',
|
||||||
description: '',
|
description: '',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: true,
|
requiresAdmin: true,
|
||||||
|
requiredRoles: [],
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
action: Action.RefreshMetadata,
|
action: Action.RefreshMetadata,
|
||||||
title: 'refresh-covers',
|
title: 'refresh-covers',
|
||||||
description: 'refresh-covers-tooltip',
|
description: 'refresh-covers-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: true,
|
requiresAdmin: true,
|
||||||
|
requiredRoles: [Role.Admin],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -532,7 +579,9 @@ export class ActionFactoryService {
|
||||||
title: 'generate-colorscape',
|
title: 'generate-colorscape',
|
||||||
description: 'generate-colorscape-tooltip',
|
description: 'generate-colorscape-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: true,
|
requiresAdmin: true,
|
||||||
|
requiredRoles: [Role.Admin],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -540,7 +589,9 @@ export class ActionFactoryService {
|
||||||
title: 'analyze-files',
|
title: 'analyze-files',
|
||||||
description: 'analyze-files-tooltip',
|
description: 'analyze-files-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: true,
|
requiresAdmin: true,
|
||||||
|
requiredRoles: [Role.Admin],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -548,7 +599,9 @@ export class ActionFactoryService {
|
||||||
title: 'delete',
|
title: 'delete',
|
||||||
description: 'delete-tooltip',
|
description: 'delete-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: true,
|
requiresAdmin: true,
|
||||||
|
requiredRoles: [Role.Admin],
|
||||||
class: 'danger',
|
class: 'danger',
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
|
|
@ -559,7 +612,9 @@ export class ActionFactoryService {
|
||||||
title: 'match',
|
title: 'match',
|
||||||
description: 'match-tooltip',
|
description: 'match-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: true,
|
requiresAdmin: true,
|
||||||
|
requiredRoles: [Role.Admin],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -567,7 +622,9 @@ export class ActionFactoryService {
|
||||||
title: 'download',
|
title: 'download',
|
||||||
description: 'download-tooltip',
|
description: 'download-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [Role.Download],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -575,7 +632,9 @@ export class ActionFactoryService {
|
||||||
title: 'edit',
|
title: 'edit',
|
||||||
description: 'edit-tooltip',
|
description: 'edit-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: true,
|
requiresAdmin: true,
|
||||||
|
requiredRoles: [Role.Admin],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
@ -586,7 +645,9 @@ export class ActionFactoryService {
|
||||||
title: 'read-incognito',
|
title: 'read-incognito',
|
||||||
description: 'read-incognito-tooltip',
|
description: 'read-incognito-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -594,7 +655,9 @@ export class ActionFactoryService {
|
||||||
title: 'mark-as-read',
|
title: 'mark-as-read',
|
||||||
description: 'mark-as-read-tooltip',
|
description: 'mark-as-read-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -602,7 +665,9 @@ export class ActionFactoryService {
|
||||||
title: 'mark-as-unread',
|
title: 'mark-as-unread',
|
||||||
description: 'mark-as-unread-tooltip',
|
description: 'mark-as-unread-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -610,14 +675,18 @@ export class ActionFactoryService {
|
||||||
title: 'add-to',
|
title: 'add-to',
|
||||||
description: '=',
|
description: '=',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
action: Action.AddToReadingList,
|
action: Action.AddToReadingList,
|
||||||
title: 'add-to-reading-list',
|
title: 'add-to-reading-list',
|
||||||
description: 'add-to-reading-list-tooltip',
|
description: 'add-to-reading-list-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
children: [],
|
children: [],
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -627,14 +696,18 @@ export class ActionFactoryService {
|
||||||
title: 'send-to',
|
title: 'send-to',
|
||||||
description: 'send-to-tooltip',
|
description: 'send-to-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
action: Action.SendTo,
|
action: Action.SendTo,
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
dynamicList: this.deviceService.devices$.pipe(map((devices: Array<Device>) => devices.map(d => {
|
dynamicList: this.deviceService.devices$.pipe(map((devices: Array<Device>) => devices.map(d => {
|
||||||
return {'title': d.name, 'data': d};
|
return {'title': d.name, 'data': d};
|
||||||
}), shareReplay())),
|
}), shareReplay())),
|
||||||
|
|
@ -647,14 +720,18 @@ export class ActionFactoryService {
|
||||||
title: 'others',
|
title: 'others',
|
||||||
description: '',
|
description: '',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
action: Action.Delete,
|
action: Action.Delete,
|
||||||
title: 'delete',
|
title: 'delete',
|
||||||
description: 'delete-tooltip',
|
description: 'delete-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: true,
|
requiresAdmin: true,
|
||||||
|
requiredRoles: [Role.Admin],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -662,7 +739,9 @@ export class ActionFactoryService {
|
||||||
title: 'download',
|
title: 'download',
|
||||||
description: 'download-tooltip',
|
description: 'download-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
@ -672,7 +751,9 @@ export class ActionFactoryService {
|
||||||
title: 'details',
|
title: 'details',
|
||||||
description: 'edit-tooltip',
|
description: 'edit-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
@ -683,7 +764,9 @@ export class ActionFactoryService {
|
||||||
title: 'read-incognito',
|
title: 'read-incognito',
|
||||||
description: 'read-incognito-tooltip',
|
description: 'read-incognito-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -691,7 +774,9 @@ export class ActionFactoryService {
|
||||||
title: 'mark-as-read',
|
title: 'mark-as-read',
|
||||||
description: 'mark-as-read-tooltip',
|
description: 'mark-as-read-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -699,7 +784,9 @@ export class ActionFactoryService {
|
||||||
title: 'mark-as-unread',
|
title: 'mark-as-unread',
|
||||||
description: 'mark-as-unread-tooltip',
|
description: 'mark-as-unread-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -707,14 +794,18 @@ export class ActionFactoryService {
|
||||||
title: 'add-to',
|
title: 'add-to',
|
||||||
description: '',
|
description: '',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
action: Action.AddToReadingList,
|
action: Action.AddToReadingList,
|
||||||
title: 'add-to-reading-list',
|
title: 'add-to-reading-list',
|
||||||
description: 'add-to-reading-list-tooltip',
|
description: 'add-to-reading-list-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
children: [],
|
children: [],
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -724,14 +815,18 @@ export class ActionFactoryService {
|
||||||
title: 'send-to',
|
title: 'send-to',
|
||||||
description: 'send-to-tooltip',
|
description: 'send-to-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
action: Action.SendTo,
|
action: Action.SendTo,
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
dynamicList: this.deviceService.devices$.pipe(map((devices: Array<Device>) => devices.map(d => {
|
dynamicList: this.deviceService.devices$.pipe(map((devices: Array<Device>) => devices.map(d => {
|
||||||
return {'title': d.name, 'data': d};
|
return {'title': d.name, 'data': d};
|
||||||
}), shareReplay())),
|
}), shareReplay())),
|
||||||
|
|
@ -745,14 +840,18 @@ export class ActionFactoryService {
|
||||||
title: 'others',
|
title: 'others',
|
||||||
description: '',
|
description: '',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
action: Action.Delete,
|
action: Action.Delete,
|
||||||
title: 'delete',
|
title: 'delete',
|
||||||
description: 'delete-tooltip',
|
description: 'delete-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: true,
|
requiresAdmin: true,
|
||||||
|
requiredRoles: [Role.Admin],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -760,7 +859,9 @@ export class ActionFactoryService {
|
||||||
title: 'download',
|
title: 'download',
|
||||||
description: 'download-tooltip',
|
description: 'download-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [Role.Download],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
@ -770,7 +871,9 @@ export class ActionFactoryService {
|
||||||
title: 'edit',
|
title: 'edit',
|
||||||
description: 'edit-tooltip',
|
description: 'edit-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
@ -781,7 +884,9 @@ export class ActionFactoryService {
|
||||||
title: 'edit',
|
title: 'edit',
|
||||||
description: 'edit-tooltip',
|
description: 'edit-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -789,7 +894,9 @@ export class ActionFactoryService {
|
||||||
title: 'delete',
|
title: 'delete',
|
||||||
description: 'delete-tooltip',
|
description: 'delete-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
class: 'danger',
|
class: 'danger',
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
|
|
@ -798,7 +905,9 @@ export class ActionFactoryService {
|
||||||
title: 'promote',
|
title: 'promote',
|
||||||
description: 'promote-tooltip',
|
description: 'promote-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -806,7 +915,9 @@ export class ActionFactoryService {
|
||||||
title: 'unpromote',
|
title: 'unpromote',
|
||||||
description: 'unpromote-tooltip',
|
description: 'unpromote-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
@ -817,7 +928,19 @@ export class ActionFactoryService {
|
||||||
title: 'edit',
|
title: 'edit',
|
||||||
description: 'edit-person-tooltip',
|
description: 'edit-person-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: true,
|
requiresAdmin: true,
|
||||||
|
requiredRoles: [Role.Admin],
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: Action.Merge,
|
||||||
|
title: 'merge',
|
||||||
|
description: 'merge-person-tooltip',
|
||||||
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
|
requiresAdmin: true,
|
||||||
|
requiredRoles: [Role.Admin],
|
||||||
children: [],
|
children: [],
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
@ -828,7 +951,9 @@ export class ActionFactoryService {
|
||||||
title: 'view-series',
|
title: 'view-series',
|
||||||
description: 'view-series-tooltip',
|
description: 'view-series-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -836,7 +961,9 @@ export class ActionFactoryService {
|
||||||
title: 'download',
|
title: 'download',
|
||||||
description: 'download-tooltip',
|
description: 'download-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -844,8 +971,10 @@ export class ActionFactoryService {
|
||||||
title: 'clear',
|
title: 'clear',
|
||||||
description: 'delete-tooltip',
|
description: 'delete-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
class: 'danger',
|
class: 'danger',
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
@ -856,7 +985,9 @@ export class ActionFactoryService {
|
||||||
title: 'mark-visible',
|
title: 'mark-visible',
|
||||||
description: 'mark-visible-tooltip',
|
description: 'mark-visible-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -864,7 +995,9 @@ export class ActionFactoryService {
|
||||||
title: 'mark-invisible',
|
title: 'mark-invisible',
|
||||||
description: 'mark-invisible-tooltip',
|
description: 'mark-invisible-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
@ -875,7 +1008,9 @@ export class ActionFactoryService {
|
||||||
title: 'rename',
|
title: 'rename',
|
||||||
description: 'rename-tooltip',
|
description: 'rename-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -883,7 +1018,9 @@ export class ActionFactoryService {
|
||||||
title: 'delete',
|
title: 'delete',
|
||||||
description: 'delete-tooltip',
|
description: 'delete-tooltip',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
@ -894,7 +1031,9 @@ export class ActionFactoryService {
|
||||||
title: 'reorder',
|
title: 'reorder',
|
||||||
description: '',
|
description: '',
|
||||||
callback: this.dummyCallback,
|
callback: this.dummyCallback,
|
||||||
|
shouldRender: this.dummyShouldRender,
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
children: [],
|
children: [],
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -902,21 +1041,24 @@ export class ActionFactoryService {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyCallback(action: ActionItem<any>, callback: (action: ActionItem<any>, data: any) => void) {
|
private applyCallback(action: ActionItem<any>, callback: ActionCallback<any>, shouldRenderFunc: ActionShouldRenderFunc<any>) {
|
||||||
action.callback = callback;
|
action.callback = callback;
|
||||||
|
action.shouldRender = shouldRenderFunc;
|
||||||
|
|
||||||
if (action.children === null || action.children?.length === 0) return;
|
if (action.children === null || action.children?.length === 0) return;
|
||||||
|
|
||||||
action.children?.forEach((childAction) => {
|
action.children?.forEach((childAction) => {
|
||||||
this.applyCallback(childAction, callback);
|
this.applyCallback(childAction, callback, shouldRenderFunc);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public applyCallbackToList(list: Array<ActionItem<any>>, callback: (action: ActionItem<any>, data: any) => void): Array<ActionItem<any>> {
|
public applyCallbackToList(list: Array<ActionItem<any>>,
|
||||||
|
callback: ActionCallback<any>,
|
||||||
|
shouldRenderFunc: ActionShouldRenderFunc<any> = this.dummyShouldRender): Array<ActionItem<any>> {
|
||||||
const actions = list.map((a) => {
|
const actions = list.map((a) => {
|
||||||
return { ...a };
|
return { ...a };
|
||||||
});
|
});
|
||||||
actions.forEach((action) => this.applyCallback(action, callback));
|
actions.forEach((action) => this.applyCallback(action, callback, shouldRenderFunc));
|
||||||
return actions;
|
return actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -473,8 +473,7 @@ export class ActionService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteMultipleVolumes(volumes: Array<Volume>, callback?: BooleanActionCallback) {
|
async deleteMultipleVolumes(volumes: Array<Volume>, callback?: BooleanActionCallback) {
|
||||||
// TODO: Change translation key back to "toasts.confirm-delete-multiple-volumes"
|
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-multiple-volumes', {count: volumes.length}))) return;
|
||||||
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-multiple-chapters', {count: volumes.length}))) return;
|
|
||||||
|
|
||||||
this.volumeService.deleteMultipleVolumes(volumes.map(v => v.id)).subscribe((success) => {
|
this.volumeService.deleteMultipleVolumes(volumes.map(v => v.id)).subscribe((success) => {
|
||||||
if (callback) {
|
if (callback) {
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,11 @@ 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});
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { HttpClient } from '@angular/common/http';
|
import {HttpClient, HttpParams} from '@angular/common/http';
|
||||||
import {Inject, inject, Injectable} from '@angular/core';
|
import {Inject, inject, Injectable} from '@angular/core';
|
||||||
import {environment} from 'src/environments/environment';
|
import {environment} from 'src/environments/environment';
|
||||||
import {UserReadStatistics} from '../statistics/_models/user-read-statistics';
|
import {UserReadStatistics} from '../statistics/_models/user-read-statistics';
|
||||||
import {PublicationStatusPipe} from '../_pipes/publication-status.pipe';
|
import {PublicationStatusPipe} from '../_pipes/publication-status.pipe';
|
||||||
import {asyncScheduler, finalize, map, tap} from 'rxjs';
|
import {asyncScheduler, map} from 'rxjs';
|
||||||
import {MangaFormatPipe} from '../_pipes/manga-format.pipe';
|
import {MangaFormatPipe} from '../_pipes/manga-format.pipe';
|
||||||
import {FileExtensionBreakdown} from '../statistics/_models/file-breakdown';
|
import {FileExtensionBreakdown} from '../statistics/_models/file-breakdown';
|
||||||
import {TopUserRead} from '../statistics/_models/top-reads';
|
import {TopUserRead} from '../statistics/_models/top-reads';
|
||||||
|
|
@ -14,7 +14,6 @@ import { PublicationStatus } from '../_models/metadata/publication-status';
|
||||||
import {MangaFormat} from '../_models/manga-format';
|
import {MangaFormat} from '../_models/manga-format';
|
||||||
import {TextResonse} from '../_types/text-response';
|
import {TextResonse} from '../_types/text-response';
|
||||||
import {TranslocoService} from "@jsverse/transloco";
|
import {TranslocoService} from "@jsverse/transloco";
|
||||||
import {KavitaPlusMetadataBreakdown} from "../statistics/_models/kavitaplus-metadata-breakdown";
|
|
||||||
import {throttleTime} from "rxjs/operators";
|
import {throttleTime} from "rxjs/operators";
|
||||||
import {DEBOUNCE_TIME} from "../shared/_services/download.service";
|
import {DEBOUNCE_TIME} from "../shared/_services/download.service";
|
||||||
import {download} from "../shared/_models/download";
|
import {download} from "../shared/_models/download";
|
||||||
|
|
@ -44,11 +43,14 @@ export class StatisticsService {
|
||||||
constructor(private httpClient: HttpClient, @Inject(SAVER) private save: Saver) { }
|
constructor(private httpClient: HttpClient, @Inject(SAVER) private save: Saver) { }
|
||||||
|
|
||||||
getUserStatistics(userId: number, libraryIds: Array<number> = []) {
|
getUserStatistics(userId: number, libraryIds: Array<number> = []) {
|
||||||
// TODO: Convert to httpParams object
|
const url = `${this.baseUrl}stats/user/${userId}/read`;
|
||||||
let url = 'stats/user/' + userId + '/read';
|
|
||||||
if (libraryIds.length > 0) url += '?libraryIds=' + libraryIds.join(',');
|
|
||||||
|
|
||||||
return this.httpClient.get<UserReadStatistics>(this.baseUrl + url);
|
let params = new HttpParams();
|
||||||
|
if (libraryIds.length > 0) {
|
||||||
|
params = params.set('libraryIds', libraryIds.join(','));
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.httpClient.get<UserReadStatistics>(url, { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
getServerStatistics() {
|
getServerStatistics() {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
<ng-container *transloco="let t; read: 'actionable'">
|
<ng-container *transloco="let t; read: 'actionable'">
|
||||||
<div class="modal-container">
|
<div class="modal-container">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h4 class="modal-title">{{t('title')}}</h4>
|
<h4 class="modal-title">
|
||||||
|
{{t('title')}}
|
||||||
|
</h4>
|
||||||
<button type="button" class="btn-close" aria-label="close" (click)="modal.close()"></button>
|
<button type="button" class="btn-close" aria-label="close" (click)="modal.close()"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body scrollable-modal">
|
<div class="modal-body scrollable-modal">
|
||||||
|
|
@ -12,8 +14,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="d-grid gap-2">
|
<div class="d-grid gap-2">
|
||||||
|
|
||||||
|
|
||||||
@for (action of currentItems; track action.title) {
|
@for (action of currentItems; track action.title) {
|
||||||
@if (willRenderAction(action)) {
|
@if (willRenderAction(action)) {
|
||||||
<button class="btn btn-outline-primary text-start d-flex justify-content-between align-items-center w-100"
|
<button class="btn btn-outline-primary text-start d-flex justify-content-between align-items-center w-100"
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,18 @@
|
||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
ChangeDetectorRef,
|
ChangeDetectorRef,
|
||||||
Component, DestroyRef,
|
Component,
|
||||||
|
DestroyRef,
|
||||||
EventEmitter,
|
EventEmitter,
|
||||||
inject,
|
inject,
|
||||||
Input,
|
Input,
|
||||||
OnInit,
|
OnInit,
|
||||||
Output
|
Output
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import {NgClass} from "@angular/common";
|
|
||||||
import {translate, TranslocoDirective} from "@jsverse/transloco";
|
import {translate, TranslocoDirective} from "@jsverse/transloco";
|
||||||
import {Breakpoint, UtilityService} from "../../shared/_services/utility.service";
|
import {Breakpoint, UtilityService} from "../../shared/_services/utility.service";
|
||||||
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
|
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
|
||||||
import {Action, ActionItem} from "../../_services/action-factory.service";
|
import {ActionableEntity, ActionItem} from "../../_services/action-factory.service";
|
||||||
import {AccountService} from "../../_services/account.service";
|
import {AccountService} from "../../_services/account.service";
|
||||||
import {tap} from "rxjs";
|
import {tap} from "rxjs";
|
||||||
import {User} from "../../_models/user";
|
import {User} from "../../_models/user";
|
||||||
|
|
@ -36,6 +36,7 @@ export class ActionableModalComponent implements OnInit {
|
||||||
protected readonly destroyRef = inject(DestroyRef);
|
protected readonly destroyRef = inject(DestroyRef);
|
||||||
protected readonly Breakpoint = Breakpoint;
|
protected readonly Breakpoint = Breakpoint;
|
||||||
|
|
||||||
|
@Input() entity: ActionableEntity = null;
|
||||||
@Input() actions: ActionItem<any>[] = [];
|
@Input() actions: ActionItem<any>[] = [];
|
||||||
@Input() willRenderAction!: (action: ActionItem<any>) => boolean;
|
@Input() willRenderAction!: (action: ActionItem<any>) => boolean;
|
||||||
@Input() shouldRenderSubMenu!: (action: ActionItem<any>, dynamicList: null | Array<any>) => boolean;
|
@Input() shouldRenderSubMenu!: (action: ActionItem<any>, dynamicList: null | Array<any>) => boolean;
|
||||||
|
|
|
||||||
|
|
@ -25,19 +25,25 @@
|
||||||
@for(dynamicItem of dList; track dynamicItem.title) {
|
@for(dynamicItem of dList; track dynamicItem.title) {
|
||||||
<button ngbDropdownItem (click)="performDynamicClick($event, action, dynamicItem)">{{dynamicItem.title}}</button>
|
<button ngbDropdownItem (click)="performDynamicClick($event, action, dynamicItem)">{{dynamicItem.title}}</button>
|
||||||
}
|
}
|
||||||
} @else if (willRenderAction(action)) {
|
} @else if (willRenderAction(action, this.currentUser!)) {
|
||||||
<button ngbDropdownItem (click)="performAction($event, action)" (mouseover)="closeAllSubmenus()">{{t(action.title)}}</button>
|
<button ngbDropdownItem (click)="performAction($event, action)">{{t(action.title)}}</button>
|
||||||
}
|
}
|
||||||
} @else {
|
} @else {
|
||||||
@if (shouldRenderSubMenu(action, action.children?.[0].dynamicList | async)) {
|
@if (shouldRenderSubMenu(action, action.children?.[0].dynamicList | async) && hasRenderableChildren(action, this.currentUser!)) {
|
||||||
<!-- Submenu items -->
|
<!-- Submenu items -->
|
||||||
<div ngbDropdown #subMenuHover="ngbDropdown" placement="right left"
|
<div ngbDropdown #subMenuHover="ngbDropdown" placement="right left"
|
||||||
(click)="preventEvent($event); openSubmenu(action.title, subMenuHover)"
|
(click)="openSubmenu(action.title, subMenuHover)"
|
||||||
(mouseover)="preventEvent($event); openSubmenu(action.title, subMenuHover)"
|
(mouseenter)="openSubmenu(action.title, subMenuHover)"
|
||||||
(mouseleave)="preventEvent($event)">
|
(mouseover)="preventEvent($event)"
|
||||||
@if (willRenderAction(action)) {
|
class="submenu-wrapper">
|
||||||
<button id="actions-{{action.title}}" class="submenu-toggle" ngbDropdownToggle>{{t(action.title)}} <i class="fa-solid fa-angle-right submenu-icon"></i></button>
|
|
||||||
|
<!-- Check to ensure the submenu has items -->
|
||||||
|
@if (willRenderAction(action, this.currentUser!)) {
|
||||||
|
<button id="actions-{{action.title}}" class="submenu-toggle" ngbDropdownToggle>
|
||||||
|
{{t(action.title)}} <i class="fa-solid fa-angle-right submenu-icon"></i>
|
||||||
|
</button>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div ngbDropdownMenu attr.aria-labelledby="actions-{{action.title}}">
|
<div ngbDropdownMenu attr.aria-labelledby="actions-{{action.title}}">
|
||||||
<ng-container *ngTemplateOutlet="submenu; context: { list: action.children }"></ng-container>
|
<ng-container *ngTemplateOutlet="submenu; context: { list: action.children }"></ng-container>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,22 @@
|
||||||
content: none !important;
|
content: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.submenu-wrapper {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: -10px;
|
||||||
|
width: 10px;
|
||||||
|
height: 100%;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.submenu-toggle {
|
.submenu-toggle {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
@ -30,9 +46,3 @@
|
||||||
.btn {
|
.btn {
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Robbie added this but it broke most of the uses
|
|
||||||
//.dropdown-toggle {
|
|
||||||
// padding-top: 0;
|
|
||||||
// padding-bottom: 0;
|
|
||||||
//}
|
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,39 @@
|
||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
ChangeDetectorRef,
|
ChangeDetectorRef,
|
||||||
Component, DestroyRef,
|
Component,
|
||||||
|
DestroyRef,
|
||||||
EventEmitter,
|
EventEmitter,
|
||||||
inject,
|
inject,
|
||||||
Input,
|
Input,
|
||||||
|
OnChanges,
|
||||||
|
OnDestroy,
|
||||||
OnInit,
|
OnInit,
|
||||||
Output
|
Output
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import {NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle, NgbModal} from '@ng-bootstrap/ng-bootstrap';
|
import {NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle, NgbModal} from '@ng-bootstrap/ng-bootstrap';
|
||||||
import {AccountService} from 'src/app/_services/account.service';
|
import {AccountService} from 'src/app/_services/account.service';
|
||||||
import { Action, ActionItem } from 'src/app/_services/action-factory.service';
|
import {ActionableEntity, ActionItem} from 'src/app/_services/action-factory.service';
|
||||||
import {AsyncPipe, NgTemplateOutlet} from "@angular/common";
|
import {AsyncPipe, NgTemplateOutlet} from "@angular/common";
|
||||||
import {TranslocoDirective} from "@jsverse/transloco";
|
import {TranslocoDirective} from "@jsverse/transloco";
|
||||||
import {DynamicListPipe} from "./_pipes/dynamic-list.pipe";
|
import {DynamicListPipe} from "./_pipes/dynamic-list.pipe";
|
||||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||||
import {Breakpoint, UtilityService} from "../../shared/_services/utility.service";
|
import {Breakpoint, UtilityService} from "../../shared/_services/utility.service";
|
||||||
import {ActionableModalComponent} from "../actionable-modal/actionable-modal.component";
|
import {ActionableModalComponent} from "../actionable-modal/actionable-modal.component";
|
||||||
|
import {User} from "../../_models/user";
|
||||||
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-card-actionables',
|
selector: 'app-card-actionables',
|
||||||
imports: [NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, DynamicListPipe, TranslocoDirective, AsyncPipe, NgTemplateOutlet],
|
imports: [
|
||||||
|
NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem,
|
||||||
|
DynamicListPipe, TranslocoDirective, AsyncPipe, NgTemplateOutlet
|
||||||
|
],
|
||||||
templateUrl: './card-actionables.component.html',
|
templateUrl: './card-actionables.component.html',
|
||||||
styleUrls: ['./card-actionables.component.scss'],
|
styleUrls: ['./card-actionables.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class CardActionablesComponent implements OnInit {
|
export class CardActionablesComponent implements OnInit, OnChanges, OnDestroy {
|
||||||
|
|
||||||
private readonly cdRef = inject(ChangeDetectorRef);
|
private readonly cdRef = inject(ChangeDetectorRef);
|
||||||
private readonly accountService = inject(AccountService);
|
private readonly accountService = inject(AccountService);
|
||||||
|
|
@ -37,58 +45,69 @@ export class CardActionablesComponent implements OnInit {
|
||||||
|
|
||||||
@Input() iconClass = 'fa-ellipsis-v';
|
@Input() iconClass = 'fa-ellipsis-v';
|
||||||
@Input() btnClass = '';
|
@Input() btnClass = '';
|
||||||
@Input() actions: ActionItem<any>[] = [];
|
@Input() inputActions: ActionItem<any>[] = [];
|
||||||
@Input() labelBy = 'card';
|
@Input() labelBy = 'card';
|
||||||
/**
|
/**
|
||||||
* Text to display as if actionable was a button
|
* Text to display as if actionable was a button
|
||||||
*/
|
*/
|
||||||
@Input() label = '';
|
@Input() label = '';
|
||||||
@Input() disabled: boolean = false;
|
@Input() disabled: boolean = false;
|
||||||
|
|
||||||
|
@Input() entity: ActionableEntity = null;
|
||||||
|
/**
|
||||||
|
* This will only emit when the action is clicked and the entity is null. Otherwise, the entity callback handler will be invoked.
|
||||||
|
*/
|
||||||
@Output() actionHandler = new EventEmitter<ActionItem<any>>();
|
@Output() actionHandler = new EventEmitter<ActionItem<any>>();
|
||||||
|
|
||||||
|
|
||||||
isAdmin: boolean = false;
|
actions: ActionItem<ActionableEntity>[] = [];
|
||||||
canDownload: boolean = false;
|
currentUser: User | undefined = undefined;
|
||||||
canPromote: boolean = false;
|
|
||||||
submenu: {[key: string]: NgbDropdown} = {};
|
submenu: {[key: string]: NgbDropdown} = {};
|
||||||
|
private closeTimeout: any = null;
|
||||||
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((user) => {
|
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((user) => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
this.isAdmin = this.accountService.hasAdminRole(user);
|
this.currentUser = user;
|
||||||
this.canDownload = this.accountService.hasDownloadRole(user);
|
this.actions = this.inputActions.filter(a => this.willRenderAction(a, user!));
|
||||||
this.canPromote = this.accountService.hasPromoteRole(user);
|
|
||||||
|
|
||||||
// We want to avoid an empty menu when user doesn't have access to anything
|
|
||||||
if (!this.isAdmin && this.actions.filter(a => !a.requiresAdmin).length === 0) {
|
|
||||||
this.actions = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ngOnChanges() {
|
||||||
|
this.actions = this.inputActions.filter(a => this.willRenderAction(a, this.currentUser!));
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
this.cancelCloseSubmenus();
|
||||||
|
}
|
||||||
|
|
||||||
preventEvent(event: any) {
|
preventEvent(event: any) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
performAction(event: any, action: ActionItem<any>) {
|
performAction(event: any, action: ActionItem<ActionableEntity>) {
|
||||||
this.preventEvent(event);
|
this.preventEvent(event);
|
||||||
|
|
||||||
if (typeof action.callback === 'function') {
|
if (typeof action.callback === 'function') {
|
||||||
|
if (this.entity === null) {
|
||||||
this.actionHandler.emit(action);
|
this.actionHandler.emit(action);
|
||||||
|
} else {
|
||||||
|
action.callback(action, this.entity);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
willRenderAction(action: ActionItem<any>) {
|
/**
|
||||||
return (action.requiresAdmin && this.isAdmin)
|
* The user has required roles (or no roles defined) and action shouldRender returns true
|
||||||
|| (action.action === Action.Download && (this.canDownload || this.isAdmin))
|
* @param action
|
||||||
|| (!action.requiresAdmin && action.action !== Action.Download)
|
* @param user
|
||||||
|| (action.action === Action.Promote && (this.canPromote || this.isAdmin))
|
*/
|
||||||
|| (action.action === Action.UnPromote && (this.canPromote || this.isAdmin))
|
willRenderAction(action: ActionItem<ActionableEntity>, user: User) {
|
||||||
;
|
return (!action.requiredRoles?.length || this.accountService.hasAnyRole(user, action.requiredRoles)) && action.shouldRender(action, this.entity, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldRenderSubMenu(action: ActionItem<any>, dynamicList: null | Array<any>) {
|
shouldRenderSubMenu(action: ActionItem<any>, dynamicList: null | Array<any>) {
|
||||||
|
|
@ -109,13 +128,41 @@ export class CardActionablesComponent implements OnInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
closeAllSubmenus() {
|
closeAllSubmenus() {
|
||||||
|
// Clear any existing timeout to avoid race conditions
|
||||||
|
if (this.closeTimeout) {
|
||||||
|
clearTimeout(this.closeTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set a new timeout to close submenus after a short delay
|
||||||
|
this.closeTimeout = setTimeout(() => {
|
||||||
Object.keys(this.submenu).forEach(key => {
|
Object.keys(this.submenu).forEach(key => {
|
||||||
this.submenu[key].close();
|
this.submenu[key].close();
|
||||||
delete this.submenu[key];
|
delete this.submenu[key];
|
||||||
});
|
});
|
||||||
|
}, 100); // Small delay to prevent premature closing (dropdown tunneling)
|
||||||
}
|
}
|
||||||
|
|
||||||
performDynamicClick(event: any, action: ActionItem<any>, dynamicItem: any) {
|
cancelCloseSubmenus() {
|
||||||
|
if (this.closeTimeout) {
|
||||||
|
clearTimeout(this.closeTimeout);
|
||||||
|
this.closeTimeout = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hasRenderableChildren(action: ActionItem<ActionableEntity>, user: User): boolean {
|
||||||
|
if (!action.children || action.children.length === 0) return false;
|
||||||
|
|
||||||
|
for (const child of action.children) {
|
||||||
|
const dynamicList = child.dynamicList;
|
||||||
|
if (dynamicList !== undefined) return true; // Dynamic list gets rendered if loaded
|
||||||
|
|
||||||
|
if (this.willRenderAction(child, user)) return true;
|
||||||
|
if (child.children?.length && this.hasRenderableChildren(child, user)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
performDynamicClick(event: any, action: ActionItem<ActionableEntity>, dynamicItem: any) {
|
||||||
action._extra = dynamicItem;
|
action._extra = dynamicItem;
|
||||||
this.performAction(event, action);
|
this.performAction(event, action);
|
||||||
}
|
}
|
||||||
|
|
@ -124,6 +171,7 @@ export class CardActionablesComponent implements OnInit {
|
||||||
this.preventEvent(event);
|
this.preventEvent(event);
|
||||||
|
|
||||||
const ref = this.modalService.open(ActionableModalComponent, {fullscreen: true, centered: true});
|
const ref = this.modalService.open(ActionableModalComponent, {fullscreen: true, centered: true});
|
||||||
|
ref.componentInstance.entity = this.entity;
|
||||||
ref.componentInstance.actions = this.actions;
|
ref.componentInstance.actions = this.actions;
|
||||||
ref.componentInstance.willRenderAction = this.willRenderAction.bind(this);
|
ref.componentInstance.willRenderAction = this.willRenderAction.bind(this);
|
||||||
ref.componentInstance.shouldRenderSubMenu = this.shouldRenderSubMenu.bind(this);
|
ref.componentInstance.shouldRenderSubMenu = this.shouldRenderSubMenu.bind(this);
|
||||||
|
|
|
||||||
|
|
@ -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 + '');
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
<ng-container *transloco="let t; read: 'manage-library'">
|
<ng-container *transloco="let t; read: 'manage-library'">
|
||||||
<div class="position-relative">
|
<div class="position-relative">
|
||||||
<div class="position-absolute custom-position-2">
|
<div class="position-absolute custom-position-2">
|
||||||
<app-card-actionables [actions]="bulkActions" btnClass="btn-outline-primary ms-1" [label]="t('bulk-action-label')" [disabled]="bulkMode" (actionHandler)="handleBulkAction($event, null)">
|
<app-card-actionables [inputActions]="bulkActions" btnClass="btn-outline-primary ms-1" [label]="t('bulk-action-label')"
|
||||||
|
[disabled]="bulkMode">
|
||||||
</app-card-actionables>
|
</app-card-actionables>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -72,11 +73,22 @@
|
||||||
<td>
|
<td>
|
||||||
<!-- On Mobile we want to use ... for each row -->
|
<!-- On Mobile we want to use ... for each row -->
|
||||||
@if (useActionables$ | async) {
|
@if (useActionables$ | async) {
|
||||||
<app-card-actionables [actions]="actions" (actionHandler)="performAction($event, library)"></app-card-actionables>
|
<app-card-actionables [entity]="library" [inputActions]="actions"></app-card-actionables>
|
||||||
} @else {
|
} @else {
|
||||||
<button class="btn btn-secondary me-2 btn-sm" (click)="scanLibrary(library)" placement="top" [ngbTooltip]="t('scan-library')" [attr.aria-label]="t('scan-library')"><i class="fa fa-sync-alt" aria-hidden="true"></i></button>
|
<button class="btn btn-secondary me-2 btn-sm" (click)="scanLibrary(library)" placement="top" [ngbTooltip]="t('scan-library')"
|
||||||
<button class="btn btn-danger me-2 btn-sm" [disabled]="deletionInProgress" (click)="deleteLibrary(library)"><i class="fa fa-trash" placement="top" [ngbTooltip]="t('delete-library')" [attr.aria-label]="t('delete-library-by-name', {name: library.name | sentenceCase})"></i></button>
|
[attr.aria-label]="t('scan-library')">
|
||||||
<button class="btn btn-primary btn-sm" (click)="editLibrary(library)"><i class="fa fa-pen" placement="top" [ngbTooltip]="t('edit-library')" [attr.aria-label]="t('edit-library-by-name', {name: library.name | sentenceCase})"></i></button>
|
<i class="fa fa-sync-alt" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn btn-danger me-2 btn-sm" [disabled]="deletionInProgress" (click)="deleteLibrary(library)">
|
||||||
|
<i class="fa fa-trash" placement="top" [ngbTooltip]="t('delete-library')"
|
||||||
|
[attr.aria-label]="t('delete-library-by-name', {name: library.name | sentenceCase})"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn btn-primary btn-sm" (click)="editLibrary(library)">
|
||||||
|
<i class="fa fa-pen" placement="top" [ngbTooltip]="t('edit-library')"
|
||||||
|
[attr.aria-label]="t('edit-library-by-name', {name: library.name | sentenceCase})"></i>
|
||||||
|
</button>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
||||||
|
|
@ -83,12 +83,12 @@ export class ManageLibraryComponent implements OnInit {
|
||||||
lastSelectedIndex: number | null = null;
|
lastSelectedIndex: number | null = null;
|
||||||
|
|
||||||
@HostListener('document:keydown.shift', ['$event'])
|
@HostListener('document:keydown.shift', ['$event'])
|
||||||
handleKeypress(event: KeyboardEvent) {
|
handleKeypress(_: KeyboardEvent) {
|
||||||
this.isShiftDown = true;
|
this.isShiftDown = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@HostListener('document:keyup.shift', ['$event'])
|
@HostListener('document:keyup.shift', ['$event'])
|
||||||
handleKeyUp(event: KeyboardEvent) {
|
handleKeyUp(_: KeyboardEvent) {
|
||||||
this.isShiftDown = false;
|
this.isShiftDown = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -106,7 +106,7 @@ export class ManageLibraryComponent implements OnInit {
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.getLibraries();
|
this.getLibraries();
|
||||||
|
|
||||||
// when a progress event comes in, show it on the UI next to library
|
// when a progress event comes in, show it on the UI next to the library
|
||||||
this.hubService.messages$.pipe(takeUntilDestroyed(this.destroyRef),
|
this.hubService.messages$.pipe(takeUntilDestroyed(this.destroyRef),
|
||||||
filter(event => event.event === EVENTS.ScanSeries || event.event === EVENTS.NotificationProgress),
|
filter(event => event.event === EVENTS.ScanSeries || event.event === EVENTS.NotificationProgress),
|
||||||
distinctUntilChanged((prev: Message<ScanSeriesEvent | NotificationProgressEvent>, curr: Message<ScanSeriesEvent | NotificationProgressEvent>) =>
|
distinctUntilChanged((prev: Message<ScanSeriesEvent | NotificationProgressEvent>, curr: Message<ScanSeriesEvent | NotificationProgressEvent>) =>
|
||||||
|
|
@ -270,7 +270,8 @@ export class ManageLibraryComponent implements OnInit {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleBulkAction(action: ActionItem<Library>, library : Library | null) {
|
async handleBulkAction(action: ActionItem<Library>, _: Library) {
|
||||||
|
//Library is null for bulk actions
|
||||||
this.bulkAction = action.action;
|
this.bulkAction = action.action;
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
|
|
||||||
|
|
@ -284,7 +285,7 @@ export class ManageLibraryComponent implements OnInit {
|
||||||
break;
|
break;
|
||||||
case (Action.CopySettings):
|
case (Action.CopySettings):
|
||||||
|
|
||||||
// Prompt the user for the library then wait for them to manually trigger applyBulkAction
|
// Prompt the user for the library, then wait for them to manually trigger applyBulkAction
|
||||||
const ref = this.modalService.open(CopySettingsFromLibraryModalComponent, {size: 'lg', fullscreen: 'md'});
|
const ref = this.modalService.open(CopySettingsFromLibraryModalComponent, {size: 'lg', fullscreen: 'md'});
|
||||||
ref.componentInstance.libraries = this.libraries;
|
ref.componentInstance.libraries = this.libraries;
|
||||||
ref.closed.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((res: number | null) => {
|
ref.closed.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((res: number | null) => {
|
||||||
|
|
@ -298,7 +299,6 @@ export class ManageLibraryComponent implements OnInit {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async handleAction(action: ActionItem<Library>, library: Library) {
|
async handleAction(action: ActionItem<Library>, library: Library) {
|
||||||
switch (action.action) {
|
switch (action.action) {
|
||||||
case(Action.Scan):
|
case(Action.Scan):
|
||||||
|
|
@ -321,13 +321,6 @@ export class ManageLibraryComponent implements OnInit {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
performAction(action: ActionItem<Library>, library: Library) {
|
|
||||||
if (typeof action.callback === 'function') {
|
|
||||||
action.callback(action, library);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
setupSelections() {
|
setupSelections() {
|
||||||
this.selections = new SelectionModel<Library>(false, this.libraries);
|
this.selections = new SelectionModel<Library>(false, this.libraries);
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
@if (logs$ | async; as items) {
|
|
||||||
<virtual-scroller #scroll [items]="items" [bufferAmount]="1">
|
|
||||||
<div class="grid row g-0" #container>
|
|
||||||
@for (item of scroll.viewPortItems; track item.timestamp) {
|
|
||||||
<div class="card col-auto mt-2 mb-2">
|
|
||||||
{{item.timestamp | date}} [{{item.level}}] {{item.message}}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</virtual-scroller>
|
|
||||||
}
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
|
||||||
import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr';
|
|
||||||
import { BehaviorSubject, take } from 'rxjs';
|
|
||||||
import { AccountService } from 'src/app/_services/account.service';
|
|
||||||
import { environment } from 'src/environments/environment';
|
|
||||||
import { VirtualScrollerModule } from '@iharbeck/ngx-virtual-scroller';
|
|
||||||
import { AsyncPipe, DatePipe } from '@angular/common';
|
|
||||||
|
|
||||||
interface LogMessage {
|
|
||||||
timestamp: string;
|
|
||||||
level: 'Information' | 'Debug' | 'Warning' | 'Error';
|
|
||||||
message: string;
|
|
||||||
exception: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-manage-logs',
|
|
||||||
templateUrl: './manage-logs.component.html',
|
|
||||||
styleUrls: ['./manage-logs.component.scss'],
|
|
||||||
standalone: true,
|
|
||||||
imports: [VirtualScrollerModule, AsyncPipe, DatePipe]
|
|
||||||
})
|
|
||||||
export class ManageLogsComponent implements OnInit, OnDestroy {
|
|
||||||
|
|
||||||
hubUrl = environment.hubUrl;
|
|
||||||
private hubConnection!: HubConnection;
|
|
||||||
|
|
||||||
logsSource = new BehaviorSubject<LogMessage[]>([]);
|
|
||||||
public logs$ = this.logsSource.asObservable();
|
|
||||||
|
|
||||||
constructor(private accountService: AccountService) { }
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
// TODO: Come back and implement this one day
|
|
||||||
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
|
||||||
if (user) {
|
|
||||||
this.hubConnection = new HubConnectionBuilder()
|
|
||||||
.withUrl(this.hubUrl + 'logs', {
|
|
||||||
accessTokenFactory: () => user.token
|
|
||||||
})
|
|
||||||
.withAutomaticReconnect()
|
|
||||||
.build();
|
|
||||||
|
|
||||||
console.log('Starting log connection');
|
|
||||||
|
|
||||||
this.hubConnection
|
|
||||||
.start()
|
|
||||||
.catch(err => console.error(err));
|
|
||||||
|
|
||||||
this.hubConnection.on('SendLogAsObject', resp => {
|
|
||||||
const payload = resp.arguments[0] as LogMessage;
|
|
||||||
const logMessage = {timestamp: payload.timestamp, level: payload.level, message: payload.message, exception: payload.exception};
|
|
||||||
// NOTE: It might be better to just have a queue to show this
|
|
||||||
const values = this.logsSource.getValue();
|
|
||||||
values.push(logMessage);
|
|
||||||
this.logsSource.next(values);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
// unsubscribe from signalr connection
|
|
||||||
if (this.hubConnection) {
|
|
||||||
this.hubConnection.stop().catch(err => console.error(err));
|
|
||||||
console.log('Stopping log connection');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -10,6 +10,4 @@ import {ChangeDetectionStrategy, Component, Input} from '@angular/core';
|
||||||
export class UpdateSectionComponent {
|
export class UpdateSectionComponent {
|
||||||
@Input({required: true}) items: Array<string> = [];
|
@Input({required: true}) items: Array<string> = [];
|
||||||
@Input({required: true}) title: string = '';
|
@Input({required: true}) title: string = '';
|
||||||
|
|
||||||
// TODO: Implement a read-more-list so that we by default show a configurable number
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 + '');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
<span class="visually-hidden">{{t('mark-as-read')}}</span>
|
<span class="visually-hidden">{{t('mark-as-read')}}</span>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
<app-card-actionables [actions]="actions" labelBy="bulk-actions-header" iconClass="fa-ellipsis-h" (actionHandler)="performAction($event)"></app-card-actionables>
|
<app-card-actionables (actionHandler)="performAction($event)" [inputActions]="actions" labelBy="bulk-actions-header" iconClass="fa-ellipsis-h" />
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span id="bulk-actions-header" class="visually-hidden">Bulk Actions</span>
|
<span id="bulk-actions-header" class="visually-hidden">Bulk Actions</span>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@ import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
ChangeDetectorRef,
|
ChangeDetectorRef,
|
||||||
Component,
|
Component,
|
||||||
DestroyRef, HostListener,
|
DestroyRef,
|
||||||
|
HostListener,
|
||||||
inject,
|
inject,
|
||||||
Input,
|
Input,
|
||||||
OnInit
|
OnInit
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
<h4>
|
<h4>
|
||||||
@if (actions.length > 0) {
|
@if (actions.length > 0) {
|
||||||
<span>
|
<span>
|
||||||
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="header"></app-card-actionables>
|
<app-card-actionables (actionHandler)="performAction($event)" [inputActions]="actions" [labelBy]="header"></app-card-actionables>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@
|
||||||
|
|
||||||
<span class="card-actions">
|
<span class="card-actions">
|
||||||
@if (actions && actions.length > 0) {
|
@if (actions && actions.length > 0) {
|
||||||
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="title"></app-card-actionables>
|
<app-card-actionables (actionHandler)="performAction($event)" [inputActions]="actions" [labelBy]="title"></app-card-actionables>
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -344,10 +344,6 @@ export class CardItemComponent implements OnInit {
|
||||||
this.clicked.emit(this.title);
|
this.clicked.emit(this.title);
|
||||||
}
|
}
|
||||||
|
|
||||||
preventClick(event: any) {
|
|
||||||
event.stopPropagation();
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
performAction(action: ActionItem<any>) {
|
performAction(action: ActionItem<any>) {
|
||||||
if (action.action == Action.Download) {
|
if (action.action == Action.Download) {
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,7 @@
|
||||||
</span>
|
</span>
|
||||||
<span class="card-actions">
|
<span class="card-actions">
|
||||||
@if (actions && actions.length > 0) {
|
@if (actions && actions.length > 0) {
|
||||||
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="chapter.titleName"></app-card-actionables>
|
<app-card-actionables [entity]="chapter" [inputActions]="actions" [labelBy]="chapter.titleName"></app-card-actionables>
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@ import {
|
||||||
ChangeDetectorRef,
|
ChangeDetectorRef,
|
||||||
Component,
|
Component,
|
||||||
DestroyRef,
|
DestroyRef,
|
||||||
EventEmitter, HostListener,
|
EventEmitter,
|
||||||
|
HostListener,
|
||||||
inject,
|
inject,
|
||||||
Input, OnInit,
|
Input,
|
||||||
|
OnInit,
|
||||||
Output
|
Output
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import {ImageService} from "../../_services/image.service";
|
import {ImageService} from "../../_services/image.service";
|
||||||
|
|
@ -14,7 +16,7 @@ import {DownloadEvent, DownloadService} from "../../shared/_services/download.se
|
||||||
import {EVENTS, MessageHubService} from "../../_services/message-hub.service";
|
import {EVENTS, MessageHubService} from "../../_services/message-hub.service";
|
||||||
import {AccountService} from "../../_services/account.service";
|
import {AccountService} from "../../_services/account.service";
|
||||||
import {ScrollService} from "../../_services/scroll.service";
|
import {ScrollService} from "../../_services/scroll.service";
|
||||||
import {Action, ActionFactoryService, ActionItem} from "../../_services/action-factory.service";
|
import {ActionItem} from "../../_services/action-factory.service";
|
||||||
import {Chapter} from "../../_models/chapter";
|
import {Chapter} from "../../_models/chapter";
|
||||||
import {Observable} from "rxjs";
|
import {Observable} from "rxjs";
|
||||||
import {User} from "../../_models/user";
|
import {User} from "../../_models/user";
|
||||||
|
|
@ -28,13 +30,10 @@ import {EntityTitleComponent} from "../entity-title/entity-title.component";
|
||||||
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
|
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
|
||||||
import {Router, RouterLink} from "@angular/router";
|
import {Router, RouterLink} from "@angular/router";
|
||||||
import {TranslocoDirective} from "@jsverse/transloco";
|
import {TranslocoDirective} from "@jsverse/transloco";
|
||||||
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
|
|
||||||
import {filter, map} from "rxjs/operators";
|
import {filter, map} from "rxjs/operators";
|
||||||
import {UserProgressUpdateEvent} from "../../_models/events/user-progress-update-event";
|
import {UserProgressUpdateEvent} from "../../_models/events/user-progress-update-event";
|
||||||
import {ReaderService} from "../../_services/reader.service";
|
import {ReaderService} from "../../_services/reader.service";
|
||||||
import {LibraryType} from "../../_models/library/library";
|
import {LibraryType} from "../../_models/library/library";
|
||||||
import {Device} from "../../_models/device/device";
|
|
||||||
import {ActionService} from "../../_services/action.service";
|
|
||||||
import {MangaFormat} from "../../_models/manga-format";
|
import {MangaFormat} from "../../_models/manga-format";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
|
@ -60,15 +59,16 @@ export class ChapterCardComponent implements OnInit {
|
||||||
public readonly imageService = inject(ImageService);
|
public readonly imageService = inject(ImageService);
|
||||||
public readonly bulkSelectionService = inject(BulkSelectionService);
|
public readonly bulkSelectionService = inject(BulkSelectionService);
|
||||||
private readonly downloadService = inject(DownloadService);
|
private readonly downloadService = inject(DownloadService);
|
||||||
private readonly actionService = inject(ActionService);
|
|
||||||
private readonly messageHub = inject(MessageHubService);
|
private readonly messageHub = inject(MessageHubService);
|
||||||
private readonly accountService = inject(AccountService);
|
private readonly accountService = inject(AccountService);
|
||||||
private readonly scrollService = inject(ScrollService);
|
private readonly scrollService = inject(ScrollService);
|
||||||
private readonly cdRef = inject(ChangeDetectorRef);
|
private readonly cdRef = inject(ChangeDetectorRef);
|
||||||
private readonly actionFactoryService = inject(ActionFactoryService);
|
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly readerService = inject(ReaderService);
|
private readonly readerService = inject(ReaderService);
|
||||||
|
|
||||||
|
protected readonly LibraryType = LibraryType;
|
||||||
|
protected readonly MangaFormat = MangaFormat;
|
||||||
|
|
||||||
@Input({required: true}) libraryId: number = 0;
|
@Input({required: true}) libraryId: number = 0;
|
||||||
@Input({required: true}) seriesId: number = 0;
|
@Input({required: true}) seriesId: number = 0;
|
||||||
@Input({required: true}) chapter!: Chapter;
|
@Input({required: true}) chapter!: Chapter;
|
||||||
|
|
@ -143,8 +143,6 @@ export class ChapterCardComponent implements OnInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.filterSendTo();
|
|
||||||
|
|
||||||
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
|
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
|
||||||
this.user = user;
|
this.user = user;
|
||||||
});
|
});
|
||||||
|
|
@ -172,30 +170,6 @@ export class ChapterCardComponent implements OnInit {
|
||||||
this.cdRef.detectChanges();
|
this.cdRef.detectChanges();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
filterSendTo() {
|
|
||||||
if (!this.actions || this.actions.length === 0) return;
|
|
||||||
|
|
||||||
this.actions = this.actionFactoryService.filterSendToAction(this.actions, this.chapter);
|
|
||||||
}
|
|
||||||
|
|
||||||
performAction(action: ActionItem<any>) {
|
|
||||||
if (action.action == Action.Download) {
|
|
||||||
this.downloadService.download('chapter', this.chapter);
|
|
||||||
return; // Don't propagate the download from a card
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action.action == Action.SendTo) {
|
|
||||||
const device = (action._extra!.data as Device);
|
|
||||||
this.actionService.sendToDevice([this.chapter.id], device);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof action.callback === 'function') {
|
|
||||||
action.callback(action, this.chapter);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleClick(event: any) {
|
handleClick(event: any) {
|
||||||
if (this.bulkSelectionService.hasSelections()) {
|
if (this.bulkSelectionService.hasSelections()) {
|
||||||
this.handleSelection(event);
|
this.handleSelection(event);
|
||||||
|
|
@ -209,8 +183,4 @@ export class ChapterCardComponent implements OnInit {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
this.readerService.readChapter(this.libraryId, this.seriesId, this.chapter, false);
|
this.readerService.readChapter(this.libraryId, this.seriesId, this.chapter, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
protected readonly LibraryType = LibraryType;
|
|
||||||
protected readonly MangaFormat = MangaFormat;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@
|
||||||
</span>
|
</span>
|
||||||
@if (actions && actions.length > 0) {
|
@if (actions && actions.length > 0) {
|
||||||
<span class="card-actions float-end">
|
<span class="card-actions float-end">
|
||||||
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="title"></app-card-actionables>
|
<app-card-actionables [entity]="entity" [inputActions]="actions" [labelBy]="title"></app-card-actionables>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,20 @@
|
||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
ChangeDetectorRef,
|
ChangeDetectorRef,
|
||||||
Component, ContentChild,
|
Component,
|
||||||
DestroyRef, EventEmitter,
|
ContentChild,
|
||||||
|
DestroyRef,
|
||||||
|
EventEmitter,
|
||||||
HostListener,
|
HostListener,
|
||||||
inject,
|
inject,
|
||||||
Input, Output, TemplateRef
|
Input,
|
||||||
|
Output,
|
||||||
|
TemplateRef
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import {Action, ActionFactoryService, ActionItem} from "../../_services/action-factory.service";
|
import {ActionItem} from "../../_services/action-factory.service";
|
||||||
import {ImageService} from "../../_services/image.service";
|
import {ImageService} from "../../_services/image.service";
|
||||||
import {BulkSelectionService} from "../bulk-selection.service";
|
import {BulkSelectionService} from "../bulk-selection.service";
|
||||||
import {LibraryService} from "../../_services/library.service";
|
|
||||||
import {DownloadService} from "../../shared/_services/download.service";
|
|
||||||
import {UtilityService} from "../../shared/_services/utility.service";
|
|
||||||
import {MessageHubService} from "../../_services/message-hub.service";
|
import {MessageHubService} from "../../_services/message-hub.service";
|
||||||
import {AccountService} from "../../_services/account.service";
|
|
||||||
import {ScrollService} from "../../_services/scroll.service";
|
import {ScrollService} from "../../_services/scroll.service";
|
||||||
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
|
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
|
||||||
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
|
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
|
||||||
|
|
@ -139,11 +139,6 @@ export class PersonCardComponent {
|
||||||
this.clicked.emit(this.title);
|
this.clicked.emit(this.title);
|
||||||
}
|
}
|
||||||
|
|
||||||
performAction(action: ActionItem<any>) {
|
|
||||||
if (typeof action.callback === 'function') {
|
|
||||||
action.callback(action, this.entity);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleSelection(event?: any) {
|
handleSelection(event?: any) {
|
||||||
if (event) {
|
if (event) {
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@
|
||||||
|
|
||||||
@if (actions && actions.length > 0) {
|
@if (actions && actions.length > 0) {
|
||||||
<span class="card-actions">
|
<span class="card-actions">
|
||||||
<app-card-actionables (actionHandler)="handleSeriesActionCallback($event, series)" [actions]="actions" [labelBy]="series.name"></app-card-actionables>
|
<app-card-actionables [entity]="series" [inputActions]="actions" [labelBy]="series.name"></app-card-actionables>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ import {ActionService} from 'src/app/_services/action.service';
|
||||||
import {EditSeriesModalComponent} from '../_modals/edit-series-modal/edit-series-modal.component';
|
import {EditSeriesModalComponent} from '../_modals/edit-series-modal/edit-series-modal.component';
|
||||||
import {RelationKind} from 'src/app/_models/series-detail/relation-kind';
|
import {RelationKind} from 'src/app/_models/series-detail/relation-kind';
|
||||||
import {DecimalPipe} from "@angular/common";
|
import {DecimalPipe} from "@angular/common";
|
||||||
import {CardItemComponent} from "../card-item/card-item.component";
|
|
||||||
import {RelationshipPipe} from "../../_pipes/relationship.pipe";
|
import {RelationshipPipe} from "../../_pipes/relationship.pipe";
|
||||||
import {Device} from "../../_models/device/device";
|
import {Device} from "../../_models/device/device";
|
||||||
import {translate, TranslocoDirective} from "@jsverse/transloco";
|
import {translate, TranslocoDirective} from "@jsverse/transloco";
|
||||||
|
|
@ -30,7 +29,6 @@ import {SeriesPreviewDrawerComponent} from "../../_single-module/series-preview-
|
||||||
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
|
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
|
||||||
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
|
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
|
||||||
import {DownloadIndicatorComponent} from "../download-indicator/download-indicator.component";
|
import {DownloadIndicatorComponent} from "../download-indicator/download-indicator.component";
|
||||||
import {EntityTitleComponent} from "../entity-title/entity-title.component";
|
|
||||||
import {FormsModule} from "@angular/forms";
|
import {FormsModule} from "@angular/forms";
|
||||||
import {ImageComponent} from "../../shared/image/image.component";
|
import {ImageComponent} from "../../shared/image/image.component";
|
||||||
import {DownloadEvent, DownloadService} from "../../shared/_services/download.service";
|
import {DownloadEvent, DownloadService} from "../../shared/_services/download.service";
|
||||||
|
|
@ -39,7 +37,6 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||||
import {map} from "rxjs/operators";
|
import {map} from "rxjs/operators";
|
||||||
import {AccountService} from "../../_services/account.service";
|
import {AccountService} from "../../_services/account.service";
|
||||||
import {BulkSelectionService} from "../bulk-selection.service";
|
import {BulkSelectionService} from "../bulk-selection.service";
|
||||||
import {User} from "../../_models/user";
|
|
||||||
import {ScrollService} from "../../_services/scroll.service";
|
import {ScrollService} from "../../_services/scroll.service";
|
||||||
import {ReaderService} from "../../_services/reader.service";
|
import {ReaderService} from "../../_services/reader.service";
|
||||||
import {SeriesFormatComponent} from "../../shared/series-format/series-format.component";
|
import {SeriesFormatComponent} from "../../shared/series-format/series-format.component";
|
||||||
|
|
@ -147,8 +144,6 @@ export class SeriesCardComponent implements OnInit, OnChanges {
|
||||||
*/
|
*/
|
||||||
prevOffset: number = 0;
|
prevOffset: number = 0;
|
||||||
selectionInProgress: boolean = false;
|
selectionInProgress: boolean = false;
|
||||||
private user: User | undefined;
|
|
||||||
|
|
||||||
|
|
||||||
@HostListener('touchmove', ['$event'])
|
@HostListener('touchmove', ['$event'])
|
||||||
onTouchMove(event: TouchEvent) {
|
onTouchMove(event: TouchEvent) {
|
||||||
|
|
@ -192,15 +187,15 @@ export class SeriesCardComponent implements OnInit, OnChanges {
|
||||||
|
|
||||||
ngOnChanges(changes: any) {
|
ngOnChanges(changes: any) {
|
||||||
if (this.series) {
|
if (this.series) {
|
||||||
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
|
// this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
|
||||||
this.user = user;
|
// this.user = user;
|
||||||
});
|
// });
|
||||||
|
|
||||||
this.download$ = this.downloadService.activeDownloads$.pipe(takeUntilDestroyed(this.destroyRef), map((events) => {
|
this.download$ = this.downloadService.activeDownloads$.pipe(takeUntilDestroyed(this.destroyRef), map((events) => {
|
||||||
return this.downloadService.mapToEntityType(events, this.series);
|
return this.downloadService.mapToEntityType(events, this.series);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
this.actions = [...this.actionFactoryService.getSeriesActions((action: ActionItem<Series>, series: Series) => this.handleSeriesActionCallback(action, series))];
|
this.actions = [...this.actionFactoryService.getSeriesActions(this.handleSeriesActionCallback.bind(this))];
|
||||||
if (this.isOnDeck) {
|
if (this.isOnDeck) {
|
||||||
const othersIndex = this.actions.findIndex(obj => obj.title === 'others');
|
const othersIndex = this.actions.findIndex(obj => obj.title === 'others');
|
||||||
const othersAction = deepClone(this.actions[othersIndex]) as ActionItem<Series>;
|
const othersAction = deepClone(this.actions[othersIndex]) as ActionItem<Series>;
|
||||||
|
|
@ -209,9 +204,11 @@ export class SeriesCardComponent implements OnInit, OnChanges {
|
||||||
action: Action.RemoveFromOnDeck,
|
action: Action.RemoveFromOnDeck,
|
||||||
title: 'remove-from-on-deck',
|
title: 'remove-from-on-deck',
|
||||||
description: '',
|
description: '',
|
||||||
callback: (action: ActionItem<Series>, series: Series) => this.handleSeriesActionCallback(action, series),
|
callback: this.handleSeriesActionCallback.bind(this),
|
||||||
class: 'danger',
|
class: 'danger',
|
||||||
requiresAdmin: false,
|
requiresAdmin: false,
|
||||||
|
requiredRoles: [],
|
||||||
|
shouldRender: (_, _2, _3) => true,
|
||||||
children: [],
|
children: [],
|
||||||
});
|
});
|
||||||
this.actions[othersIndex] = othersAction;
|
this.actions[othersIndex] = othersAction;
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@
|
||||||
|
|
||||||
@if (actions && actions.length > 0) {
|
@if (actions && actions.length > 0) {
|
||||||
<span class="card-actions">
|
<span class="card-actions">
|
||||||
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="volume.name"></app-card-actionables>
|
<app-card-actionables [entity]="volume" [inputActions]="actions" [labelBy]="volume.name"></app-card-actionables>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ import {DownloadEvent, DownloadService} from "../../shared/_services/download.se
|
||||||
import {EVENTS, MessageHubService} from "../../_services/message-hub.service";
|
import {EVENTS, MessageHubService} from "../../_services/message-hub.service";
|
||||||
import {AccountService} from "../../_services/account.service";
|
import {AccountService} from "../../_services/account.service";
|
||||||
import {ScrollService} from "../../_services/scroll.service";
|
import {ScrollService} from "../../_services/scroll.service";
|
||||||
import {Action, ActionItem} from "../../_services/action-factory.service";
|
import {ActionItem} from "../../_services/action-factory.service";
|
||||||
import {ReaderService} from "../../_services/reader.service";
|
import {ReaderService} from "../../_services/reader.service";
|
||||||
import {Observable} from "rxjs";
|
import {Observable} from "rxjs";
|
||||||
import {User} from "../../_models/user";
|
import {User} from "../../_models/user";
|
||||||
|
|
@ -33,7 +33,6 @@ import {UserProgressUpdateEvent} from "../../_models/events/user-progress-update
|
||||||
import {Volume} from "../../_models/volume";
|
import {Volume} from "../../_models/volume";
|
||||||
import {UtilityService} from "../../shared/_services/utility.service";
|
import {UtilityService} from "../../shared/_services/utility.service";
|
||||||
import {LibraryType} from "../../_models/library/library";
|
import {LibraryType} from "../../_models/library/library";
|
||||||
import {Device} from "../../_models/device/device";
|
|
||||||
import {ActionService} from "../../_services/action.service";
|
import {ActionService} from "../../_services/action.service";
|
||||||
import {FormsModule} from "@angular/forms";
|
import {FormsModule} from "@angular/forms";
|
||||||
|
|
||||||
|
|
@ -143,8 +142,6 @@ export class VolumeCardComponent implements OnInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.filterSendTo();
|
|
||||||
|
|
||||||
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
|
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
|
||||||
this.user = user;
|
this.user = user;
|
||||||
});
|
});
|
||||||
|
|
@ -180,30 +177,6 @@ export class VolumeCardComponent implements OnInit {
|
||||||
this.cdRef.detectChanges();
|
this.cdRef.detectChanges();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
filterSendTo() {
|
|
||||||
if (!this.actions || this.actions.length === 0) return;
|
|
||||||
// TODO: See if we can handle send to for volumes
|
|
||||||
//this.actions = this.actionFactoryService.filterSendToAction(this.actions, this.volume);
|
|
||||||
}
|
|
||||||
|
|
||||||
performAction(action: ActionItem<Volume>) {
|
|
||||||
if (action.action == Action.Download) {
|
|
||||||
this.downloadService.download('volume', this.volume);
|
|
||||||
return; // Don't propagate the download from a card
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action.action == Action.SendTo) {
|
|
||||||
const device = (action._extra!.data as Device);
|
|
||||||
this.actionService.sendToDevice(this.volume.chapters.map(c => c.id), device);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof action.callback === 'function') {
|
|
||||||
action.callback(action, this.volume);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleClick(event: any) {
|
handleClick(event: any) {
|
||||||
if (this.bulkSelectionService.hasSelections()) {
|
if (this.bulkSelectionService.hasSelections()) {
|
||||||
this.handleSelection(event);
|
this.handleSelection(event);
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
<div class="carousel-container mb-3">
|
<div class="carousel-container mb-3">
|
||||||
<div>
|
<div>
|
||||||
@if (actionables.length > 0) {
|
@if (actionables.length > 0) {
|
||||||
<app-card-actionables [actions]="actionables" (actionHandler)="performAction($event)"></app-card-actionables>
|
<app-card-actionables [inputActions]="actionables" (actionHandler)="performAction($event)"></app-card-actionables>
|
||||||
}
|
}
|
||||||
<h4 class="header" (click)="sectionClicked($event)" [ngClass]="{'non-selectable': !clickableTitle}">
|
<h4 class="header" (click)="sectionClicked($event)" [ngClass]="{'non-selectable': !clickableTitle}">
|
||||||
@if (titleLink !== '') {
|
@if (titleLink !== '') {
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@
|
||||||
|
|
||||||
<div class="col-auto ms-2k">
|
<div class="col-auto ms-2k">
|
||||||
<div class="card-actions" [ngbTooltip]="t('more-alt')">
|
<div class="card-actions" [ngbTooltip]="t('more-alt')">
|
||||||
<app-card-actionables (actionHandler)="performAction($event)" [actions]="chapterActions" [labelBy]="series.name + ' ' + chapter.minNumber" iconClass="fa-ellipsis-h" btnClass="btn-actions btn"></app-card-actionables>
|
<app-card-actionables [entity]="chapter" [inputActions]="chapterActions" [labelBy]="series.name + ' ' + chapter.minNumber" iconClass="fa-ellipsis-h" btnClass="btn-actions btn"></app-card-actionables>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -339,10 +339,6 @@ export class ChapterDetailComponent implements OnInit {
|
||||||
this.location.replaceState(newUrl)
|
this.location.replaceState(newUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
openPerson(field: FilterField, value: number) {
|
|
||||||
this.filterUtilityService.applyFilter(['all-series'], field, FilterComparison.Equal, `${value}`).subscribe();
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadChapter() {
|
downloadChapter() {
|
||||||
if (this.downloadInProgress) return;
|
if (this.downloadInProgress) return;
|
||||||
this.downloadService.download('chapter', this.chapter!, (d) => {
|
this.downloadService.download('chapter', this.chapter!, (d) => {
|
||||||
|
|
@ -360,11 +356,6 @@ export class ChapterDetailComponent implements OnInit {
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
performAction(action: ActionItem<Chapter>) {
|
|
||||||
if (typeof action.callback === 'function') {
|
|
||||||
action.callback(action, this.chapter!);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleChapterActionCallback(action: ActionItem<Chapter>, chapter: Chapter) {
|
handleChapterActionCallback(action: ActionItem<Chapter>, chapter: Chapter) {
|
||||||
switch (action.action) {
|
switch (action.action) {
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,9 @@
|
||||||
<ng-container title>
|
<ng-container title>
|
||||||
@if (collectionTag) {
|
@if (collectionTag) {
|
||||||
<h4>
|
<h4>
|
||||||
{{collectionTag.title}}
|
<app-promoted-icon [promoted]="collectionTag.promoted"></app-promoted-icon>
|
||||||
@if(collectionTag.promoted) {
|
<span class="ms-2">{{collectionTag.title}}</span>
|
||||||
<span class="ms-1">(<i aria-hidden="true" class="fa fa-angle-double-up"></i>)</span>
|
<app-card-actionables [entity]="collectionTag" [disabled]="actionInProgress" [inputActions]="collectionTagActions" [labelBy]="collectionTag.title" iconClass="fa-ellipsis-v"></app-card-actionables>
|
||||||
}
|
|
||||||
<app-card-actionables [disabled]="actionInProgress" (actionHandler)="performAction($event)" [actions]="collectionTagActions" [labelBy]="collectionTag.title" iconClass="fa-ellipsis-v"></app-card-actionables>
|
|
||||||
</h4>
|
</h4>
|
||||||
}
|
}
|
||||||
<h5 subtitle class="subtitle-with-actionables">{{t('item-count', {num: series.length})}}</h5>
|
<h5 subtitle class="subtitle-with-actionables">{{t('item-count', {num: series.length})}}</h5>
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ import {
|
||||||
} from "../../../_single-module/smart-collection-drawer/smart-collection-drawer.component";
|
} from "../../../_single-module/smart-collection-drawer/smart-collection-drawer.component";
|
||||||
import {DefaultModalOptions} from "../../../_models/default-modal-options";
|
import {DefaultModalOptions} from "../../../_models/default-modal-options";
|
||||||
import {ScrobbleProviderNamePipe} from "../../../_pipes/scrobble-provider-name.pipe";
|
import {ScrobbleProviderNamePipe} from "../../../_pipes/scrobble-provider-name.pipe";
|
||||||
|
import {PromotedIconComponent} from "../../../shared/_components/promoted-icon/promoted-icon.component";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-collection-detail',
|
selector: 'app-collection-detail',
|
||||||
|
|
@ -69,7 +70,7 @@ import {ScrobbleProviderNamePipe} from "../../../_pipes/scrobble-provider-name.p
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [SideNavCompanionBarComponent, CardActionablesComponent, ImageComponent, ReadMoreComponent,
|
imports: [SideNavCompanionBarComponent, CardActionablesComponent, ImageComponent, ReadMoreComponent,
|
||||||
BulkOperationsComponent, CardDetailLayoutComponent, SeriesCardComponent, TranslocoDirective, NgbTooltip,
|
BulkOperationsComponent, CardDetailLayoutComponent, SeriesCardComponent, TranslocoDirective, NgbTooltip,
|
||||||
DatePipe, DefaultDatePipe, ProviderImagePipe, AsyncPipe, ScrobbleProviderNamePipe]
|
DatePipe, DefaultDatePipe, ProviderImagePipe, AsyncPipe, ScrobbleProviderNamePipe, PromotedIconComponent]
|
||||||
})
|
})
|
||||||
export class CollectionDetailComponent implements OnInit, AfterContentChecked {
|
export class CollectionDetailComponent implements OnInit, AfterContentChecked {
|
||||||
|
|
||||||
|
|
@ -304,12 +305,6 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
performAction(action: ActionItem<any>) {
|
|
||||||
if (typeof action.callback === 'function') {
|
|
||||||
action.callback(action, this.collectionTag);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
openEditCollectionTagModal(collectionTag: UserCollection) {
|
openEditCollectionTagModal(collectionTag: UserCollection) {
|
||||||
const modalRef = this.modalService.open(EditCollectionTagsComponent, DefaultModalOptions);
|
const modalRef = this.modalService.open(EditCollectionTagsComponent, DefaultModalOptions);
|
||||||
modalRef.componentInstance.tag = this.collectionTag;
|
modalRef.componentInstance.tag = this.collectionTag;
|
||||||
|
|
@ -320,7 +315,6 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
|
||||||
}
|
}
|
||||||
|
|
||||||
openSyncDetailDrawer() {
|
openSyncDetailDrawer() {
|
||||||
|
|
||||||
const ref = this.offcanvasService.open(SmartCollectionDrawerComponent, {position: 'end', panelClass: ''});
|
const ref = this.offcanvasService.open(SmartCollectionDrawerComponent, {position: 'end', panelClass: ''});
|
||||||
ref.componentInstance.collection = this.collectionTag;
|
ref.componentInstance.collection = this.collectionTag;
|
||||||
ref.componentInstance.series = this.series;
|
ref.componentInstance.series = this.series;
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
<app-side-nav-companion-bar [hasFilter]="true" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive">
|
<app-side-nav-companion-bar [hasFilter]="true" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive">
|
||||||
<h4 title>
|
<h4 title>
|
||||||
<span>{{libraryName}}</span>
|
<span>{{libraryName}}</span>
|
||||||
<app-card-actionables [actions]="actions" (actionHandler)="performAction($event)"></app-card-actionables>
|
<app-card-actionables [inputActions]="actions" (actionHandler)="performAction($event)"></app-card-actionables>
|
||||||
</h4>
|
</h4>
|
||||||
@if (active.fragment === '') {
|
@if (active.fragment === '') {
|
||||||
<h5 subtitle class="subtitle-with-actionables">{{t('common.series-count', {num: pagination.totalItems | number})}} </h5>
|
<h5 subtitle class="subtitle-with-actionables">{{t('common.series-count', {num: pagination.totalItems | number})}} </h5>
|
||||||
|
|
@ -31,7 +31,6 @@
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
<ng-template #noData>
|
<ng-template #noData>
|
||||||
<!-- TODO: Come back and figure this out -->
|
|
||||||
{{t('common.no-data')}}
|
{{t('common.no-data')}}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-card-detail-layout>
|
</app-card-detail-layout>
|
||||||
|
|
|
||||||
|
|
@ -297,8 +297,6 @@ export class LibraryDetailComponent implements OnInit {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
performAction(action: ActionItem<any>) {
|
performAction(action: ActionItem<any>) {
|
||||||
if (typeof action.callback === 'function') {
|
if (typeof action.callback === 'function') {
|
||||||
action.callback(action, undefined);
|
action.callback(action, undefined);
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,10 @@
|
||||||
} @else {
|
} @else {
|
||||||
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="resetField()"></button>
|
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="resetField()"></button>
|
||||||
}
|
}
|
||||||
|
} @else {
|
||||||
|
<div class="input-hint">
|
||||||
|
Ctrl+K
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,17 @@
|
||||||
right: 5px;
|
right: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.input-hint {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-top: 3px;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
margin-right: 9px;
|
||||||
|
border: 1px solid var(--input-hint-border-color, lightgrey);
|
||||||
|
color: var(--input-hint-text-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.typeahead-input {
|
.typeahead-input {
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,9 @@ export class GroupedTypeaheadComponent implements OnInit {
|
||||||
|
|
||||||
|
|
||||||
hasFocus: boolean = false;
|
hasFocus: boolean = false;
|
||||||
typeaheadForm: FormGroup = new FormGroup({});
|
typeaheadForm: FormGroup = new FormGroup({
|
||||||
|
typeahead: new FormControl('', []),
|
||||||
|
});
|
||||||
includeChapterAndFiles: boolean = false;
|
includeChapterAndFiles: boolean = false;
|
||||||
prevSearchTerm: string = '';
|
prevSearchTerm: string = '';
|
||||||
searchSettingsForm = new FormGroup(({'includeExtras': new FormControl(false)}));
|
searchSettingsForm = new FormGroup(({'includeExtras': new FormControl(false)}));
|
||||||
|
|
@ -121,22 +123,37 @@ export class GroupedTypeaheadComponent implements OnInit {
|
||||||
this.close();
|
this.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
@HostListener('window:keydown', ['$event'])
|
@HostListener('document:keydown', ['$event'])
|
||||||
handleKeyPress(event: KeyboardEvent) {
|
handleKeyPress(event: KeyboardEvent) {
|
||||||
if (!this.hasFocus) { return; }
|
|
||||||
|
const isCtrlOrMeta = event.ctrlKey || event.metaKey;
|
||||||
|
|
||||||
|
|
||||||
switch(event.key) {
|
switch(event.key) {
|
||||||
case KEY_CODES.ESC_KEY:
|
case KEY_CODES.ESC_KEY:
|
||||||
|
if (!this.hasFocus) { return; }
|
||||||
this.close();
|
this.close();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case KEY_CODES.K:
|
||||||
|
if (isCtrlOrMeta) {
|
||||||
|
if (this.inputElem.nativeElement) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
this.inputElem.nativeElement.focus();
|
||||||
|
this.inputElem.nativeElement.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.typeaheadForm.addControl('typeahead', new FormControl(this.initialValue, []));
|
this.typeaheadForm.get('typeahead')?.setValue(this.initialValue);
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
|
|
||||||
this.searchSettingsForm.get('includeExtras')!.valueChanges.pipe(
|
this.searchSettingsForm.get('includeExtras')!.valueChanges.pipe(
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,14 @@
|
||||||
width="24px" [imageUrl]="imageService.getPersonImage(item.id)" [errorImage]="imageService.noPersonImage"></app-image>
|
width="24px" [imageUrl]="imageService.getPersonImage(item.id)" [errorImage]="imageService.noPersonImage"></app-image>
|
||||||
</div>
|
</div>
|
||||||
<div class="ms-1">
|
<div class="ms-1">
|
||||||
<div>{{item.name}}</div>
|
<div>
|
||||||
|
{{item.name}}
|
||||||
|
</div>
|
||||||
|
@if (item.aliases.length > 0) {
|
||||||
|
<span class="small-text">
|
||||||
|
{{t('person-aka-status')}}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue