diff --git a/API.Tests/Services/PersonServiceTests.cs b/API.Tests/Services/PersonServiceTests.cs
index f640b8b4c..17df87179 100644
--- a/API.Tests/Services/PersonServiceTests.cs
+++ b/API.Tests/Services/PersonServiceTests.cs
@@ -32,6 +32,11 @@ public class PersonServiceTests: AbstractDbTest
Name= "Delores Casey",
NormalizedName = "Delores Casey".ToNormalized(),
Description = "Hi, I'm Delores Casey!",
+ Aliases = [new PersonAlias
+ {
+ Alias = "Casey, Delores",
+ NormalizedAlias = "Casey, Delores".ToNormalized(),
+ }],
AniListId = 27,
};
@@ -51,6 +56,7 @@ public class PersonServiceTests: AbstractDbTest
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]
@@ -178,7 +184,7 @@ public class PersonServiceTests: AbstractDbTest
// Some overlap
success = await ps.UpdatePersonAliasesAsync(person1, ["Jolly Cowan", "Jilly Joy Cowan"]);
- Assert.True(success);
+ Assert.False(success);
Assert.Single(person2.Aliases);
}
diff --git a/API/Controllers/PersonController.cs b/API/Controllers/PersonController.cs
index 670570dc4..e99b81118 100644
--- a/API/Controllers/PersonController.cs
+++ b/API/Controllers/PersonController.cs
@@ -2,6 +2,7 @@
using System.Linq;
using System.Threading.Tasks;
using API.Data;
+using API.Data.Repositories;
using API.DTOs;
using API.Entities.Enums;
using API.Extensions;
@@ -186,16 +187,16 @@ public class PersonController : BaseApiController
///
///
[HttpPost("merge")]
- public async Task MergePersons(PersonMergeDto dto)
+ public async Task> MergePersons(PersonMergeDto dto)
{
- var dst = await _unitOfWork.PersonRepository.GetPersonById(dto.DestId);
+ var dst = await _unitOfWork.PersonRepository.GetPersonById(dto.DestId, PersonIncludes.All);
if (dst == null) return BadRequest();
- var src = await _unitOfWork.PersonRepository.GetPersonById(dto.SrcId);
+ var src = await _unitOfWork.PersonRepository.GetPersonById(dto.SrcId, PersonIncludes.All);
if (src == null) return BadRequest();
await _personService.MergePeopleAsync(dst, src);
- return Ok();
+ return Ok(_mapper.Map(dst));
}
[HttpGet("alias/{personId}/{alias}")]
diff --git a/API/Data/Repositories/PersonRepository.cs b/API/Data/Repositories/PersonRepository.cs
index 24f0b23f5..eced401e6 100644
--- a/API/Data/Repositories/PersonRepository.cs
+++ b/API/Data/Repositories/PersonRepository.cs
@@ -20,6 +20,10 @@ public enum PersonIncludes
{
None = 1 << 0,
Aliases = 1 << 1,
+ ChapterPeople = 1 << 2,
+ SeriesPeople = 1 << 3,
+
+ All = Aliases | ChapterPeople | SeriesPeople,
}
public interface IPersonRepository
diff --git a/API/Extensions/QueryExtensions/IncludesExtensions.cs b/API/Extensions/QueryExtensions/IncludesExtensions.cs
index fe19abc0a..16d1f50e6 100644
--- a/API/Extensions/QueryExtensions/IncludesExtensions.cs
+++ b/API/Extensions/QueryExtensions/IncludesExtensions.cs
@@ -331,6 +331,16 @@ public static class IncludesExtensions
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;
}
}
diff --git a/API/Services/PersonService.cs b/API/Services/PersonService.cs
index b04fbd26d..fc8f82bec 100644
--- a/API/Services/PersonService.cs
+++ b/API/Services/PersonService.cs
@@ -14,11 +14,12 @@ public interface IPersonService
///
/// Remaining person
/// Merged person
+ /// The entities passed as arguments **must** include all relations
///
Task MergePeopleAsync(Person dst, Person src);
///
- /// Adds the alias to the person, requires that the alias is not shared with anyone else
+ /// Adds the alias to the person, requires that the aliases are not shared with anyone else
///
/// This method does NOT commit changes
///
@@ -32,6 +33,7 @@ public class PersonService(IUnitOfWork unitOfWork): IPersonService
public async Task MergePeopleAsync(Person dst, Person src)
{
+ if (dst.Id == src.Id) return;
if (string.IsNullOrWhiteSpace(dst.Description) && !string.IsNullOrWhiteSpace(src.Description))
{
@@ -68,7 +70,7 @@ public class PersonService(IUnitOfWork unitOfWork): IPersonService
dst.ChapterPeople.Add(new ChapterPeople
{
Role = chapter.Role,
- Chapter = chapter.Chapter,
+ ChapterId = chapter.ChapterId,
Person = dst,
KavitaPlusConnection = chapter.KavitaPlusConnection,
OrderWeight = chapter.OrderWeight,
@@ -79,6 +81,7 @@ public class PersonService(IUnitOfWork unitOfWork): IPersonService
{
dst.SeriesMetadataPeople.Add(new SeriesMetadataPeople
{
+ SeriesMetadataId = series.SeriesMetadataId,
Role = series.Role,
Person = dst,
KavitaPlusConnection = series.KavitaPlusConnection,
@@ -92,6 +95,11 @@ public class PersonService(IUnitOfWork unitOfWork): IPersonService
NormalizedAlias = src.NormalizedName,
});
+ foreach (var alias in src.Aliases)
+ {
+ dst.Aliases.Add(alias);
+ }
+
unitOfWork.PersonRepository.Remove(src);
unitOfWork.PersonRepository.Update(dst);
await unitOfWork.CommitAsync();
diff --git a/UI/Web/src/app/_services/action-factory.service.ts b/UI/Web/src/app/_services/action-factory.service.ts
index 6d2f7053e..0fef35b0e 100644
--- a/UI/Web/src/app/_services/action-factory.service.ts
+++ b/UI/Web/src/app/_services/action-factory.service.ts
@@ -116,7 +116,11 @@ export enum Action {
/**
* Match an entity with an upstream system
*/
- Match = 28
+ Match = 28,
+ /**
+ * Merge two (or more?) entities
+ */
+ Merge = 29,
}
/**
@@ -819,6 +823,14 @@ export class ActionFactoryService {
callback: this.dummyCallback,
requiresAdmin: true,
children: [],
+ },
+ {
+ action: Action.Merge,
+ title: 'merge',
+ description: 'merge-person-tooltip',
+ callback: this.dummyCallback,
+ requiresAdmin: true,
+ children: [],
}
];
diff --git a/UI/Web/src/app/_services/person.service.ts b/UI/Web/src/app/_services/person.service.ts
index 72105b680..85bf216e6 100644
--- a/UI/Web/src/app/_services/person.service.ts
+++ b/UI/Web/src/app/_services/person.service.ts
@@ -61,4 +61,8 @@ export class PersonService {
return this.httpClient.get(this.baseUrl + `person/alias/${personId}/${alias}`);
}
+ mergePerson(destId: number, srcId: number) {
+ return this.httpClient.post(this.baseUrl + 'person/merge', {destId, srcId});
+ }
+
}
diff --git a/UI/Web/src/app/person-detail/_modal/merge-person-modal/merge-person-modal.component.html b/UI/Web/src/app/person-detail/_modal/merge-person-modal/merge-person-modal.component.html
new file mode 100644
index 000000000..6ab90d854
--- /dev/null
+++ b/UI/Web/src/app/person-detail/_modal/merge-person-modal/merge-person-modal.component.html
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{item.name}}
+
+
+ {{item.name}}
+
+
+
+
+
+
+
+
+ @if (mergee) {
+
+
+
+
+
{{t('alias-title')}}
+
+
+
+ {{item}}
+
+
+
+
+
+
+ }
+
+
+
+
+
+
diff --git a/UI/Web/src/app/person-detail/_modal/merge-person-modal/merge-person-modal.component.scss b/UI/Web/src/app/person-detail/_modal/merge-person-modal/merge-person-modal.component.scss
new file mode 100644
index 000000000..e69de29bb
diff --git a/UI/Web/src/app/person-detail/_modal/merge-person-modal/merge-person-modal.component.ts b/UI/Web/src/app/person-detail/_modal/merge-person-modal/merge-person-modal.component.ts
new file mode 100644
index 000000000..d851ebfea
--- /dev/null
+++ b/UI/Web/src/app/person-detail/_modal/merge-person-modal/merge-person-modal.component.ts
@@ -0,0 +1,92 @@
+import {Component, EventEmitter, inject, Input, OnInit} from '@angular/core';
+import {Person} from "../../../_models/metadata/person";
+import {PersonService} from "../../../_services/person.service";
+import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
+import {ToastrService} from "ngx-toastr";
+import {TranslocoDirective} from "@jsverse/transloco";
+import {TypeaheadComponent} from "../../../typeahead/_components/typeahead.component";
+import {TypeaheadSettings} from "../../../typeahead/_models/typeahead-settings";
+import {map} from "rxjs/operators";
+import {UtilityService} from "../../../shared/_services/utility.service";
+import {MetadataService} from "../../../_services/metadata.service";
+import {SettingItemComponent} from "../../../settings/_components/setting-item/setting-item.component";
+import {BadgeExpanderComponent} from "../../../shared/badge-expander/badge-expander.component";
+import {FilterField} from "../../../_models/metadata/v2/filter-field";
+
+@Component({
+ selector: 'app-merge-person-modal',
+ imports: [
+ TranslocoDirective,
+ TypeaheadComponent,
+ SettingItemComponent,
+ BadgeExpanderComponent
+ ],
+ templateUrl: './merge-person-modal.component.html',
+ styleUrl: './merge-person-modal.component.scss'
+})
+export class MergePersonModalComponent implements OnInit {
+
+ private readonly personService = inject(PersonService);
+ public readonly utilityService = inject(UtilityService);
+ private readonly metadataService = inject(MetadataService);
+ private readonly modal = inject(NgbActiveModal);
+ protected readonly toastr = inject(ToastrService);
+
+ typeAheadSettings!: TypeaheadSettings;
+ typeAheadUnfocus = new EventEmitter();
+
+ @Input({required: true}) person!: Person;
+
+ mergee: Person | null = null;
+
+ save() {
+ if (!this.mergee) {
+ this.close();
+ return;
+ }
+
+ this.personService.mergePerson(this.person.id, this.mergee.id).subscribe(person => {
+ this.modal.close({success: true, person: person});
+ })
+ }
+
+ close() {
+ this.modal.close({success: false, person: this.person});
+ }
+
+ ngOnInit(): void {
+ this.typeAheadSettings = new TypeaheadSettings();
+ this.typeAheadSettings.minCharacters = 0;
+ this.typeAheadSettings.multiple = false;
+ this.typeAheadSettings.addIfNonExisting = false;
+ this.typeAheadSettings.id = "merge-person-modal-typeahead";
+ this.typeAheadSettings.compareFn = (options: Person[], filter: string) => {
+ return options.filter(m => this.utilityService.filter(m.name, filter));
+ }
+ this.typeAheadSettings.selectionCompareFn = (a: Person, b: Person) => {
+ return a.name == b.name;
+ }
+ this.typeAheadSettings.fetchFn = (filter: string) => {
+ return this.metadataService.getAllPeople().pipe(map(people => {
+ return people.filter(p => this.utilityService.filter(p.name, filter) && p.id != this.person.id);
+ }));
+ };
+
+ this.typeAheadSettings.trackByIdentityFn = (index, value) => value.name + (value.id + '');
+ }
+
+ updatePerson(people: Person[]) {
+ if (people.length == 0) return;
+
+ this.typeAheadUnfocus.emit(this.typeAheadSettings.id);
+ this.mergee = people[0];
+ }
+
+ protected readonly FilterField = FilterField;
+
+ allNewAliases() {
+ if (!this.mergee) return [];
+
+ return [this.mergee.name, ...this.mergee.aliases]
+ }
+}
diff --git a/UI/Web/src/app/person-detail/person-detail.component.ts b/UI/Web/src/app/person-detail/person-detail.component.ts
index ae4848a04..2d7e89f61 100644
--- a/UI/Web/src/app/person-detail/person-detail.component.ts
+++ b/UI/Web/src/app/person-detail/person-detail.component.ts
@@ -1,17 +1,17 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
- Component, DestroyRef,
+ Component,
+ DestroyRef,
ElementRef,
- Inject,
- inject, OnInit,
+ inject,
ViewChild
} from '@angular/core';
import {ActivatedRoute, Router} from "@angular/router";
import {PersonService} from "../_services/person.service";
import {BehaviorSubject, EMPTY, Observable, switchMap, tap} from "rxjs";
import {Person, PersonRole} from "../_models/metadata/person";
-import {AsyncPipe, NgStyle} from "@angular/common";
+import {AsyncPipe} from "@angular/common";
import {ImageComponent} from "../shared/image/image.component";
import {ImageService} from "../_services/image.service";
import {
@@ -21,7 +21,6 @@ import {ReadMoreComponent} from "../shared/read-more/read-more.component";
import {TagBadgeComponent, TagBadgeCursor} from "../shared/tag-badge/tag-badge.component";
import {PersonRolePipe} from "../_pipes/person-role.pipe";
import {CarouselReelComponent} from "../carousel/_components/carousel-reel/carousel-reel.component";
-import {SeriesCardComponent} from "../cards/series-card/series-card.component";
import {FilterComparison} from "../_models/metadata/v2/filter-comparison";
import {FilterUtilitiesService} from "../shared/_services/filter-utilities.service";
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
@@ -42,6 +41,7 @@ import {DefaultModalOptions} from "../_models/default-modal-options";
import {ToastrService} from "ngx-toastr";
import {LicenseService} from "../_services/license.service";
import {SafeUrlPipe} from "../_pipes/safe-url.pipe";
+import {MergePersonModalComponent} from "./_modal/merge-person-modal/merge-person-modal.component";
@Component({
selector: 'app-person-detail',
@@ -117,43 +117,46 @@ export class PersonDetailComponent {
return this.personService.get(personName);
}),
tap((person) => {
-
if (person == null) {
this.toastr.error(translate('toasts.unauthorized-1'));
this.router.navigateByUrl('/home');
return;
}
- this.person = person;
- this.personSubject.next(person); // emit the person data for subscribers
- this.themeService.setColorScape(person.primaryColor || '', person.secondaryColor);
-
- // Fetch roles and process them
- this.roles$ = this.personService.getRolesForPerson(this.person.id).pipe(
- tap(roles => {
- this.roles = roles;
- this.filter = this.createFilter(roles);
- this.chaptersByRole = {}; // Reset chaptersByRole for each person
-
- // Populate chapters by role
- roles.forEach(role => {
- this.chaptersByRole[role] = this.personService.getChaptersByRole(person.id, role)
- .pipe(takeUntilDestroyed(this.destroyRef));
- });
- this.cdRef.markForCheck();
- }),
- takeUntilDestroyed(this.destroyRef)
- );
-
- // Fetch series known for this person
- this.works$ = this.personService.getSeriesMostKnownFor(person.id).pipe(
- takeUntilDestroyed(this.destroyRef)
- );
+ this.setPerson(person);
}),
takeUntilDestroyed(this.destroyRef)
).subscribe();
}
+ private setPerson(person: Person) {
+ this.person = person;
+ this.personSubject.next(person); // emit the person data for subscribers
+ this.themeService.setColorScape(person.primaryColor || '', person.secondaryColor);
+
+ // Fetch roles and process them
+ this.roles$ = this.personService.getRolesForPerson(this.person.id).pipe(
+ tap(roles => {
+ this.roles = roles;
+ this.filter = this.createFilter(roles);
+ this.chaptersByRole = {}; // Reset chaptersByRole for each person
+
+ // Populate chapters by role
+ roles.forEach(role => {
+ this.chaptersByRole[role] = this.personService.getChaptersByRole(person.id, role)
+ .pipe(takeUntilDestroyed(this.destroyRef));
+ });
+ this.cdRef.markForCheck();
+ }),
+ takeUntilDestroyed(this.destroyRef)
+ );
+
+ // Fetch series known for this person
+ this.works$ = this.personService.getSeriesMostKnownFor(person.id).pipe(
+ takeUntilDestroyed(this.destroyRef)
+ );
+ }
+
createFilter(roles: PersonRole[]) {
const filter: SeriesFilterV2 = this.filterUtilityService.createSeriesV2Filter();
filter.combination = FilterCombination.Or;
@@ -228,11 +231,29 @@ export class PersonDetailComponent {
}
});
break;
+ case (Action.Merge):
+ this.mergePersonAction();
+ break;
default:
break;
}
}
+ private mergePersonAction() {
+ const ref = this.modalService.open(MergePersonModalComponent, DefaultModalOptions);
+ ref.componentInstance.person = this.person;
+
+ ref.closed.subscribe(r => {
+ if (r.success) {
+ // Reload the person data, as relations may have changed
+ this.personService.get(r.person.name).subscribe(person => {
+ this.setPerson(person!);
+ this.cdRef.markForCheck();
+ })
+ }
+ });
+ }
+
performAction(action: ActionItem) {
if (typeof action.callback === 'function') {
action.callback(action, this.person);
diff --git a/UI/Web/src/app/typeahead/_components/typeahead.component.ts b/UI/Web/src/app/typeahead/_components/typeahead.component.ts
index 223676b3a..17dbc7b4c 100644
--- a/UI/Web/src/app/typeahead/_components/typeahead.component.ts
+++ b/UI/Web/src/app/typeahead/_components/typeahead.component.ts
@@ -72,6 +72,10 @@ export class TypeaheadComponent implements OnInit {
* When triggered, will focus the input if the passed string matches the id
*/
@Input() focus: EventEmitter | undefined;
+ /**
+ * When triggered, will unfocus the input if the passed string matches the id
+ */
+ @Input() unFocus: EventEmitter | undefined;
@Output() selectedData = new EventEmitter();
@Output() newItemAdded = new EventEmitter();
// eslint-disable-next-line @angular-eslint/no-output-on-prefix
@@ -113,6 +117,13 @@ export class TypeaheadComponent implements OnInit {
});
}
+ if (this.unFocus) {
+ this.unFocus.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((id: string) => {
+ if (this.settings.id !== id) return;
+ this.hasFocus = false;
+ });
+ }
+
this.init();
}
diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json
index 47b40d2dd..decfff338 100644
--- a/UI/Web/src/assets/langs/en.json
+++ b/UI/Web/src/assets/langs/en.json
@@ -2271,6 +2271,15 @@
"alias-overlap": "This alias already points towards another person, consider merging them."
},
+ "merge-person-modal": {
+ "title": "{{personName}}",
+ "close": "{{common.close}}",
+ "save": "{{common.save}}",
+ "src": "Merge Person",
+ "merge-warning": "If you proceed, the selected person will be removed. If the target person has no existing names, the selected person's name will be added as their first name. Otherwise, the selected person's name will be added as an additional alias.",
+ "alias-title": "New aliases"
+ },
+
"day-breakdown": {
"title": "Day Breakdown",
"no-data": "No progress, get to reading",
@@ -2786,7 +2795,8 @@
"match-tooltip": "Match Series with Kavita+ manually",
"reorder": "Reorder",
"rename": "Rename",
- "rename-tooltip": "Rename the Smart Filter"
+ "rename-tooltip": "Rename the Smart Filter",
+ "merge": "Merge"
},
"preferences": {