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.NET.Test.Sdk" Version="17.13.0" />
|
||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="22.0.13" />
|
||||
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="22.0.13" />
|
||||
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="22.0.14" />
|
||||
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="22.0.14" />
|
||||
<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>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
using System.Linq;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Entities.Enums;
|
||||
using API.Helpers;
|
||||
using API.Helpers.Builders;
|
||||
using Xunit;
|
||||
|
||||
namespace API.Tests.Helpers;
|
||||
|
||||
|
|
@ -7,127 +12,215 @@ public class PersonHelperTests : AbstractDbTest
|
|||
{
|
||||
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());
|
||||
await Context.SaveChangesAsync();
|
||||
}
|
||||
//
|
||||
// // 1. Test adding new people and keeping existing ones
|
||||
// [Fact]
|
||||
// public async Task UpdateChapterPeopleAsync_AddNewPeople_ExistingPersonRetained()
|
||||
// {
|
||||
// var existingPerson = new PersonBuilder("Joe Shmo").Build();
|
||||
// var chapter = new ChapterBuilder("1").Build();
|
||||
//
|
||||
// // Create an existing person and assign them to the series with a role
|
||||
// var series = new SeriesBuilder("Test 1")
|
||||
// .WithFormat(MangaFormat.Archive)
|
||||
// .WithMetadata(new SeriesMetadataBuilder()
|
||||
// .WithPerson(existingPerson, PersonRole.Editor)
|
||||
// .Build())
|
||||
// .WithVolume(new VolumeBuilder("1").WithChapter(chapter).Build())
|
||||
// .Build();
|
||||
//
|
||||
// _unitOfWork.SeriesRepository.Add(series);
|
||||
// await _unitOfWork.CommitAsync();
|
||||
//
|
||||
// // Call UpdateChapterPeopleAsync with one existing and one new person
|
||||
// await PersonHelper.UpdateChapterPeopleAsync(chapter, new List<string> { "Joe Shmo", "New Person" }, PersonRole.Editor, _unitOfWork);
|
||||
//
|
||||
// // Assert existing person retained and new person added
|
||||
// var people = await _unitOfWork.PersonRepository.GetAllPeople();
|
||||
// Assert.Contains(people, p => p.Name == "Joe Shmo");
|
||||
// Assert.Contains(people, p => p.Name == "New Person");
|
||||
//
|
||||
// var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList();
|
||||
// Assert.Contains("Joe Shmo", chapterPeople);
|
||||
// Assert.Contains("New Person", chapterPeople);
|
||||
// }
|
||||
//
|
||||
// // 2. Test removing a person no longer in the list
|
||||
// [Fact]
|
||||
// public async Task UpdateChapterPeopleAsync_RemovePeople()
|
||||
// {
|
||||
// var existingPerson1 = new PersonBuilder("Joe Shmo").Build();
|
||||
// var existingPerson2 = new PersonBuilder("Jane Doe").Build();
|
||||
// var chapter = new ChapterBuilder("1").Build();
|
||||
//
|
||||
// var series = new SeriesBuilder("Test 1")
|
||||
// .WithVolume(new VolumeBuilder("1")
|
||||
// .WithChapter(new ChapterBuilder("1")
|
||||
// .WithPerson(existingPerson1, PersonRole.Editor)
|
||||
// .WithPerson(existingPerson2, PersonRole.Editor)
|
||||
// .Build())
|
||||
// .Build())
|
||||
// .Build();
|
||||
//
|
||||
// _unitOfWork.SeriesRepository.Add(series);
|
||||
// await _unitOfWork.CommitAsync();
|
||||
//
|
||||
// // Call UpdateChapterPeopleAsync with only one person
|
||||
// await PersonHelper.UpdateChapterPeopleAsync(chapter, new List<string> { "Joe Shmo" }, PersonRole.Editor, _unitOfWork);
|
||||
//
|
||||
// var people = await _unitOfWork.PersonRepository.GetAllPeople();
|
||||
// Assert.DoesNotContain(people, p => p.Name == "Jane Doe");
|
||||
//
|
||||
// var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList();
|
||||
// Assert.Contains("Joe Shmo", chapterPeople);
|
||||
// Assert.DoesNotContain("Jane Doe", chapterPeople);
|
||||
// }
|
||||
//
|
||||
// // 3. Test no changes when the list of people is the same
|
||||
// [Fact]
|
||||
// public async Task UpdateChapterPeopleAsync_NoChanges()
|
||||
// {
|
||||
// var existingPerson = new PersonBuilder("Joe Shmo").Build();
|
||||
// var chapter = new ChapterBuilder("1").Build();
|
||||
//
|
||||
// var series = new SeriesBuilder("Test 1")
|
||||
// .WithVolume(new VolumeBuilder("1")
|
||||
// .WithChapter(new ChapterBuilder("1")
|
||||
// .WithPerson(existingPerson, PersonRole.Editor)
|
||||
// .Build())
|
||||
// .Build())
|
||||
// .Build();
|
||||
//
|
||||
// _unitOfWork.SeriesRepository.Add(series);
|
||||
// await _unitOfWork.CommitAsync();
|
||||
//
|
||||
// // Call UpdateChapterPeopleAsync with the same list
|
||||
// await PersonHelper.UpdateChapterPeopleAsync(chapter, new List<string> { "Joe Shmo" }, PersonRole.Editor, _unitOfWork);
|
||||
//
|
||||
// var people = await _unitOfWork.PersonRepository.GetAllPeople();
|
||||
// Assert.Contains(people, p => p.Name == "Joe Shmo");
|
||||
//
|
||||
// var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList();
|
||||
// Assert.Contains("Joe Shmo", chapterPeople);
|
||||
// Assert.Single(chapter.People); // No duplicate entries
|
||||
// }
|
||||
//
|
||||
// // 4. Test multiple roles for a person
|
||||
// [Fact]
|
||||
// public async Task UpdateChapterPeopleAsync_MultipleRoles()
|
||||
// {
|
||||
// var person = new PersonBuilder("Joe Shmo").Build();
|
||||
// var chapter = new ChapterBuilder("1").Build();
|
||||
//
|
||||
// var series = new SeriesBuilder("Test 1")
|
||||
// .WithVolume(new VolumeBuilder("1")
|
||||
// .WithChapter(new ChapterBuilder("1")
|
||||
// .WithPerson(person, PersonRole.Writer) // Assign person as Writer
|
||||
// .Build())
|
||||
// .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);
|
||||
// }
|
||||
|
||||
// 1. Test adding new people and keeping existing ones
|
||||
[Fact]
|
||||
public async Task UpdateChapterPeopleAsync_AddNewPeople_ExistingPersonRetained()
|
||||
{
|
||||
await ResetDb();
|
||||
|
||||
var library = new LibraryBuilder("My Library")
|
||||
.Build();
|
||||
|
||||
UnitOfWork.LibraryRepository.Add(library);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
var existingPerson = new PersonBuilder("Joe Shmo").Build();
|
||||
var chapter = new ChapterBuilder("1").Build();
|
||||
|
||||
// Create an existing person and assign them to the series with a role
|
||||
var series = new SeriesBuilder("Test 1")
|
||||
.WithLibraryId(library.Id)
|
||||
.WithFormat(MangaFormat.Archive)
|
||||
.WithMetadata(new SeriesMetadataBuilder()
|
||||
.WithPerson(existingPerson, PersonRole.Editor)
|
||||
.Build())
|
||||
.WithVolume(new VolumeBuilder("1").WithChapter(chapter).Build())
|
||||
.Build();
|
||||
|
||||
UnitOfWork.SeriesRepository.Add(series);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
// Call UpdateChapterPeopleAsync with one existing and one new person
|
||||
await PersonHelper.UpdateChapterPeopleAsync(chapter, new List<string> { "Joe Shmo", "New Person" }, PersonRole.Editor, UnitOfWork);
|
||||
|
||||
// Assert existing person retained and new person added
|
||||
var people = await UnitOfWork.PersonRepository.GetAllPeople();
|
||||
Assert.Contains(people, p => p.Name == "Joe Shmo");
|
||||
Assert.Contains(people, p => p.Name == "New Person");
|
||||
|
||||
var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList();
|
||||
Assert.Contains("Joe Shmo", chapterPeople);
|
||||
Assert.Contains("New Person", chapterPeople);
|
||||
}
|
||||
|
||||
// 2. Test removing a person no longer in the list
|
||||
[Fact]
|
||||
public async Task UpdateChapterPeopleAsync_RemovePeople()
|
||||
{
|
||||
await ResetDb();
|
||||
|
||||
var library = new LibraryBuilder("My Library")
|
||||
.Build();
|
||||
|
||||
UnitOfWork.LibraryRepository.Add(library);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
var existingPerson1 = new PersonBuilder("Joe Shmo").Build();
|
||||
var existingPerson2 = new PersonBuilder("Jane Doe").Build();
|
||||
var chapter = new ChapterBuilder("1")
|
||||
.WithPerson(existingPerson1, PersonRole.Editor)
|
||||
.WithPerson(existingPerson2, 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();
|
||||
|
||||
// Call UpdateChapterPeopleAsync with only one person
|
||||
await PersonHelper.UpdateChapterPeopleAsync(chapter, new List<string> { "Joe Shmo" }, PersonRole.Editor, UnitOfWork);
|
||||
|
||||
// PersonHelper does not remove the Person from the global DbSet itself
|
||||
await UnitOfWork.PersonRepository.RemoveAllPeopleNoLongerAssociated();
|
||||
|
||||
var people = await UnitOfWork.PersonRepository.GetAllPeople();
|
||||
Assert.DoesNotContain(people, p => p.Name == "Jane Doe");
|
||||
|
||||
var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList();
|
||||
Assert.Contains("Joe Shmo", chapterPeople);
|
||||
Assert.DoesNotContain("Jane Doe", chapterPeople);
|
||||
}
|
||||
|
||||
// 3. Test no changes when the list of people is the same
|
||||
[Fact]
|
||||
public async Task UpdateChapterPeopleAsync_NoChanges()
|
||||
{
|
||||
await ResetDb();
|
||||
|
||||
var library = new LibraryBuilder("My Library")
|
||||
.Build();
|
||||
|
||||
UnitOfWork.LibraryRepository.Add(library);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
var existingPerson = new PersonBuilder("Joe Shmo").Build();
|
||||
var chapter = new ChapterBuilder("1").WithPerson(existingPerson, 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();
|
||||
|
||||
// Call UpdateChapterPeopleAsync with the same list
|
||||
await PersonHelper.UpdateChapterPeopleAsync(chapter, new List<string> { "Joe Shmo" }, PersonRole.Editor, UnitOfWork);
|
||||
|
||||
var people = await UnitOfWork.PersonRepository.GetAllPeople();
|
||||
Assert.Contains(people, p => p.Name == "Joe Shmo");
|
||||
|
||||
var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList();
|
||||
Assert.Contains("Joe Shmo", chapterPeople);
|
||||
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
|
||||
|
||||
#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
|
||||
|
||||
[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.DTOs;
|
||||
using API.DTOs.Metadata;
|
||||
using API.DTOs.Person;
|
||||
using API.DTOs.SeriesDetail;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@
|
|||
|
||||
<ItemGroup>
|
||||
<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">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
|
@ -66,7 +66,7 @@
|
|||
<PackageReference Include="Hangfire.InMemory" Version="1.0.0" />
|
||||
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
|
||||
<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="Hangfire.AspNetCore" Version="1.8.18" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
|
||||
|
|
@ -78,7 +78,7 @@
|
|||
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
|
||||
<PackageReference Include="MimeTypeMapOfficial" Version="1.0.17" />
|
||||
<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="Serilog" Version="4.2.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.Sinks.AspNetCore.SignalR" Version="0.4.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="SharpCompress" Version="0.39.0" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.7" />
|
||||
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.8.0.113526">
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.8" />
|
||||
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.9.0.115408">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.1" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.2" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.8.0" />
|
||||
<PackageReference Include="System.IO.Abstractions" Version="22.0.13" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.3" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.9.0" />
|
||||
<PackageReference Include="System.IO.Abstractions" Version="22.0.14" />
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
|
||||
|
|
@ -111,17 +111,16 @@
|
|||
|
||||
|
||||
<ItemGroup>
|
||||
<None Remove="Hangfire-log.db" />
|
||||
<None Remove="obj\**" />
|
||||
<None Remove="cache\**" />
|
||||
<None Remove="cache-long\**" />
|
||||
<None Remove="backups\**" />
|
||||
<None Remove="logs\**" />
|
||||
<None Remove="temp\**" />
|
||||
<None Remove="kavita.log" />
|
||||
<None Remove="kavita.db" />
|
||||
<None Remove="covers\**" />
|
||||
<None Remove="config\kavita.log" />
|
||||
<None Remove="config\kavita.db" />
|
||||
<None Remove="config\covers\**" />
|
||||
<None Remove="wwwroot\**" />
|
||||
<None Remove="cache\cache-long\**" />
|
||||
<None Remove="config\cache\**" />
|
||||
<None Remove="config\logs\**" />
|
||||
<None Remove="config\covers\**" />
|
||||
|
|
@ -139,6 +138,7 @@
|
|||
<Compile Remove="covers\**" />
|
||||
<Compile Remove="wwwroot\**" />
|
||||
<Compile Remove="config\cache\**" />
|
||||
<Compile Remove="cache\cache-long\**" />
|
||||
<Compile Remove="config\logs\**" />
|
||||
<Compile Remove="config\covers\**" />
|
||||
<Compile Remove="config\bookmarks\**" />
|
||||
|
|
@ -188,7 +188,6 @@
|
|||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="config\cache-long\" />
|
||||
<Folder Include="config\themes" />
|
||||
<Content Include="EmailTemplates\**">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ using System.Threading.Tasks;
|
|||
using API.Constants;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Filtering;
|
||||
using API.DTOs.Metadata;
|
||||
using API.DTOs.Person;
|
||||
using API.DTOs.Recommendation;
|
||||
using API.DTOs.SeriesDetail;
|
||||
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()));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ using API.DTOs.CollectionTags;
|
|||
using API.DTOs.Filtering;
|
||||
using API.DTOs.Filtering.v2;
|
||||
using API.DTOs.OPDS;
|
||||
using API.DTOs.Person;
|
||||
using API.DTOs.Progress;
|
||||
using API.DTOs.Search;
|
||||
using API.Entities;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Person;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Helpers;
|
||||
|
|
@ -24,9 +27,10 @@ public class PersonController : BaseApiController
|
|||
private readonly ICoverDbService _coverDbService;
|
||||
private readonly IImageService _imageService;
|
||||
private readonly IEventHub _eventHub;
|
||||
private readonly IPersonService _personService;
|
||||
|
||||
public PersonController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IMapper mapper,
|
||||
ICoverDbService coverDbService, IImageService imageService, IEventHub eventHub)
|
||||
ICoverDbService coverDbService, IImageService imageService, IEventHub eventHub, IPersonService personService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_localizationService = localizationService;
|
||||
|
|
@ -34,6 +38,7 @@ public class PersonController : BaseApiController
|
|||
_coverDbService = coverDbService;
|
||||
_imageService = imageService;
|
||||
_eventHub = eventHub;
|
||||
_personService = personService;
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -43,6 +48,17 @@ public class PersonController : BaseApiController
|
|||
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>
|
||||
/// Returns all roles for a Person
|
||||
/// </summary>
|
||||
|
|
@ -54,6 +70,7 @@ public class PersonController : BaseApiController
|
|||
return Ok(await _unitOfWork.PersonRepository.GetRolesForPersonByName(personId, User.GetUserId()));
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Returns a list of authors and artists for browsing
|
||||
/// </summary>
|
||||
|
|
@ -78,7 +95,7 @@ public class PersonController : BaseApiController
|
|||
public async Task<ActionResult<PersonDto>> UpdatePerson(UpdatePersonDto dto)
|
||||
{
|
||||
// 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 (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"));
|
||||
}
|
||||
|
||||
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.Description = dto.Description ?? string.Empty;
|
||||
person.CoverImageLocked = dto.CoverImageLocked;
|
||||
|
|
@ -173,5 +194,41 @@ public class PersonController : BaseApiController
|
|||
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.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Person;
|
||||
using API.DTOs.ReadingLists;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ public class SearchController : BaseApiController
|
|||
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||
if (user == null) return Unauthorized();
|
||||
|
||||
var libraries = _unitOfWork.LibraryRepository.GetLibraryIdsForUserIdAsync(user.Id, QueryContext.Search).ToList();
|
||||
if (libraries.Count == 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "libraries-restricted"));
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using API.DTOs.Metadata;
|
||||
using API.DTOs.Person;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Interfaces;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,14 +4,18 @@ using API.DTOs.Scrobbling;
|
|||
namespace API.DTOs.KavitaPlus.ExternalMetadata;
|
||||
#nullable enable
|
||||
|
||||
/// <summary>
|
||||
/// Represents a request to match some series from Kavita to an external id which K+ uses.
|
||||
/// </summary>
|
||||
internal sealed record MatchSeriesRequestDto
|
||||
{
|
||||
public string SeriesName { get; set; }
|
||||
public ICollection<string> AlternativeNames { get; set; }
|
||||
public required string SeriesName { get; set; }
|
||||
public ICollection<string> AlternativeNames { get; set; } = [];
|
||||
public int Year { get; set; } = 0;
|
||||
public string Query { get; set; }
|
||||
public string? Query { get; set; }
|
||||
public int? AniListId { get; set; }
|
||||
public long? MalId { get; set; }
|
||||
public string? HardcoverId { get; set; }
|
||||
public int? CbrId { get; set; }
|
||||
public PlusMediaFormat Format { get; set; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using API.DTOs.Person;
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.DTOs.Metadata;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
namespace API.DTOs;
|
||||
using API.DTOs.Person;
|
||||
|
||||
namespace API.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
|
||||
public class PersonDto
|
||||
|
|
@ -13,6 +13,7 @@ public class PersonDto
|
|||
public string? SecondaryColor { get; set; }
|
||||
|
||||
public string? CoverImage { get; set; }
|
||||
public List<string> Aliases { get; set; } = [];
|
||||
|
||||
public string? Description { get; set; }
|
||||
/// <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;
|
||||
#nullable enable
|
||||
|
|
@ -11,6 +12,7 @@ public sealed record UpdatePersonDto
|
|||
public bool CoverImageLocked { get; set; }
|
||||
[Required]
|
||||
public string Name {get; set;}
|
||||
public IList<string> Aliases { get; set; } = [];
|
||||
public string? Description { get; set; }
|
||||
|
||||
public int? AniListId { get; set; }
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Collections.Generic;
|
||||
using API.DTOs.Person;
|
||||
|
||||
namespace API.DTOs.ReadingLists;
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
using API.DTOs.Collection;
|
||||
using API.DTOs.CollectionTags;
|
||||
using API.DTOs.Metadata;
|
||||
using API.DTOs.Person;
|
||||
using API.DTOs.Reader;
|
||||
using API.DTOs.ReadingLists;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System.Collections.Generic;
|
||||
using API.DTOs.Metadata;
|
||||
using API.DTOs.Person;
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.DTOs;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using API.DTOs.Metadata;
|
||||
using API.DTOs.Person;
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.DTOs;
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
|
|||
public DbSet<ReadingList> ReadingList { get; set; } = null!;
|
||||
public DbSet<ReadingListItem> ReadingListItem { 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<Tag> Tag { 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");
|
||||
});
|
||||
|
||||
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 =>
|
||||
{
|
||||
b.Property<int>("SeriesMetadataId")
|
||||
|
|
@ -3082,6 +3104,17 @@ namespace API.Data.Migrations
|
|||
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 =>
|
||||
{
|
||||
b.HasOne("API.Entities.Person.Person", "Person")
|
||||
|
|
@ -3496,6 +3529,8 @@ namespace API.Data.Migrations
|
|||
|
||||
modelBuilder.Entity("API.Entities.Person.Person", b =>
|
||||
{
|
||||
b.Navigation("Aliases");
|
||||
|
||||
b.Navigation("ChapterPeople");
|
||||
|
||||
b.Navigation("SeriesMetadataPeople");
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Person;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Person;
|
||||
using API.Extensions;
|
||||
|
|
@ -14,6 +16,17 @@ using Microsoft.EntityFrameworkCore;
|
|||
namespace API.Data.Repositories;
|
||||
#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
|
||||
{
|
||||
void Attach(Person person);
|
||||
|
|
@ -23,24 +36,41 @@ public interface IPersonRepository
|
|||
void Remove(SeriesMetadataPeople person);
|
||||
void Update(Person person);
|
||||
|
||||
Task<IList<Person>> GetAllPeople();
|
||||
Task<IList<PersonDto>> GetAllPersonDtosAsync(int userId);
|
||||
Task<IList<PersonDto>> GetAllPersonDtosByRoleAsync(int userId, PersonRole role);
|
||||
Task<IList<Person>> GetAllPeople(PersonIncludes includes = PersonIncludes.Aliases);
|
||||
Task<IList<PersonDto>> GetAllPersonDtosAsync(int userId, PersonIncludes includes = PersonIncludes.None);
|
||||
Task<IList<PersonDto>> GetAllPersonDtosByRoleAsync(int userId, PersonRole role, PersonIncludes includes = PersonIncludes.None);
|
||||
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?> GetCoverImageByNameAsync(string name);
|
||||
Task<IEnumerable<PersonRole>> GetRolesForPersonByName(int personId, int userId);
|
||||
Task<PagedList<BrowsePersonDto>> GetAllWritersAndSeriesCount(int userId, UserParams userParams);
|
||||
Task<Person?> GetPersonById(int personId);
|
||||
Task<PersonDto?> GetPersonDtoByName(string name, int userId);
|
||||
Task<Person?> GetPersonById(int personId, PersonIncludes includes = PersonIncludes.None);
|
||||
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<IEnumerable<SeriesDto>> GetSeriesKnownFor(int personId);
|
||||
Task<IEnumerable<StandaloneChapterDto>> GetChaptersForPersonByRole(int personId, int userId, PersonRole role);
|
||||
Task<IList<Person>> GetPeopleByNames(List<string> normalizedNames);
|
||||
Task<Person?> GetPersonByAniListId(int aniListId);
|
||||
/// <summary>
|
||||
/// 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
|
||||
|
|
@ -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 userLibs = await _context.Library.GetUserLibraries(userId).ToListAsync();
|
||||
|
|
@ -113,6 +143,7 @@ public class PersonRepository : IPersonRepository
|
|||
.Where(s => userLibs.Contains(s.LibraryId))
|
||||
.RestrictAgainstAgeRestriction(ageRating)
|
||||
.SelectMany(s => s.Metadata.People.Select(p => p.Person))
|
||||
.Includes(includes)
|
||||
.Distinct()
|
||||
.OrderBy(p => p.Name)
|
||||
.AsNoTracking()
|
||||
|
|
@ -193,27 +224,41 @@ public class PersonRepository : IPersonRepository
|
|||
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)
|
||||
.Includes(includes)
|
||||
.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 ageRating = await _context.AppUser.GetUserAgeRestriction(userId);
|
||||
|
||||
return await _context.Person
|
||||
.Where(p => p.NormalizedName == normalized)
|
||||
.Includes(includes)
|
||||
.RestrictAgainstAgeRestriction(ageRating)
|
||||
.ProjectTo<PersonDto>(_mapper.ConfigurationProvider)
|
||||
.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)
|
||||
{
|
||||
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)
|
||||
|
|
@ -245,45 +290,69 @@ public class PersonRepository : IPersonRepository
|
|||
.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
|
||||
.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)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<Person?> GetPersonByAniListId(int aniListId)
|
||||
public async Task<Person?> GetPersonByAniListId(int aniListId, PersonIncludes includes = PersonIncludes.Aliases)
|
||||
{
|
||||
return await _context.Person
|
||||
.Where(p => p.AniListId == aniListId)
|
||||
.Includes(includes)
|
||||
.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
|
||||
.Includes(includes)
|
||||
.OrderBy(p => p.Name)
|
||||
.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);
|
||||
|
||||
return await _context.Person
|
||||
.Includes(includes)
|
||||
.OrderBy(p => p.Name)
|
||||
.RestrictAgainstAgeRestriction(ageRating)
|
||||
.ProjectTo<PersonDto>(_mapper.ConfigurationProvider)
|
||||
.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);
|
||||
|
||||
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
|
||||
.Includes(includes)
|
||||
.OrderBy(p => p.Name)
|
||||
.RestrictAgainstAgeRestriction(ageRating)
|
||||
.ProjectTo<PersonDto>(_mapper.ConfigurationProvider)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ using System.Collections.Generic;
|
|||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data.Misc;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Person;
|
||||
using API.DTOs.ReadingLists;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ using API.DTOs.Filtering;
|
|||
using API.DTOs.Filtering.v2;
|
||||
using API.DTOs.KavitaPlus.Metadata;
|
||||
using API.DTOs.Metadata;
|
||||
using API.DTOs.Person;
|
||||
using API.DTOs.ReadingLists;
|
||||
using API.DTOs.Recommendation;
|
||||
using API.DTOs.Scrobbling;
|
||||
|
|
@ -455,11 +456,18 @@ public class SeriesRepository : ISeriesRepository
|
|||
.ProjectTo<AppUserCollectionDto>(_mapper.ConfigurationProvider)
|
||||
.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)
|
||||
.Take(maxRecords)
|
||||
.OrderBy(t => t.NormalizedName)
|
||||
.Select(p => p.Id)
|
||||
.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)
|
||||
.ToListAsync();
|
||||
|
||||
|
|
@ -475,8 +483,8 @@ public class SeriesRepository : ISeriesRepository
|
|||
.ProjectTo<TagDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
|
||||
result.Files = new List<MangaFileDto>();
|
||||
result.Chapters = new List<ChapterDto>();
|
||||
result.Files = [];
|
||||
result.Chapters = (List<ChapterDto>) [];
|
||||
|
||||
|
||||
if (includeChapterAndFiles)
|
||||
|
|
|
|||
|
|
@ -8,8 +8,7 @@ public class Person : IHasCoverImage
|
|||
public int Id { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public required string NormalizedName { get; set; }
|
||||
|
||||
//public ICollection<PersonAlias> Aliases { get; set; } = default!;
|
||||
public ICollection<PersonAlias> Aliases { get; set; } = [];
|
||||
|
||||
public string? CoverImage { get; set; }
|
||||
public bool CoverImageLocked { get; set; }
|
||||
|
|
@ -47,8 +46,8 @@ public class Person : IHasCoverImage
|
|||
//public long MetronId { get; set; } = 0;
|
||||
|
||||
// Relationships
|
||||
public ICollection<ChapterPeople> ChapterPeople { get; set; } = new List<ChapterPeople>();
|
||||
public ICollection<SeriesMetadataPeople> SeriesMetadataPeople { get; set; } = new List<SeriesMetadataPeople>();
|
||||
public ICollection<ChapterPeople> ChapterPeople { get; set; } = [];
|
||||
public ICollection<SeriesMetadataPeople> SeriesMetadataPeople { get; set; } = [];
|
||||
|
||||
|
||||
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<IStreamService, StreamService>();
|
||||
services.AddScoped<IRatingService, RatingService>();
|
||||
services.AddScoped<IPersonService, PersonService>();
|
||||
|
||||
services.AddScoped<IScannerService, ScannerService>();
|
||||
services.AddScoped<IProcessSeries, ProcessSeries>();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using API.Data.Misc;
|
||||
using API.Data.Repositories;
|
||||
|
|
@ -49,23 +50,26 @@ public static class SearchQueryableExtensions
|
|||
// Get people from SeriesMetadata
|
||||
var peopleFromSeriesMetadata = queryable
|
||||
.Where(sm => seriesIds.Contains(sm.SeriesId))
|
||||
.SelectMany(sm => sm.People)
|
||||
.Where(p => p.Person.Name != null && EF.Functions.Like(p.Person.Name, $"%{searchQuery}%"))
|
||||
.Select(p => p.Person);
|
||||
.SelectMany(sm => sm.People.Select(sp => sp.Person))
|
||||
.Where(p =>
|
||||
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
|
||||
.Where(sm => seriesIds.Contains(sm.SeriesId))
|
||||
.SelectMany(sm => sm.Series.Volumes)
|
||||
.SelectMany(v => v.Chapters)
|
||||
.SelectMany(ch => ch.People)
|
||||
.Where(cp => cp.Person.Name != null && EF.Functions.Like(cp.Person.Name, $"%{searchQuery}%"))
|
||||
.Select(cp => cp.Person);
|
||||
.SelectMany(ch => ch.People.Select(cp => cp.Person))
|
||||
.Where(p =>
|
||||
EF.Functions.Like(p.Name, $"%{searchQuery}%") ||
|
||||
p.Aliases.Any(pa => EF.Functions.Like(pa.Alias, $"%{searchQuery}%"))
|
||||
);
|
||||
|
||||
// Combine both queries and ensure distinct results
|
||||
return peopleFromSeriesMetadata
|
||||
.Union(peopleFromChapterPeople)
|
||||
.Distinct()
|
||||
.Select(p => p)
|
||||
.OrderBy(p => p.NormalizedName);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
using System.Linq;
|
||||
using API.Data.Repositories;
|
||||
using API.Entities;
|
||||
using API.Entities.Metadata;
|
||||
using API.Entities.Person;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace API.Extensions.QueryExtensions;
|
||||
|
|
@ -321,4 +321,25 @@ public static class IncludesExtensions
|
|||
|
||||
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.MediaErrors;
|
||||
using API.DTOs.Metadata;
|
||||
using API.DTOs.Person;
|
||||
using API.DTOs.Progress;
|
||||
using API.DTOs.Reader;
|
||||
using API.DTOs.ReadingLists;
|
||||
|
|
@ -68,7 +69,8 @@ public class AutoMapperProfiles : Profile
|
|||
CreateMap<AppUserCollection, AppUserCollectionDto>()
|
||||
.ForMember(dest => dest.Owner, opt => opt.MapFrom(src => src.AppUser.UserName))
|
||||
.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<Tag, TagDto>();
|
||||
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 API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Metadata;
|
||||
using System.Linq;
|
||||
using API.Entities.Person;
|
||||
using API.Extensions;
|
||||
|
||||
|
|
@ -34,6 +32,20 @@ public class PersonBuilder : IEntityBuilder<Person>
|
|||
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)
|
||||
{
|
||||
_person.SeriesMetadataPeople.Add(seriesMetadataPeople);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,20 @@ namespace API.Helpers;
|
|||
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,
|
||||
IEnumerable<ChapterPeople> chapterPeople, PersonRole role, IUnitOfWork unitOfWork)
|
||||
{
|
||||
|
|
@ -38,7 +52,9 @@ public static class PersonHelper
|
|||
|
||||
// Identify people to remove from metadataPeople
|
||||
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();
|
||||
|
||||
// Remove identified people from metadataPeople
|
||||
|
|
@ -53,11 +69,7 @@ public static class PersonHelper
|
|||
.GetPeopleByNames(peopleToAdd.Select(p => p.NormalizedName).ToList());
|
||||
|
||||
// Prepare a dictionary for quick lookup of existing people by normalized name
|
||||
var existingPeopleDict = new Dictionary<string, Person>();
|
||||
foreach (var person in existingPeopleInDb)
|
||||
{
|
||||
existingPeopleDict.TryAdd(person.NormalizedName, person);
|
||||
}
|
||||
var existingPeopleDict = ConstructNameAndAliasDictionary(existingPeopleInDb);
|
||||
|
||||
// Track the people to attach (newly created people)
|
||||
var peopleToAttach = new List<Person>();
|
||||
|
|
@ -129,15 +141,12 @@ public static class PersonHelper
|
|||
var existingPeople = await unitOfWork.PersonRepository.GetPeopleByNames(normalizedPeople);
|
||||
|
||||
// Prepare a dictionary for quick lookup by normalized name
|
||||
var existingPeopleDict = new Dictionary<string, Person>();
|
||||
foreach (var person in existingPeople)
|
||||
{
|
||||
existingPeopleDict.TryAdd(person.NormalizedName, person);
|
||||
}
|
||||
var existingPeopleDict = ConstructNameAndAliasDictionary(existingPeople);
|
||||
|
||||
// Identify people to remove (those present in ChapterPeople but not in the new list)
|
||||
foreach (var existingChapterPerson in existingChapterPeople
|
||||
.Where(existingChapterPerson => !normalizedPeople.Contains(existingChapterPerson.Person.NormalizedName)))
|
||||
var toRemove = existingChapterPeople
|
||||
.Where(existingChapterPerson => !normalizedPeople.Contains(existingChapterPerson.Person.NormalizedName));
|
||||
foreach (var existingChapterPerson in toRemove)
|
||||
{
|
||||
chapter.People.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ů",
|
||||
"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",
|
||||
"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",
|
||||
"series-restricted-age-restriction": "User is not allowed to view this series due to age restrictions",
|
||||
"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}",
|
||||
"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",
|
||||
"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",
|
||||
"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-image-doesnt-exist": "CoversDB에 사람이 존재하지 않습니다",
|
||||
"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",
|
||||
"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",
|
||||
"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 не включен на этом сервере",
|
||||
"stats-permission-denied": "Вы не имеете права просматривать статистику другого пользователя",
|
||||
"reading-list-restricted": "Список чтения не существует или у вас нет доступа",
|
||||
"favicon-doesnt-exist": "Фавикон не существует",
|
||||
"favicon-doesnt-exist": "Favicon не существует",
|
||||
"external-source-already-in-use": "Существует поток с этим внешним источником",
|
||||
"issue-num": "Вопрос {0}{1}",
|
||||
"generic-create-temp-archive": "Возникла проблема с созданием временного архива",
|
||||
|
|
@ -194,5 +194,13 @@
|
|||
"backup": "Резервное копирование",
|
||||
"process-processed-scrobbling-events": "Обработка обработанных событий скроблинга",
|
||||
"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": "只能从仪表板中删除智能筛选器流",
|
||||
"smart-filter-name-required": "需要智能筛选器名称",
|
||||
"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.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
|
|
@ -26,7 +27,7 @@ public class SecurityEventMiddleware(RequestDelegate next)
|
|||
}
|
||||
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 requestPath = context.Request.Path;
|
||||
var userAgent = context.Request.Headers.UserAgent;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.IO.Abstractions;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
|
|
@ -48,15 +49,13 @@ public class Program
|
|||
|
||||
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
|
||||
if (!Configuration.CheckIfJwtTokenSet() &&
|
||||
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);
|
||||
}
|
||||
EnsureJwtTokenKey();
|
||||
|
||||
try
|
||||
{
|
||||
|
|
@ -70,6 +69,7 @@ public class Program
|
|||
{
|
||||
var logger = services.GetRequiredService<ILogger<Program>>();
|
||||
var context = services.GetRequiredService<DataContext>();
|
||||
|
||||
var pendingMigrations = await context.Database.GetPendingMigrationsAsync();
|
||||
var isDbCreated = await context.Database.CanConnectAsync();
|
||||
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)
|
||||
{
|
||||
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.Metadata;
|
||||
using API.DTOs.Metadata.Matching;
|
||||
using API.DTOs.Person;
|
||||
using API.DTOs.Recommendation;
|
||||
using API.DTOs.Scrobbling;
|
||||
using API.DTOs.SeriesDetail;
|
||||
|
|
@ -17,8 +18,10 @@ using API.Entities;
|
|||
using API.Entities.Enums;
|
||||
using API.Entities.Metadata;
|
||||
using API.Entities.MetadataMatching;
|
||||
using API.Entities.Person;
|
||||
using API.Extensions;
|
||||
using API.Helpers;
|
||||
using API.Helpers.Builders;
|
||||
using API.Services.Tasks.Metadata;
|
||||
using API.Services.Tasks.Scanner.Parser;
|
||||
using API.SignalR;
|
||||
|
|
@ -223,7 +226,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||
AlternativeNames = altNames.Where(s => !string.IsNullOrEmpty(s)).ToList(),
|
||||
Year = series.Metadata.ReleaseYear,
|
||||
AniListId = potentialAnilistId ?? ScrobblingService.GetAniListId(series),
|
||||
MalId = potentialMalId ?? ScrobblingService.GetMalId(series),
|
||||
MalId = potentialMalId ?? ScrobblingService.GetMalId(series)
|
||||
};
|
||||
|
||||
var token = (await _unitOfWork.UserRepository.GetDefaultAdminUser()).AniListAccessToken;
|
||||
|
|
@ -614,12 +617,8 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||
madeModification = await UpdateTags(series, settings, externalMetadata, processedTags) || madeModification;
|
||||
madeModification = UpdateAgeRating(series, settings, processedGenres.Concat(processedTags)) || madeModification;
|
||||
|
||||
var staff = (externalMetadata.Staff ?? []).Select(s =>
|
||||
{
|
||||
s.Name = settings.FirstLastPeopleNaming ? $"{s.FirstName} {s.LastName}" : $"{s.LastName} {s.FirstName}";
|
||||
var staff = await SetNameAndAddAliases(settings, externalMetadata.Staff);
|
||||
|
||||
return s;
|
||||
}).ToList();
|
||||
madeModification = await UpdateWriters(series, settings, staff) || madeModification;
|
||||
madeModification = await UpdateArtists(series, settings, staff) || madeModification;
|
||||
madeModification = await UpdateCharacters(series, settings, externalMetadata.Characters) || madeModification;
|
||||
|
|
@ -632,6 +631,49 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||
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,
|
||||
ref List<string> processedTags, ref List<string> processedGenres)
|
||||
{
|
||||
|
|
@ -750,7 +792,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||
var characters = externalCharacters
|
||||
.Select(w => new PersonDto()
|
||||
{
|
||||
Name = w.Name,
|
||||
Name = w.Name.Trim(),
|
||||
AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListCharacterWebsite),
|
||||
Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))),
|
||||
})
|
||||
|
|
@ -831,7 +873,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||
var artists = upstreamArtists
|
||||
.Select(w => new PersonDto()
|
||||
{
|
||||
Name = w.Name,
|
||||
Name = w.Name.Trim(),
|
||||
AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListStaffWebsite),
|
||||
Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))),
|
||||
})
|
||||
|
|
@ -887,7 +929,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||
var writers = upstreamWriters
|
||||
.Select(w => new PersonDto()
|
||||
{
|
||||
Name = w.Name,
|
||||
Name = w.Name.Trim(),
|
||||
AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListStaffWebsite),
|
||||
Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))),
|
||||
})
|
||||
|
|
@ -1311,7 +1353,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||
var people = staff!
|
||||
.Select(w => new PersonDto()
|
||||
{
|
||||
Name = w,
|
||||
Name = w.Trim(),
|
||||
})
|
||||
.Concat(chapter.People
|
||||
.Where(p => p.Role == role)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ using API.Comparators;
|
|||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Person;
|
||||
using API.DTOs.SeriesDetail;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
|
|
@ -361,8 +362,7 @@ public class SeriesService : ISeriesService
|
|||
var existingPeople = await unitOfWork.PersonRepository.GetPeopleByNames(normalizedNames);
|
||||
|
||||
// Use a dictionary for quick lookups
|
||||
var existingPeopleDictionary = existingPeople.DistinctBy(p => p.NormalizedName)
|
||||
.ToDictionary(p => p.NormalizedName, p => p);
|
||||
var existingPeopleDictionary = PersonHelper.ConstructNameAndAliasDictionary(existingPeople);
|
||||
|
||||
// List to track people that will be added to the metadata
|
||||
var peopleToAdd = new List<Person>();
|
||||
|
|
|
|||
|
|
@ -501,7 +501,7 @@ public class CoverDbService : ICoverDbService
|
|||
else
|
||||
{
|
||||
_directoryService.DeleteFiles([tempFullPath]);
|
||||
person.CoverImage = Path.GetFileName(existingPath);
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
|
|
@ -572,14 +572,20 @@ public class CoverDbService : ICoverDbService
|
|||
var choseNewImage = string.Equals(betterImage, tempFullPath, StringComparison.OrdinalIgnoreCase);
|
||||
if (choseNewImage)
|
||||
{
|
||||
_directoryService.DeleteFiles([existingPath]);
|
||||
|
||||
// Don't delete series cover, unless it's an override, otherwise the first chapter cover will be null
|
||||
if (existingPath.Contains(ImageService.GetSeriesFormat(series.Id)))
|
||||
{
|
||||
_directoryService.DeleteFiles([existingPath]);
|
||||
}
|
||||
|
||||
_directoryService.CopyFile(tempFullPath, finalFullPath);
|
||||
series.CoverImage = finalFileName;
|
||||
}
|
||||
else
|
||||
{
|
||||
_directoryService.DeleteFiles([tempFullPath]);
|
||||
series.CoverImage = Path.GetFileName(existingPath);
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
|
@ -651,6 +657,7 @@ public class CoverDbService : ICoverDbService
|
|||
else
|
||||
{
|
||||
_directoryService.DeleteFiles([tempFullPath]);
|
||||
return;
|
||||
}
|
||||
|
||||
chapter.CoverImage = finalFileName;
|
||||
|
|
|
|||
|
|
@ -310,7 +310,7 @@ public class LibraryWatcher : ILibraryWatcher
|
|||
if (rootFolder.Count == 0) return string.Empty;
|
||||
|
||||
// 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<IList<UpdateNotificationDto>> GetAllReleases(int count = 0);
|
||||
Task<int> GetNumberOfReleasesBehind(bool stableOnly = false);
|
||||
void BustGithubCache();
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -384,7 +385,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService
|
|||
if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc <= CacheDuration)
|
||||
{
|
||||
var cachedData = await File.ReadAllTextAsync(_cacheLatestReleaseFilePath);
|
||||
return System.Text.Json.JsonSerializer.Deserialize<UpdateNotificationDto>(cachedData);
|
||||
return JsonSerializer.Deserialize<UpdateNotificationDto>(cachedData);
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
@ -407,7 +408,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService
|
|||
{
|
||||
try
|
||||
{
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(update, JsonOptions);
|
||||
var json = JsonSerializer.Serialize(update, JsonOptions);
|
||||
await File.WriteAllTextAsync(_cacheLatestReleaseFilePath, json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
|
@ -446,6 +447,21 @@ public partial class VersionUpdaterService : IVersionUpdaterService
|
|||
.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)
|
||||
{
|
||||
if (update == null || string.IsNullOrEmpty(update.Tag_Name)) return null;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using API.DTOs.Update;
|
||||
using API.Entities.Person;
|
||||
using API.Extensions;
|
||||
using API.Services.Plus;
|
||||
|
||||
|
|
@ -147,6 +148,10 @@ public static class MessageFactory
|
|||
/// Volume is removed from server
|
||||
/// </summary>
|
||||
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)
|
||||
{
|
||||
|
|
@ -661,4 +666,17 @@ public static class MessageFactory
|
|||
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;
|
||||
_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.
|
||||
|
|
@ -223,7 +226,7 @@ public class Startup
|
|||
// 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,
|
||||
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>>();
|
||||
|
|
@ -235,9 +238,10 @@ public class Startup
|
|||
// Apply all migrations on startup
|
||||
var dataContext = serviceProvider.GetRequiredService<DataContext>();
|
||||
|
||||
|
||||
logger.LogInformation("Running Migrations");
|
||||
|
||||
#region Migrations
|
||||
|
||||
// v0.7.9
|
||||
await MigrateUserLibrarySideNavStream.Migrate(unitOfWork, dataContext, logger);
|
||||
|
||||
|
|
@ -289,13 +293,23 @@ public class Startup
|
|||
await ManualMigrateScrobbleSpecials.Migrate(dataContext, logger);
|
||||
await ManualMigrateScrobbleEventGen.Migrate(dataContext, logger);
|
||||
|
||||
#endregion
|
||||
|
||||
// Update the version in the DB after all migrations are run
|
||||
var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion);
|
||||
var isVersionDifferent = installVersion.Value != BuildInfo.Version.ToString();
|
||||
installVersion.Value = BuildInfo.Version.ToString();
|
||||
unitOfWork.SettingsRepository.Update(installVersion);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
logger.LogInformation("Running Migrations - complete");
|
||||
|
||||
if (isVersionDifferent)
|
||||
{
|
||||
// Clear the Github cache so update stuff shows correctly
|
||||
versionService.BustGithubCache();
|
||||
}
|
||||
|
||||
}).GetAwaiter()
|
||||
.GetResult();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Company>kavitareader.com</Company>
|
||||
<Product>Kavita</Product>
|
||||
<AssemblyVersion>0.8.6.8</AssemblyVersion>
|
||||
<AssemblyVersion>0.8.6.11</AssemblyVersion>
|
||||
<NeutralLanguage>en</NeutralLanguage>
|
||||
<TieredPGO>true</TieredPGO>
|
||||
</PropertyGroup>
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
<PackageReference Include="Flurl.Http" Version="4.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" 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>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
font-size: 1.75rem;
|
||||
}
|
||||
.subtitle {
|
||||
color: lightgrey;
|
||||
color: var(--detail-subtitle-color);;
|
||||
font-weight: bold;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ export enum LibraryType {
|
|||
Book = 2,
|
||||
Images = 3,
|
||||
LightNovel = 4,
|
||||
/**
|
||||
* Comic (Legacy)
|
||||
*/
|
||||
ComicVine = 5
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export interface Person extends IHasCover {
|
|||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
aliases: Array<string>;
|
||||
coverImage?: string;
|
||||
coverImageLocked: boolean;
|
||||
malId?: number;
|
||||
|
|
|
|||
|
|
@ -7,12 +7,13 @@ import {Library} from '../_models/library/library';
|
|||
import {ReadingList} from '../_models/reading-list';
|
||||
import {Series} from '../_models/series';
|
||||
import {Volume} from '../_models/volume';
|
||||
import {AccountService} from './account.service';
|
||||
import {AccountService, Role} from './account.service';
|
||||
import {DeviceService} from './device.service';
|
||||
import {SideNavStream} from "../_models/sidenav/sidenav-stream";
|
||||
import {SmartFilter} from "../_models/metadata/v2/smart-filter";
|
||||
import {translate} from "@jsverse/transloco";
|
||||
import {Person} from "../_models/metadata/person";
|
||||
import {User} from '../_models/user';
|
||||
|
||||
export enum Action {
|
||||
Submenu = -1,
|
||||
|
|
@ -106,7 +107,7 @@ export enum Action {
|
|||
Promote = 24,
|
||||
UnPromote = 25,
|
||||
/**
|
||||
* Invoke a refresh covers as false to generate colorscapes
|
||||
* Invoke refresh covers as false to generate colorscapes
|
||||
*/
|
||||
GenerateColorScape = 26,
|
||||
/**
|
||||
|
|
@ -116,20 +117,31 @@ export enum Action {
|
|||
/**
|
||||
* Match an entity with an upstream system
|
||||
*/
|
||||
Match = 28
|
||||
Match = 28,
|
||||
/**
|
||||
* Merge two (or more?) entities
|
||||
*/
|
||||
Merge = 29,
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for an action
|
||||
*/
|
||||
export type ActionCallback<T> = (action: ActionItem<T>, data: T) => void;
|
||||
export type ActionAllowedCallback<T> = (action: ActionItem<T>) => boolean;
|
||||
export type ActionCallback<T> = (action: ActionItem<T>, entity: T) => void;
|
||||
export type ActionShouldRenderFunc<T> = (action: ActionItem<T>, entity: T, user: User) => boolean;
|
||||
|
||||
export interface ActionItem<T> {
|
||||
title: string;
|
||||
description: string;
|
||||
action: Action;
|
||||
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;
|
||||
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?: {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({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ActionFactoryService {
|
||||
libraryActions: Array<ActionItem<Library>> = [];
|
||||
|
||||
seriesActions: Array<ActionItem<Series>> = [];
|
||||
|
||||
volumeActions: Array<ActionItem<Volume>> = [];
|
||||
|
||||
chapterActions: Array<ActionItem<Chapter>> = [];
|
||||
|
||||
collectionTagActions: Array<ActionItem<UserCollection>> = [];
|
||||
|
||||
readingListActions: Array<ActionItem<ReadingList>> = [];
|
||||
|
||||
bookmarkActions: Array<ActionItem<Series>> = [];
|
||||
|
||||
private libraryActions: Array<ActionItem<Library>> = [];
|
||||
private seriesActions: Array<ActionItem<Series>> = [];
|
||||
private volumeActions: Array<ActionItem<Volume>> = [];
|
||||
private chapterActions: Array<ActionItem<Chapter>> = [];
|
||||
private collectionTagActions: Array<ActionItem<UserCollection>> = [];
|
||||
private readingListActions: Array<ActionItem<ReadingList>> = [];
|
||||
private bookmarkActions: Array<ActionItem<Series>> = [];
|
||||
private personActions: Array<ActionItem<Person>> = [];
|
||||
|
||||
sideNavStreamActions: Array<ActionItem<SideNavStream>> = [];
|
||||
smartFilterActions: Array<ActionItem<SmartFilter>> = [];
|
||||
|
||||
sideNavHomeActions: Array<ActionItem<void>> = [];
|
||||
|
||||
isAdmin = false;
|
||||
|
||||
private sideNavStreamActions: Array<ActionItem<SideNavStream>> = [];
|
||||
private smartFilterActions: Array<ActionItem<SmartFilter>> = [];
|
||||
private sideNavHomeActions: Array<ActionItem<void>> = [];
|
||||
|
||||
constructor(private accountService: AccountService, private deviceService: DeviceService) {
|
||||
this.accountService.currentUser$.subscribe((user) => {
|
||||
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.accountService.currentUser$.subscribe((_) => {
|
||||
this._resetActions();
|
||||
});
|
||||
}
|
||||
|
||||
getLibraryActions(callback: ActionCallback<Library>) {
|
||||
return this.applyCallbackToList(this.libraryActions, callback);
|
||||
getLibraryActions(callback: ActionCallback<Library>, shouldRenderFunc: ActionShouldRenderFunc<Library> = this.dummyShouldRender) {
|
||||
return this.applyCallbackToList(this.libraryActions, callback, shouldRenderFunc) as ActionItem<Library>[];
|
||||
}
|
||||
|
||||
getSeriesActions(callback: ActionCallback<Series>) {
|
||||
return this.applyCallbackToList(this.seriesActions, callback);
|
||||
getSeriesActions(callback: ActionCallback<Series>, shouldRenderFunc: ActionShouldRenderFunc<Series> = this.basicReadRender) {
|
||||
return this.applyCallbackToList(this.seriesActions, callback, shouldRenderFunc);
|
||||
}
|
||||
|
||||
getSideNavStreamActions(callback: ActionCallback<SideNavStream>) {
|
||||
return this.applyCallbackToList(this.sideNavStreamActions, callback);
|
||||
getSideNavStreamActions(callback: ActionCallback<SideNavStream>, shouldRenderFunc: ActionShouldRenderFunc<SideNavStream> = this.dummyShouldRender) {
|
||||
return this.applyCallbackToList(this.sideNavStreamActions, callback, shouldRenderFunc);
|
||||
}
|
||||
|
||||
getSmartFilterActions(callback: ActionCallback<SmartFilter>) {
|
||||
return this.applyCallbackToList(this.smartFilterActions, callback);
|
||||
getSmartFilterActions(callback: ActionCallback<SmartFilter>, shouldRenderFunc: ActionShouldRenderFunc<SmartFilter> = this.dummyShouldRender) {
|
||||
return this.applyCallbackToList(this.smartFilterActions, callback, shouldRenderFunc);
|
||||
}
|
||||
|
||||
getVolumeActions(callback: ActionCallback<Volume>) {
|
||||
return this.applyCallbackToList(this.volumeActions, callback);
|
||||
getVolumeActions(callback: ActionCallback<Volume>, shouldRenderFunc: ActionShouldRenderFunc<Volume> = this.basicReadRender) {
|
||||
return this.applyCallbackToList(this.volumeActions, callback, shouldRenderFunc);
|
||||
}
|
||||
|
||||
getChapterActions(callback: ActionCallback<Chapter>) {
|
||||
return this.applyCallbackToList(this.chapterActions, callback);
|
||||
getChapterActions(callback: ActionCallback<Chapter>, shouldRenderFunc: ActionShouldRenderFunc<Chapter> = this.basicReadRender) {
|
||||
return this.applyCallbackToList(this.chapterActions, callback, shouldRenderFunc);
|
||||
}
|
||||
|
||||
getCollectionTagActions(callback: ActionCallback<UserCollection>) {
|
||||
return this.applyCallbackToList(this.collectionTagActions, callback);
|
||||
getCollectionTagActions(callback: ActionCallback<UserCollection>, shouldRenderFunc: ActionShouldRenderFunc<UserCollection> = this.dummyShouldRender) {
|
||||
return this.applyCallbackToList(this.collectionTagActions, callback, shouldRenderFunc);
|
||||
}
|
||||
|
||||
getReadingListActions(callback: ActionCallback<ReadingList>) {
|
||||
return this.applyCallbackToList(this.readingListActions, callback);
|
||||
getReadingListActions(callback: ActionCallback<ReadingList>, shouldRenderFunc: ActionShouldRenderFunc<ReadingList> = this.dummyShouldRender) {
|
||||
return this.applyCallbackToList(this.readingListActions, callback, shouldRenderFunc);
|
||||
}
|
||||
|
||||
getBookmarkActions(callback: ActionCallback<Series>) {
|
||||
return this.applyCallbackToList(this.bookmarkActions, callback);
|
||||
getBookmarkActions(callback: ActionCallback<Series>, shouldRenderFunc: ActionShouldRenderFunc<Series> = this.dummyShouldRender) {
|
||||
return this.applyCallbackToList(this.bookmarkActions, callback, shouldRenderFunc);
|
||||
}
|
||||
|
||||
getPersonActions(callback: ActionCallback<Person>) {
|
||||
return this.applyCallbackToList(this.personActions, callback);
|
||||
getPersonActions(callback: ActionCallback<Person>, shouldRenderFunc: ActionShouldRenderFunc<Person> = this.dummyShouldRender) {
|
||||
return this.applyCallbackToList(this.personActions, callback, shouldRenderFunc);
|
||||
}
|
||||
|
||||
getSideNavHomeActions(callback: ActionCallback<void>) {
|
||||
return this.applyCallbackToList(this.sideNavHomeActions, callback);
|
||||
getSideNavHomeActions(callback: ActionCallback<void>, shouldRenderFunc: ActionShouldRenderFunc<void> = this.dummyShouldRender) {
|
||||
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) {
|
||||
// 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));
|
||||
}
|
||||
|
||||
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
|
||||
const actions = this.flattenActions<Library>(this.libraryActions).filter(a => {
|
||||
|
|
@ -289,11 +305,13 @@ export class ActionFactoryService {
|
|||
dynamicList: undefined,
|
||||
action: Action.CopySettings,
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: shouldRenderFunc,
|
||||
children: [],
|
||||
requiredRoles: [Role.Admin],
|
||||
requiresAdmin: true,
|
||||
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>> {
|
||||
|
|
@ -319,7 +337,9 @@ export class ActionFactoryService {
|
|||
title: 'scan-library',
|
||||
description: 'scan-library-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: true,
|
||||
requiredRoles: [Role.Admin],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
|
|
@ -327,14 +347,18 @@ export class ActionFactoryService {
|
|||
title: 'others',
|
||||
description: '',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: true,
|
||||
requiredRoles: [Role.Admin],
|
||||
children: [
|
||||
{
|
||||
action: Action.RefreshMetadata,
|
||||
title: 'refresh-covers',
|
||||
description: 'refresh-covers-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: true,
|
||||
requiredRoles: [Role.Admin],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
|
|
@ -342,7 +366,9 @@ export class ActionFactoryService {
|
|||
title: 'generate-colorscape',
|
||||
description: 'generate-colorscape-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: true,
|
||||
requiredRoles: [Role.Admin],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
|
|
@ -350,7 +376,9 @@ export class ActionFactoryService {
|
|||
title: 'analyze-files',
|
||||
description: 'analyze-files-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: true,
|
||||
requiredRoles: [Role.Admin],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
|
|
@ -358,7 +386,9 @@ export class ActionFactoryService {
|
|||
title: 'delete',
|
||||
description: 'delete-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: true,
|
||||
requiredRoles: [Role.Admin],
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
|
|
@ -368,7 +398,9 @@ export class ActionFactoryService {
|
|||
title: 'settings',
|
||||
description: 'settings-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: true,
|
||||
requiredRoles: [Role.Admin],
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
|
|
@ -379,7 +411,9 @@ export class ActionFactoryService {
|
|||
title: 'edit',
|
||||
description: 'edit-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
|
|
@ -387,7 +421,9 @@ export class ActionFactoryService {
|
|||
title: 'delete',
|
||||
description: 'delete-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
class: 'danger',
|
||||
children: [],
|
||||
},
|
||||
|
|
@ -396,7 +432,9 @@ export class ActionFactoryService {
|
|||
title: 'promote',
|
||||
description: 'promote-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
|
|
@ -404,7 +442,9 @@ export class ActionFactoryService {
|
|||
title: 'unpromote',
|
||||
description: 'unpromote-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
|
|
@ -415,7 +455,9 @@ export class ActionFactoryService {
|
|||
title: 'mark-as-read',
|
||||
description: 'mark-as-read-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
|
|
@ -423,7 +465,9 @@ export class ActionFactoryService {
|
|||
title: 'mark-as-unread',
|
||||
description: 'mark-as-unread-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
|
|
@ -431,7 +475,9 @@ export class ActionFactoryService {
|
|||
title: 'scan-series',
|
||||
description: 'scan-series-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: true,
|
||||
requiredRoles: [Role.Admin],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
|
|
@ -439,14 +485,18 @@ export class ActionFactoryService {
|
|||
title: 'add-to',
|
||||
description: '',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [
|
||||
{
|
||||
action: Action.AddToWantToReadList,
|
||||
title: 'add-to-want-to-read',
|
||||
description: 'add-to-want-to-read-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
|
|
@ -454,7 +504,9 @@ export class ActionFactoryService {
|
|||
title: 'remove-from-want-to-read',
|
||||
description: 'remove-to-want-to-read-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
|
|
@ -462,7 +514,9 @@ export class ActionFactoryService {
|
|||
title: 'add-to-reading-list',
|
||||
description: 'add-to-reading-list-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
|
|
@ -470,26 +524,11 @@ export class ActionFactoryService {
|
|||
title: 'add-to-collection',
|
||||
description: 'add-to-collection-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
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',
|
||||
description: 'send-to-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [
|
||||
{
|
||||
action: Action.SendTo,
|
||||
title: '',
|
||||
description: '',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
dynamicList: this.deviceService.devices$.pipe(map((devices: Array<Device>) => devices.map(d => {
|
||||
return {'title': d.name, 'data': d};
|
||||
}), shareReplay())),
|
||||
|
|
@ -517,14 +560,18 @@ export class ActionFactoryService {
|
|||
title: 'others',
|
||||
description: '',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: true,
|
||||
requiredRoles: [],
|
||||
children: [
|
||||
{
|
||||
action: Action.RefreshMetadata,
|
||||
title: 'refresh-covers',
|
||||
description: 'refresh-covers-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: true,
|
||||
requiredRoles: [Role.Admin],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
|
|
@ -532,7 +579,9 @@ export class ActionFactoryService {
|
|||
title: 'generate-colorscape',
|
||||
description: 'generate-colorscape-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: true,
|
||||
requiredRoles: [Role.Admin],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
|
|
@ -540,7 +589,9 @@ export class ActionFactoryService {
|
|||
title: 'analyze-files',
|
||||
description: 'analyze-files-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: true,
|
||||
requiredRoles: [Role.Admin],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
|
|
@ -548,7 +599,9 @@ export class ActionFactoryService {
|
|||
title: 'delete',
|
||||
description: 'delete-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: true,
|
||||
requiredRoles: [Role.Admin],
|
||||
class: 'danger',
|
||||
children: [],
|
||||
},
|
||||
|
|
@ -559,7 +612,9 @@ export class ActionFactoryService {
|
|||
title: 'match',
|
||||
description: 'match-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: true,
|
||||
requiredRoles: [Role.Admin],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
|
|
@ -567,7 +622,9 @@ export class ActionFactoryService {
|
|||
title: 'download',
|
||||
description: 'download-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [Role.Download],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
|
|
@ -575,7 +632,9 @@ export class ActionFactoryService {
|
|||
title: 'edit',
|
||||
description: 'edit-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: true,
|
||||
requiredRoles: [Role.Admin],
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
|
|
@ -586,7 +645,9 @@ export class ActionFactoryService {
|
|||
title: 'read-incognito',
|
||||
description: 'read-incognito-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
|
|
@ -594,7 +655,9 @@ export class ActionFactoryService {
|
|||
title: 'mark-as-read',
|
||||
description: 'mark-as-read-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
|
|
@ -602,7 +665,9 @@ export class ActionFactoryService {
|
|||
title: 'mark-as-unread',
|
||||
description: 'mark-as-unread-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
|
|
@ -610,14 +675,18 @@ export class ActionFactoryService {
|
|||
title: 'add-to',
|
||||
description: '=',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [
|
||||
{
|
||||
action: Action.AddToReadingList,
|
||||
title: 'add-to-reading-list',
|
||||
description: 'add-to-reading-list-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
}
|
||||
]
|
||||
|
|
@ -627,14 +696,18 @@ export class ActionFactoryService {
|
|||
title: 'send-to',
|
||||
description: 'send-to-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [
|
||||
{
|
||||
action: Action.SendTo,
|
||||
title: '',
|
||||
description: '',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
dynamicList: this.deviceService.devices$.pipe(map((devices: Array<Device>) => devices.map(d => {
|
||||
return {'title': d.name, 'data': d};
|
||||
}), shareReplay())),
|
||||
|
|
@ -647,14 +720,18 @@ export class ActionFactoryService {
|
|||
title: 'others',
|
||||
description: '',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [
|
||||
{
|
||||
action: Action.Delete,
|
||||
title: 'delete',
|
||||
description: 'delete-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: true,
|
||||
requiredRoles: [Role.Admin],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
|
|
@ -662,7 +739,9 @@ export class ActionFactoryService {
|
|||
title: 'download',
|
||||
description: 'download-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
]
|
||||
|
|
@ -672,7 +751,9 @@ export class ActionFactoryService {
|
|||
title: 'details',
|
||||
description: 'edit-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
|
|
@ -683,7 +764,9 @@ export class ActionFactoryService {
|
|||
title: 'read-incognito',
|
||||
description: 'read-incognito-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
|
|
@ -691,7 +774,9 @@ export class ActionFactoryService {
|
|||
title: 'mark-as-read',
|
||||
description: 'mark-as-read-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
|
|
@ -699,7 +784,9 @@ export class ActionFactoryService {
|
|||
title: 'mark-as-unread',
|
||||
description: 'mark-as-unread-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
|
|
@ -707,14 +794,18 @@ export class ActionFactoryService {
|
|||
title: 'add-to',
|
||||
description: '',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [
|
||||
{
|
||||
action: Action.AddToReadingList,
|
||||
title: 'add-to-reading-list',
|
||||
description: 'add-to-reading-list-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
}
|
||||
]
|
||||
|
|
@ -724,14 +815,18 @@ export class ActionFactoryService {
|
|||
title: 'send-to',
|
||||
description: 'send-to-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [
|
||||
{
|
||||
action: Action.SendTo,
|
||||
title: '',
|
||||
description: '',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
dynamicList: this.deviceService.devices$.pipe(map((devices: Array<Device>) => devices.map(d => {
|
||||
return {'title': d.name, 'data': d};
|
||||
}), shareReplay())),
|
||||
|
|
@ -745,14 +840,18 @@ export class ActionFactoryService {
|
|||
title: 'others',
|
||||
description: '',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [
|
||||
{
|
||||
action: Action.Delete,
|
||||
title: 'delete',
|
||||
description: 'delete-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: true,
|
||||
requiredRoles: [Role.Admin],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
|
|
@ -760,7 +859,9 @@ export class ActionFactoryService {
|
|||
title: 'download',
|
||||
description: 'download-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [Role.Download],
|
||||
children: [],
|
||||
},
|
||||
]
|
||||
|
|
@ -770,7 +871,9 @@ export class ActionFactoryService {
|
|||
title: 'edit',
|
||||
description: 'edit-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
|
|
@ -781,7 +884,9 @@ export class ActionFactoryService {
|
|||
title: 'edit',
|
||||
description: 'edit-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
|
|
@ -789,7 +894,9 @@ export class ActionFactoryService {
|
|||
title: 'delete',
|
||||
description: 'delete-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
class: 'danger',
|
||||
children: [],
|
||||
},
|
||||
|
|
@ -798,7 +905,9 @@ export class ActionFactoryService {
|
|||
title: 'promote',
|
||||
description: 'promote-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
|
|
@ -806,7 +915,9 @@ export class ActionFactoryService {
|
|||
title: 'unpromote',
|
||||
description: 'unpromote-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
|
|
@ -817,7 +928,19 @@ export class ActionFactoryService {
|
|||
title: 'edit',
|
||||
description: 'edit-person-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
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: [],
|
||||
}
|
||||
];
|
||||
|
|
@ -828,7 +951,9 @@ export class ActionFactoryService {
|
|||
title: 'view-series',
|
||||
description: 'view-series-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
|
|
@ -836,7 +961,9 @@ export class ActionFactoryService {
|
|||
title: 'download',
|
||||
description: 'download-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
|
|
@ -844,8 +971,10 @@ export class ActionFactoryService {
|
|||
title: 'clear',
|
||||
description: 'delete-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
class: 'danger',
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
|
|
@ -856,7 +985,9 @@ export class ActionFactoryService {
|
|||
title: 'mark-visible',
|
||||
description: 'mark-visible-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
|
|
@ -864,7 +995,9 @@ export class ActionFactoryService {
|
|||
title: 'mark-invisible',
|
||||
description: 'mark-invisible-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
|
|
@ -875,7 +1008,9 @@ export class ActionFactoryService {
|
|||
title: 'rename',
|
||||
description: 'rename-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
|
|
@ -883,7 +1018,9 @@ export class ActionFactoryService {
|
|||
title: 'delete',
|
||||
description: 'delete-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
|
|
@ -894,7 +1031,9 @@ export class ActionFactoryService {
|
|||
title: 'reorder',
|
||||
description: '',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
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.shouldRender = shouldRenderFunc;
|
||||
|
||||
if (action.children === null || action.children?.length === 0) return;
|
||||
|
||||
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) => {
|
||||
return { ...a };
|
||||
});
|
||||
actions.forEach((action) => this.applyCallback(action, callback));
|
||||
actions.forEach((action) => this.applyCallback(action, callback, shouldRenderFunc));
|
||||
return actions;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -473,8 +473,7 @@ export class ActionService {
|
|||
}
|
||||
|
||||
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-chapters', {count: volumes.length}))) return;
|
||||
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-multiple-volumes', {count: volumes.length}))) return;
|
||||
|
||||
this.volumeService.deleteMultipleVolumes(volumes.map(v => v.id)).subscribe((success) => {
|
||||
if (callback) {
|
||||
|
|
|
|||
|
|
@ -109,7 +109,11 @@ export enum EVENTS {
|
|||
/**
|
||||
* 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> {
|
||||
|
|
@ -336,6 +340,13 @@ export class MessageHubService {
|
|||
payload: resp.body
|
||||
});
|
||||
});
|
||||
|
||||
this.hubConnection.on(EVENTS.PersonMerged, resp => {
|
||||
this.messagesSource.next({
|
||||
event: EVENTS.PersonMerged,
|
||||
payload: resp.body
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
stopHubConnection() {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,12 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from "@angular/common/http";
|
||||
import {Injectable} from '@angular/core';
|
||||
import {HttpClient, HttpParams} from "@angular/common/http";
|
||||
import {environment} from "../../environments/environment";
|
||||
import {Person, PersonRole} from "../_models/metadata/person";
|
||||
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
|
||||
import {PaginatedResult} from "../_models/pagination";
|
||||
import {Series} from "../_models/series";
|
||||
import {map} from "rxjs/operators";
|
||||
import {UtilityService} from "../shared/_services/utility.service";
|
||||
import {BrowsePerson} from "../_models/person/browse-person";
|
||||
import {Chapter} from "../_models/chapter";
|
||||
import {StandaloneChapter} from "../_models/standalone-chapter";
|
||||
import {TextResonse} from "../_types/text-response";
|
||||
|
||||
|
|
@ -29,6 +27,10 @@ export class PersonService {
|
|||
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) {
|
||||
return this.httpClient.get<Array<PersonRole>>(this.baseUrl + `person/roles?personId=${personId}`);
|
||||
}
|
||||
|
|
@ -55,4 +57,15 @@ export class PersonService {
|
|||
downloadCover(personId: number) {
|
||||
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,20 +1,19 @@
|
|||
import { HttpClient } from '@angular/common/http';
|
||||
import {HttpClient, HttpParams} from '@angular/common/http';
|
||||
import {Inject, inject, Injectable} from '@angular/core';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { UserReadStatistics } from '../statistics/_models/user-read-statistics';
|
||||
import { PublicationStatusPipe } from '../_pipes/publication-status.pipe';
|
||||
import {asyncScheduler, finalize, map, tap} from 'rxjs';
|
||||
import { MangaFormatPipe } from '../_pipes/manga-format.pipe';
|
||||
import { FileExtensionBreakdown } from '../statistics/_models/file-breakdown';
|
||||
import { TopUserRead } from '../statistics/_models/top-reads';
|
||||
import { ReadHistoryEvent } from '../statistics/_models/read-history-event';
|
||||
import { ServerStatistics } from '../statistics/_models/server-statistics';
|
||||
import { StatCount } from '../statistics/_models/stat-count';
|
||||
import { PublicationStatus } from '../_models/metadata/publication-status';
|
||||
import { MangaFormat } from '../_models/manga-format';
|
||||
import { TextResonse } from '../_types/text-response';
|
||||
import {environment} from 'src/environments/environment';
|
||||
import {UserReadStatistics} from '../statistics/_models/user-read-statistics';
|
||||
import {PublicationStatusPipe} from '../_pipes/publication-status.pipe';
|
||||
import {asyncScheduler, map} from 'rxjs';
|
||||
import {MangaFormatPipe} from '../_pipes/manga-format.pipe';
|
||||
import {FileExtensionBreakdown} from '../statistics/_models/file-breakdown';
|
||||
import {TopUserRead} from '../statistics/_models/top-reads';
|
||||
import {ReadHistoryEvent} from '../statistics/_models/read-history-event';
|
||||
import {ServerStatistics} from '../statistics/_models/server-statistics';
|
||||
import {StatCount} from '../statistics/_models/stat-count';
|
||||
import {PublicationStatus} from '../_models/metadata/publication-status';
|
||||
import {MangaFormat} from '../_models/manga-format';
|
||||
import {TextResonse} from '../_types/text-response';
|
||||
import {TranslocoService} from "@jsverse/transloco";
|
||||
import {KavitaPlusMetadataBreakdown} from "../statistics/_models/kavitaplus-metadata-breakdown";
|
||||
import {throttleTime} from "rxjs/operators";
|
||||
import {DEBOUNCE_TIME} from "../shared/_services/download.service";
|
||||
import {download} from "../shared/_models/download";
|
||||
|
|
@ -44,11 +43,14 @@ export class StatisticsService {
|
|||
constructor(private httpClient: HttpClient, @Inject(SAVER) private save: Saver) { }
|
||||
|
||||
getUserStatistics(userId: number, libraryIds: Array<number> = []) {
|
||||
// TODO: Convert to httpParams object
|
||||
let url = 'stats/user/' + userId + '/read';
|
||||
if (libraryIds.length > 0) url += '?libraryIds=' + libraryIds.join(',');
|
||||
const url = `${this.baseUrl}stats/user/${userId}/read`;
|
||||
|
||||
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() {
|
||||
|
|
@ -59,7 +61,7 @@ export class StatisticsService {
|
|||
return this.httpClient.get<StatCount<number>[]>(this.baseUrl + 'stats/server/count/year').pipe(
|
||||
map(spreads => spreads.map(spread => {
|
||||
return {name: spread.value + '', value: spread.count};
|
||||
})));
|
||||
})));
|
||||
}
|
||||
|
||||
getTopYears() {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
<ng-container *transloco="let t; read: 'actionable'">
|
||||
<div class="modal-container">
|
||||
<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>
|
||||
</div>
|
||||
<div class="modal-body scrollable-modal">
|
||||
|
|
@ -12,8 +14,6 @@
|
|||
}
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
|
||||
|
||||
@for (action of currentItems; track action.title) {
|
||||
@if (willRenderAction(action)) {
|
||||
<button class="btn btn-outline-primary text-start d-flex justify-content-between align-items-center w-100"
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component, DestroyRef,
|
||||
Component,
|
||||
DestroyRef,
|
||||
EventEmitter,
|
||||
inject,
|
||||
Input,
|
||||
OnInit,
|
||||
Output
|
||||
} from '@angular/core';
|
||||
import {NgClass} from "@angular/common";
|
||||
import {translate, TranslocoDirective} from "@jsverse/transloco";
|
||||
import {Breakpoint, UtilityService} from "../../shared/_services/utility.service";
|
||||
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 {tap} from "rxjs";
|
||||
import {User} from "../../_models/user";
|
||||
|
|
@ -36,6 +36,7 @@ export class ActionableModalComponent implements OnInit {
|
|||
protected readonly destroyRef = inject(DestroyRef);
|
||||
protected readonly Breakpoint = Breakpoint;
|
||||
|
||||
@Input() entity: ActionableEntity = null;
|
||||
@Input() actions: ActionItem<any>[] = [];
|
||||
@Input() willRenderAction!: (action: ActionItem<any>) => boolean;
|
||||
@Input() shouldRenderSubMenu!: (action: ActionItem<any>, dynamicList: null | Array<any>) => boolean;
|
||||
|
|
|
|||
|
|
@ -1,51 +1,57 @@
|
|||
<ng-container *transloco="let t; read: 'actionable'">
|
||||
@if (actions.length > 0) {
|
||||
@if ((utilityService.activeBreakpoint$ | async)! <= Breakpoint.Tablet) {
|
||||
<button [disabled]="disabled" class="btn {{btnClass}} px-3" id="actions-{{labelBy}}"
|
||||
(click)="openMobileActionableMenu($event)">
|
||||
{{label}}
|
||||
<i class="fa {{iconClass}}" aria-hidden="true"></i>
|
||||
</button>
|
||||
} @else {
|
||||
<div ngbDropdown container="body" class="d-inline-block">
|
||||
<button [disabled]="disabled" class="btn {{btnClass}} px-3" id="actions-{{labelBy}}" ngbDropdownToggle
|
||||
(click)="preventEvent($event)">
|
||||
<ng-container *transloco="let t; read: 'actionable'">
|
||||
@if (actions.length > 0) {
|
||||
@if ((utilityService.activeBreakpoint$ | async)! <= Breakpoint.Tablet) {
|
||||
<button [disabled]="disabled" class="btn {{btnClass}} px-3" id="actions-{{labelBy}}"
|
||||
(click)="openMobileActionableMenu($event)">
|
||||
{{label}}
|
||||
<i class="fa {{iconClass}}" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div ngbDropdownMenu attr.aria-labelledby="actions-{{labelBy}}">
|
||||
<ng-container *ngTemplateOutlet="submenu; context: { list: actions }"></ng-container>
|
||||
} @else {
|
||||
<div ngbDropdown container="body" class="d-inline-block">
|
||||
<button [disabled]="disabled" class="btn {{btnClass}} px-3" id="actions-{{labelBy}}" ngbDropdownToggle
|
||||
(click)="preventEvent($event)">
|
||||
{{label}}
|
||||
<i class="fa {{iconClass}}" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div ngbDropdownMenu attr.aria-labelledby="actions-{{labelBy}}">
|
||||
<ng-container *ngTemplateOutlet="submenu; context: { list: actions }"></ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ng-template #submenu let-list="list">
|
||||
@for(action of list; track action.title) {
|
||||
<!-- Non Submenu items -->
|
||||
@if (action.children === undefined || action?.children?.length === 0 || action.dynamicList !== undefined) {
|
||||
@if (action.dynamicList !== undefined && (action.dynamicList | async | dynamicList); as dList) {
|
||||
@for(dynamicItem of dList; track dynamicItem.title) {
|
||||
<button ngbDropdownItem (click)="performDynamicClick($event, action, dynamicItem)">{{dynamicItem.title}}</button>
|
||||
}
|
||||
} @else if (willRenderAction(action)) {
|
||||
<button ngbDropdownItem (click)="performAction($event, action)" (mouseover)="closeAllSubmenus()">{{t(action.title)}}</button>
|
||||
}
|
||||
} @else {
|
||||
@if (shouldRenderSubMenu(action, action.children?.[0].dynamicList | async)) {
|
||||
<!-- Submenu items -->
|
||||
<div ngbDropdown #subMenuHover="ngbDropdown" placement="right left"
|
||||
(click)="preventEvent($event); openSubmenu(action.title, subMenuHover)"
|
||||
(mouseover)="preventEvent($event); openSubmenu(action.title, subMenuHover)"
|
||||
(mouseleave)="preventEvent($event)">
|
||||
@if (willRenderAction(action)) {
|
||||
<button id="actions-{{action.title}}" class="submenu-toggle" ngbDropdownToggle>{{t(action.title)}} <i class="fa-solid fa-angle-right submenu-icon"></i></button>
|
||||
<ng-template #submenu let-list="list">
|
||||
@for(action of list; track action.title) {
|
||||
<!-- Non Submenu items -->
|
||||
@if (action.children === undefined || action?.children?.length === 0 || action.dynamicList !== undefined) {
|
||||
@if (action.dynamicList !== undefined && (action.dynamicList | async | dynamicList); as dList) {
|
||||
@for(dynamicItem of dList; track dynamicItem.title) {
|
||||
<button ngbDropdownItem (click)="performDynamicClick($event, action, dynamicItem)">{{dynamicItem.title}}</button>
|
||||
}
|
||||
<div ngbDropdownMenu attr.aria-labelledby="actions-{{action.title}}">
|
||||
<ng-container *ngTemplateOutlet="submenu; context: { list: action.children }"></ng-container>
|
||||
} @else if (willRenderAction(action, this.currentUser!)) {
|
||||
<button ngbDropdownItem (click)="performAction($event, action)">{{t(action.title)}}</button>
|
||||
}
|
||||
} @else {
|
||||
@if (shouldRenderSubMenu(action, action.children?.[0].dynamicList | async) && hasRenderableChildren(action, this.currentUser!)) {
|
||||
<!-- Submenu items -->
|
||||
<div ngbDropdown #subMenuHover="ngbDropdown" placement="right left"
|
||||
(click)="openSubmenu(action.title, subMenuHover)"
|
||||
(mouseenter)="openSubmenu(action.title, subMenuHover)"
|
||||
(mouseover)="preventEvent($event)"
|
||||
class="submenu-wrapper">
|
||||
|
||||
<!-- Check to ensure the submenu has items -->
|
||||
@if (willRenderAction(action, this.currentUser!)) {
|
||||
<button id="actions-{{action.title}}" class="submenu-toggle" ngbDropdownToggle>
|
||||
{{t(action.title)}} <i class="fa-solid fa-angle-right submenu-icon"></i>
|
||||
</button>
|
||||
}
|
||||
|
||||
<div ngbDropdownMenu attr.aria-labelledby="actions-{{action.title}}">
|
||||
<ng-container *ngTemplateOutlet="submenu; context: { list: action.children }"></ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</ng-template>
|
||||
</ng-template>
|
||||
}
|
||||
}
|
||||
}
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,22 @@
|
|||
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 {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
|
@ -30,9 +46,3 @@
|
|||
.btn {
|
||||
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 {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component, DestroyRef,
|
||||
Component,
|
||||
DestroyRef,
|
||||
EventEmitter,
|
||||
inject,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
Output
|
||||
} from '@angular/core';
|
||||
import {NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle, NgbModal} from '@ng-bootstrap/ng-bootstrap';
|
||||
import { AccountService } from 'src/app/_services/account.service';
|
||||
import { Action, ActionItem } from 'src/app/_services/action-factory.service';
|
||||
import {AccountService} from 'src/app/_services/account.service';
|
||||
import {ActionableEntity, ActionItem} from 'src/app/_services/action-factory.service';
|
||||
import {AsyncPipe, NgTemplateOutlet} from "@angular/common";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {DynamicListPipe} from "./_pipes/dynamic-list.pipe";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {Breakpoint, UtilityService} from "../../shared/_services/utility.service";
|
||||
import {ActionableModalComponent} from "../actionable-modal/actionable-modal.component";
|
||||
import {User} from "../../_models/user";
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'app-card-actionables',
|
||||
imports: [NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, DynamicListPipe, TranslocoDirective, AsyncPipe, NgTemplateOutlet],
|
||||
templateUrl: './card-actionables.component.html',
|
||||
styleUrls: ['./card-actionables.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
selector: 'app-card-actionables',
|
||||
imports: [
|
||||
NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem,
|
||||
DynamicListPipe, TranslocoDirective, AsyncPipe, NgTemplateOutlet
|
||||
],
|
||||
templateUrl: './card-actionables.component.html',
|
||||
styleUrls: ['./card-actionables.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class CardActionablesComponent implements OnInit {
|
||||
export class CardActionablesComponent implements OnInit, OnChanges, OnDestroy {
|
||||
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly accountService = inject(AccountService);
|
||||
|
|
@ -37,58 +45,69 @@ export class CardActionablesComponent implements OnInit {
|
|||
|
||||
@Input() iconClass = 'fa-ellipsis-v';
|
||||
@Input() btnClass = '';
|
||||
@Input() actions: ActionItem<any>[] = [];
|
||||
@Input() inputActions: ActionItem<any>[] = [];
|
||||
@Input() labelBy = 'card';
|
||||
/**
|
||||
* Text to display as if actionable was a button
|
||||
*/
|
||||
@Input() label = '';
|
||||
@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>>();
|
||||
|
||||
|
||||
isAdmin: boolean = false;
|
||||
canDownload: boolean = false;
|
||||
canPromote: boolean = false;
|
||||
actions: ActionItem<ActionableEntity>[] = [];
|
||||
currentUser: User | undefined = undefined;
|
||||
submenu: {[key: string]: NgbDropdown} = {};
|
||||
private closeTimeout: any = null;
|
||||
|
||||
|
||||
ngOnInit(): void {
|
||||
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((user) => {
|
||||
if (!user) return;
|
||||
this.isAdmin = this.accountService.hasAdminRole(user);
|
||||
this.canDownload = this.accountService.hasDownloadRole(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.currentUser = user;
|
||||
this.actions = this.inputActions.filter(a => this.willRenderAction(a, user!));
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
ngOnChanges() {
|
||||
this.actions = this.inputActions.filter(a => this.willRenderAction(a, this.currentUser!));
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.cancelCloseSubmenus();
|
||||
}
|
||||
|
||||
preventEvent(event: any) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
performAction(event: any, action: ActionItem<any>) {
|
||||
performAction(event: any, action: ActionItem<ActionableEntity>) {
|
||||
this.preventEvent(event);
|
||||
|
||||
if (typeof action.callback === 'function') {
|
||||
this.actionHandler.emit(action);
|
||||
if (this.entity === null) {
|
||||
this.actionHandler.emit(action);
|
||||
} else {
|
||||
action.callback(action, this.entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
willRenderAction(action: ActionItem<any>) {
|
||||
return (action.requiresAdmin && this.isAdmin)
|
||||
|| (action.action === Action.Download && (this.canDownload || this.isAdmin))
|
||||
|| (!action.requiresAdmin && action.action !== Action.Download)
|
||||
|| (action.action === Action.Promote && (this.canPromote || this.isAdmin))
|
||||
|| (action.action === Action.UnPromote && (this.canPromote || this.isAdmin))
|
||||
;
|
||||
/**
|
||||
* The user has required roles (or no roles defined) and action shouldRender returns true
|
||||
* @param action
|
||||
* @param user
|
||||
*/
|
||||
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>) {
|
||||
|
|
@ -109,13 +128,41 @@ export class CardActionablesComponent implements OnInit {
|
|||
}
|
||||
|
||||
closeAllSubmenus() {
|
||||
Object.keys(this.submenu).forEach(key => {
|
||||
this.submenu[key].close();
|
||||
// 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 => {
|
||||
this.submenu[key].close();
|
||||
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;
|
||||
this.performAction(event, action);
|
||||
}
|
||||
|
|
@ -124,6 +171,7 @@ export class CardActionablesComponent implements OnInit {
|
|||
this.preventEvent(event);
|
||||
|
||||
const ref = this.modalService.open(ActionableModalComponent, {fullscreen: true, centered: true});
|
||||
ref.componentInstance.entity = this.entity;
|
||||
ref.componentInstance.actions = this.actions;
|
||||
ref.componentInstance.willRenderAction = this.willRenderAction.bind(this);
|
||||
ref.componentInstance.shouldRenderSubMenu = this.shouldRenderSubMenu.bind(this);
|
||||
|
|
|
|||
|
|
@ -483,7 +483,7 @@ export class EditChapterModalComponent implements OnInit {
|
|||
};
|
||||
|
||||
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 + '');
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
<ng-container *transloco="let t; read: 'manage-library'">
|
||||
<div class="position-relative">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
|
@ -72,11 +73,22 @@
|
|||
<td>
|
||||
<!-- On Mobile we want to use ... for each row -->
|
||||
@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 {
|
||||
<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-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>
|
||||
<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-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>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -83,12 +83,12 @@ export class ManageLibraryComponent implements OnInit {
|
|||
lastSelectedIndex: number | null = null;
|
||||
|
||||
@HostListener('document:keydown.shift', ['$event'])
|
||||
handleKeypress(event: KeyboardEvent) {
|
||||
handleKeypress(_: KeyboardEvent) {
|
||||
this.isShiftDown = true;
|
||||
}
|
||||
|
||||
@HostListener('document:keyup.shift', ['$event'])
|
||||
handleKeyUp(event: KeyboardEvent) {
|
||||
handleKeyUp(_: KeyboardEvent) {
|
||||
this.isShiftDown = false;
|
||||
}
|
||||
|
||||
|
|
@ -106,7 +106,7 @@ export class ManageLibraryComponent implements OnInit {
|
|||
ngOnInit(): void {
|
||||
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),
|
||||
filter(event => event.event === EVENTS.ScanSeries || event.event === EVENTS.NotificationProgress),
|
||||
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.cdRef.markForCheck();
|
||||
|
||||
|
|
@ -284,7 +285,7 @@ export class ManageLibraryComponent implements OnInit {
|
|||
break;
|
||||
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'});
|
||||
ref.componentInstance.libraries = this.libraries;
|
||||
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) {
|
||||
switch (action.action) {
|
||||
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() {
|
||||
this.selections = new SelectionModel<Library>(false, this.libraries);
|
||||
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 {
|
||||
@Input({required: true}) items: Array<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) => {
|
||||
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 + '');
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@
|
|||
<span class="visually-hidden">{{t('mark-as-read')}}</span>
|
||||
</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 id="bulk-actions-header" class="visually-hidden">Bulk Actions</span>
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@ import {
|
|||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
DestroyRef, HostListener,
|
||||
DestroyRef,
|
||||
HostListener,
|
||||
inject,
|
||||
Input,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/action-factory.service';
|
||||
import { BulkSelectionService } from '../bulk-selection.service';
|
||||
import {Action, ActionFactoryService, ActionItem} from 'src/app/_services/action-factory.service';
|
||||
import {BulkSelectionService} from '../bulk-selection.service';
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {AsyncPipe, DecimalPipe, NgStyle} from "@angular/common";
|
||||
import {TranslocoModule} from "@jsverse/transloco";
|
||||
|
|
@ -17,18 +18,18 @@ import {CardActionablesComponent} from "../../_single-module/card-actionables/ca
|
|||
import {KEY_CODES} from "../../shared/_services/utility.service";
|
||||
|
||||
@Component({
|
||||
selector: 'app-bulk-operations',
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
CardActionablesComponent,
|
||||
TranslocoModule,
|
||||
NgbTooltip,
|
||||
NgStyle,
|
||||
DecimalPipe
|
||||
],
|
||||
templateUrl: './bulk-operations.component.html',
|
||||
styleUrls: ['./bulk-operations.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
selector: 'app-bulk-operations',
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
CardActionablesComponent,
|
||||
TranslocoModule,
|
||||
NgbTooltip,
|
||||
NgStyle,
|
||||
DecimalPipe
|
||||
],
|
||||
templateUrl: './bulk-operations.component.html',
|
||||
styleUrls: ['./bulk-operations.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class BulkOperationsComponent implements OnInit {
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
<h4>
|
||||
@if (actions.length > 0) {
|
||||
<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>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@
|
|||
|
||||
<span class="card-actions">
|
||||
@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>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -344,10 +344,6 @@ export class CardItemComponent implements OnInit {
|
|||
this.clicked.emit(this.title);
|
||||
}
|
||||
|
||||
preventClick(event: any) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
performAction(action: ActionItem<any>) {
|
||||
if (action.action == Action.Download) {
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@
|
|||
</span>
|
||||
<span class="card-actions">
|
||||
@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>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@ import {
|
|||
ChangeDetectorRef,
|
||||
Component,
|
||||
DestroyRef,
|
||||
EventEmitter, HostListener,
|
||||
EventEmitter,
|
||||
HostListener,
|
||||
inject,
|
||||
Input, OnInit,
|
||||
Input,
|
||||
OnInit,
|
||||
Output
|
||||
} from '@angular/core';
|
||||
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 {AccountService} from "../../_services/account.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 {Observable} from "rxjs";
|
||||
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 {Router, RouterLink} from "@angular/router";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
|
||||
import {filter, map} from "rxjs/operators";
|
||||
import {UserProgressUpdateEvent} from "../../_models/events/user-progress-update-event";
|
||||
import {ReaderService} from "../../_services/reader.service";
|
||||
import {LibraryType} from "../../_models/library/library";
|
||||
import {Device} from "../../_models/device/device";
|
||||
import {ActionService} from "../../_services/action.service";
|
||||
import {MangaFormat} from "../../_models/manga-format";
|
||||
|
||||
@Component({
|
||||
|
|
@ -60,15 +59,16 @@ export class ChapterCardComponent implements OnInit {
|
|||
public readonly imageService = inject(ImageService);
|
||||
public readonly bulkSelectionService = inject(BulkSelectionService);
|
||||
private readonly downloadService = inject(DownloadService);
|
||||
private readonly actionService = inject(ActionService);
|
||||
private readonly messageHub = inject(MessageHubService);
|
||||
private readonly accountService = inject(AccountService);
|
||||
private readonly scrollService = inject(ScrollService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly actionFactoryService = inject(ActionFactoryService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly readerService = inject(ReaderService);
|
||||
|
||||
protected readonly LibraryType = LibraryType;
|
||||
protected readonly MangaFormat = MangaFormat;
|
||||
|
||||
@Input({required: true}) libraryId: number = 0;
|
||||
@Input({required: true}) seriesId: number = 0;
|
||||
@Input({required: true}) chapter!: Chapter;
|
||||
|
|
@ -143,8 +143,6 @@ export class ChapterCardComponent implements OnInit {
|
|||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.filterSendTo();
|
||||
|
||||
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
|
||||
this.user = user;
|
||||
});
|
||||
|
|
@ -172,30 +170,6 @@ export class ChapterCardComponent implements OnInit {
|
|||
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) {
|
||||
if (this.bulkSelectionService.hasSelections()) {
|
||||
this.handleSelection(event);
|
||||
|
|
@ -209,8 +183,4 @@ export class ChapterCardComponent implements OnInit {
|
|||
event.stopPropagation();
|
||||
this.readerService.readChapter(this.libraryId, this.seriesId, this.chapter, false);
|
||||
}
|
||||
|
||||
|
||||
protected readonly LibraryType = LibraryType;
|
||||
protected readonly MangaFormat = MangaFormat;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
</span>
|
||||
@if (actions && actions.length > 0) {
|
||||
<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>
|
||||
}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component, ContentChild,
|
||||
DestroyRef, EventEmitter,
|
||||
Component,
|
||||
ContentChild,
|
||||
DestroyRef,
|
||||
EventEmitter,
|
||||
HostListener,
|
||||
inject,
|
||||
Input, Output, TemplateRef
|
||||
Input,
|
||||
Output,
|
||||
TemplateRef
|
||||
} 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 {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 {AccountService} from "../../_services/account.service";
|
||||
import {ScrollService} from "../../_services/scroll.service";
|
||||
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
|
||||
|
|
@ -139,11 +139,6 @@ export class PersonCardComponent {
|
|||
this.clicked.emit(this.title);
|
||||
}
|
||||
|
||||
performAction(action: ActionItem<any>) {
|
||||
if (typeof action.callback === 'function') {
|
||||
action.callback(action, this.entity);
|
||||
}
|
||||
}
|
||||
|
||||
handleSelection(event?: any) {
|
||||
if (event) {
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@
|
|||
|
||||
@if (actions && actions.length > 0) {
|
||||
<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>
|
||||
}
|
||||
</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 {RelationKind} from 'src/app/_models/series-detail/relation-kind';
|
||||
import {DecimalPipe} from "@angular/common";
|
||||
import {CardItemComponent} from "../card-item/card-item.component";
|
||||
import {RelationshipPipe} from "../../_pipes/relationship.pipe";
|
||||
import {Device} from "../../_models/device/device";
|
||||
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 {DefaultValuePipe} from "../../_pipes/default-value.pipe";
|
||||
import {DownloadIndicatorComponent} from "../download-indicator/download-indicator.component";
|
||||
import {EntityTitleComponent} from "../entity-title/entity-title.component";
|
||||
import {FormsModule} from "@angular/forms";
|
||||
import {ImageComponent} from "../../shared/image/image.component";
|
||||
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 {AccountService} from "../../_services/account.service";
|
||||
import {BulkSelectionService} from "../bulk-selection.service";
|
||||
import {User} from "../../_models/user";
|
||||
import {ScrollService} from "../../_services/scroll.service";
|
||||
import {ReaderService} from "../../_services/reader.service";
|
||||
import {SeriesFormatComponent} from "../../shared/series-format/series-format.component";
|
||||
|
|
@ -147,8 +144,6 @@ export class SeriesCardComponent implements OnInit, OnChanges {
|
|||
*/
|
||||
prevOffset: number = 0;
|
||||
selectionInProgress: boolean = false;
|
||||
private user: User | undefined;
|
||||
|
||||
|
||||
@HostListener('touchmove', ['$event'])
|
||||
onTouchMove(event: TouchEvent) {
|
||||
|
|
@ -192,15 +187,15 @@ export class SeriesCardComponent implements OnInit, OnChanges {
|
|||
|
||||
ngOnChanges(changes: any) {
|
||||
if (this.series) {
|
||||
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
|
||||
this.user = user;
|
||||
});
|
||||
// this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
|
||||
// this.user = user;
|
||||
// });
|
||||
|
||||
this.download$ = this.downloadService.activeDownloads$.pipe(takeUntilDestroyed(this.destroyRef), map((events) => {
|
||||
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) {
|
||||
const othersIndex = this.actions.findIndex(obj => obj.title === 'others');
|
||||
const othersAction = deepClone(this.actions[othersIndex]) as ActionItem<Series>;
|
||||
|
|
@ -209,9 +204,11 @@ export class SeriesCardComponent implements OnInit, OnChanges {
|
|||
action: Action.RemoveFromOnDeck,
|
||||
title: 'remove-from-on-deck',
|
||||
description: '',
|
||||
callback: (action: ActionItem<Series>, series: Series) => this.handleSeriesActionCallback(action, series),
|
||||
callback: this.handleSeriesActionCallback.bind(this),
|
||||
class: 'danger',
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
shouldRender: (_, _2, _3) => true,
|
||||
children: [],
|
||||
});
|
||||
this.actions[othersIndex] = othersAction;
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@
|
|||
|
||||
@if (actions && actions.length > 0) {
|
||||
<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>
|
||||
}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import {DownloadEvent, DownloadService} from "../../shared/_services/download.se
|
|||
import {EVENTS, MessageHubService} from "../../_services/message-hub.service";
|
||||
import {AccountService} from "../../_services/account.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 {Observable} from "rxjs";
|
||||
import {User} from "../../_models/user";
|
||||
|
|
@ -33,7 +33,6 @@ import {UserProgressUpdateEvent} from "../../_models/events/user-progress-update
|
|||
import {Volume} from "../../_models/volume";
|
||||
import {UtilityService} from "../../shared/_services/utility.service";
|
||||
import {LibraryType} from "../../_models/library/library";
|
||||
import {Device} from "../../_models/device/device";
|
||||
import {ActionService} from "../../_services/action.service";
|
||||
import {FormsModule} from "@angular/forms";
|
||||
|
||||
|
|
@ -143,8 +142,6 @@ export class VolumeCardComponent implements OnInit {
|
|||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.filterSendTo();
|
||||
|
||||
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
|
||||
this.user = user;
|
||||
});
|
||||
|
|
@ -180,30 +177,6 @@ export class VolumeCardComponent implements OnInit {
|
|||
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) {
|
||||
if (this.bulkSelectionService.hasSelections()) {
|
||||
this.handleSelection(event);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<div class="carousel-container mb-3">
|
||||
<div>
|
||||
@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}">
|
||||
@if (titleLink !== '') {
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@
|
|||
|
||||
<div class="col-auto ms-2k">
|
||||
<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>
|
||||
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ enum TabID {
|
|||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-chapter-detail',
|
||||
selector: 'app-chapter-detail',
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
CardActionablesComponent,
|
||||
|
|
@ -116,9 +116,9 @@ enum TabID {
|
|||
ReviewsComponent,
|
||||
ExternalRatingComponent
|
||||
],
|
||||
templateUrl: './chapter-detail.component.html',
|
||||
styleUrl: './chapter-detail.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
templateUrl: './chapter-detail.component.html',
|
||||
styleUrl: './chapter-detail.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ChapterDetailComponent implements OnInit {
|
||||
|
||||
|
|
@ -339,10 +339,6 @@ export class ChapterDetailComponent implements OnInit {
|
|||
this.location.replaceState(newUrl)
|
||||
}
|
||||
|
||||
openPerson(field: FilterField, value: number) {
|
||||
this.filterUtilityService.applyFilter(['all-series'], field, FilterComparison.Equal, `${value}`).subscribe();
|
||||
}
|
||||
|
||||
downloadChapter() {
|
||||
if (this.downloadInProgress) return;
|
||||
this.downloadService.download('chapter', this.chapter!, (d) => {
|
||||
|
|
@ -360,11 +356,6 @@ export class ChapterDetailComponent implements OnInit {
|
|||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
performAction(action: ActionItem<Chapter>) {
|
||||
if (typeof action.callback === 'function') {
|
||||
action.callback(action, this.chapter!);
|
||||
}
|
||||
}
|
||||
|
||||
handleChapterActionCallback(action: ActionItem<Chapter>, chapter: Chapter) {
|
||||
switch (action.action) {
|
||||
|
|
|
|||
|
|
@ -6,11 +6,9 @@
|
|||
<ng-container title>
|
||||
@if (collectionTag) {
|
||||
<h4>
|
||||
{{collectionTag.title}}
|
||||
@if(collectionTag.promoted) {
|
||||
<span class="ms-1">(<i aria-hidden="true" class="fa fa-angle-double-up"></i>)</span>
|
||||
}
|
||||
<app-card-actionables [disabled]="actionInProgress" (actionHandler)="performAction($event)" [actions]="collectionTagActions" [labelBy]="collectionTag.title" iconClass="fa-ellipsis-v"></app-card-actionables>
|
||||
<app-promoted-icon [promoted]="collectionTag.promoted"></app-promoted-icon>
|
||||
<span class="ms-2">{{collectionTag.title}}</span>
|
||||
<app-card-actionables [entity]="collectionTag" [disabled]="actionInProgress" [inputActions]="collectionTagActions" [labelBy]="collectionTag.title" iconClass="fa-ellipsis-v"></app-card-actionables>
|
||||
</h4>
|
||||
}
|
||||
<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";
|
||||
import {DefaultModalOptions} from "../../../_models/default-modal-options";
|
||||
import {ScrobbleProviderNamePipe} from "../../../_pipes/scrobble-provider-name.pipe";
|
||||
import {PromotedIconComponent} from "../../../shared/_components/promoted-icon/promoted-icon.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-collection-detail',
|
||||
|
|
@ -69,7 +70,7 @@ import {ScrobbleProviderNamePipe} from "../../../_pipes/scrobble-provider-name.p
|
|||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [SideNavCompanionBarComponent, CardActionablesComponent, ImageComponent, ReadMoreComponent,
|
||||
BulkOperationsComponent, CardDetailLayoutComponent, SeriesCardComponent, TranslocoDirective, NgbTooltip,
|
||||
DatePipe, DefaultDatePipe, ProviderImagePipe, AsyncPipe, ScrobbleProviderNamePipe]
|
||||
DatePipe, DefaultDatePipe, ProviderImagePipe, AsyncPipe, ScrobbleProviderNamePipe, PromotedIconComponent]
|
||||
})
|
||||
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) {
|
||||
const modalRef = this.modalService.open(EditCollectionTagsComponent, DefaultModalOptions);
|
||||
modalRef.componentInstance.tag = this.collectionTag;
|
||||
|
|
@ -320,7 +315,6 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
|
|||
}
|
||||
|
||||
openSyncDetailDrawer() {
|
||||
|
||||
const ref = this.offcanvasService.open(SmartCollectionDrawerComponent, {position: 'end', panelClass: ''});
|
||||
ref.componentInstance.collection = this.collectionTag;
|
||||
ref.componentInstance.series = this.series;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<app-side-nav-companion-bar [hasFilter]="true" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive">
|
||||
<h4 title>
|
||||
<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>
|
||||
@if (active.fragment === '') {
|
||||
<h5 subtitle class="subtitle-with-actionables">{{t('common.series-count', {num: pagination.totalItems | number})}} </h5>
|
||||
|
|
@ -31,7 +31,6 @@
|
|||
</ng-template>
|
||||
|
||||
<ng-template #noData>
|
||||
<!-- TODO: Come back and figure this out -->
|
||||
{{t('common.no-data')}}
|
||||
</ng-template>
|
||||
</app-card-detail-layout>
|
||||
|
|
|
|||
|
|
@ -297,8 +297,6 @@ export class LibraryDetailComponent implements OnInit {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
performAction(action: ActionItem<any>) {
|
||||
if (typeof action.callback === 'function') {
|
||||
action.callback(action, undefined);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@
|
|||
} @else {
|
||||
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="resetField()"></button>
|
||||
}
|
||||
} @else {
|
||||
<div class="input-hint">
|
||||
Ctrl+K
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,17 @@
|
|||
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 {
|
||||
border: 1px solid transparent;
|
||||
|
|
|
|||
|
|
@ -100,7 +100,9 @@ export class GroupedTypeaheadComponent implements OnInit {
|
|||
|
||||
|
||||
hasFocus: boolean = false;
|
||||
typeaheadForm: FormGroup = new FormGroup({});
|
||||
typeaheadForm: FormGroup = new FormGroup({
|
||||
typeahead: new FormControl('', []),
|
||||
});
|
||||
includeChapterAndFiles: boolean = false;
|
||||
prevSearchTerm: string = '';
|
||||
searchSettingsForm = new FormGroup(({'includeExtras': new FormControl(false)}));
|
||||
|
|
@ -121,22 +123,37 @@ export class GroupedTypeaheadComponent implements OnInit {
|
|||
this.close();
|
||||
}
|
||||
|
||||
@HostListener('window:keydown', ['$event'])
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
handleKeyPress(event: KeyboardEvent) {
|
||||
if (!this.hasFocus) { return; }
|
||||
|
||||
const isCtrlOrMeta = event.ctrlKey || event.metaKey;
|
||||
|
||||
|
||||
switch(event.key) {
|
||||
case KEY_CODES.ESC_KEY:
|
||||
if (!this.hasFocus) { return; }
|
||||
this.close();
|
||||
event.stopPropagation();
|
||||
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:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.typeaheadForm.addControl('typeahead', new FormControl(this.initialValue, []));
|
||||
this.typeaheadForm.get('typeahead')?.setValue(this.initialValue);
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
this.searchSettingsForm.get('includeExtras')!.valueChanges.pipe(
|
||||
|
|
|
|||
|
|
@ -118,7 +118,14 @@
|
|||
width="24px" [imageUrl]="imageService.getPersonImage(item.id)" [errorImage]="imageService.noPersonImage"></app-image>
|
||||
</div>
|
||||
<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>
|
||||
</ng-template>
|
||||
|
|
@ -206,7 +213,7 @@
|
|||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</nav>
|
||||
|
|
|
|||
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