Add merge UI

This commit is contained in:
Amelia 2025-05-07 17:41:10 +02:00
parent 29e2879153
commit 6ec7e80a43
No known key found for this signature in database
GPG key ID: D6D0ECE365407EAA
13 changed files with 275 additions and 40 deletions

View file

@ -32,6 +32,11 @@ public class PersonServiceTests: AbstractDbTest
Name= "Delores Casey", Name= "Delores Casey",
NormalizedName = "Delores Casey".ToNormalized(), NormalizedName = "Delores Casey".ToNormalized(),
Description = "Hi, I'm Delores Casey!", Description = "Hi, I'm Delores Casey!",
Aliases = [new PersonAlias
{
Alias = "Casey, Delores",
NormalizedAlias = "Casey, Delores".ToNormalized(),
}],
AniListId = 27, AniListId = 27,
}; };
@ -51,6 +56,7 @@ public class PersonServiceTests: AbstractDbTest
Assert.NotNull(person.HardcoverId); Assert.NotNull(person.HardcoverId);
Assert.NotEmpty(person.HardcoverId); Assert.NotEmpty(person.HardcoverId);
Assert.Contains(person.Aliases, pa => pa.Alias == "Delores Casey"); Assert.Contains(person.Aliases, pa => pa.Alias == "Delores Casey");
Assert.Contains(person.Aliases, pa => pa.Alias == "Casey, Delores");
} }
[Fact] [Fact]
@ -178,7 +184,7 @@ public class PersonServiceTests: AbstractDbTest
// Some overlap // Some overlap
success = await ps.UpdatePersonAliasesAsync(person1, ["Jolly Cowan", "Jilly Joy Cowan"]); success = await ps.UpdatePersonAliasesAsync(person1, ["Jolly Cowan", "Jilly Joy Cowan"]);
Assert.True(success); Assert.False(success);
Assert.Single(person2.Aliases); Assert.Single(person2.Aliases);
} }

View file

@ -2,6 +2,7 @@
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Data; using API.Data;
using API.Data.Repositories;
using API.DTOs; using API.DTOs;
using API.Entities.Enums; using API.Entities.Enums;
using API.Extensions; using API.Extensions;
@ -186,16 +187,16 @@ public class PersonController : BaseApiController
/// <param name="dto"></param> /// <param name="dto"></param>
/// <returns></returns> /// <returns></returns>
[HttpPost("merge")] [HttpPost("merge")]
public async Task<ActionResult> MergePersons(PersonMergeDto dto) public async Task<ActionResult<PersonDto>> 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(); 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(); if (src == null) return BadRequest();
await _personService.MergePeopleAsync(dst, src); await _personService.MergePeopleAsync(dst, src);
return Ok(); return Ok(_mapper.Map<PersonDto>(dst));
} }
[HttpGet("alias/{personId}/{alias}")] [HttpGet("alias/{personId}/{alias}")]

View file

@ -20,6 +20,10 @@ public enum PersonIncludes
{ {
None = 1 << 0, None = 1 << 0,
Aliases = 1 << 1, Aliases = 1 << 1,
ChapterPeople = 1 << 2,
SeriesPeople = 1 << 3,
All = Aliases | ChapterPeople | SeriesPeople,
} }
public interface IPersonRepository public interface IPersonRepository

View file

@ -331,6 +331,16 @@ public static class IncludesExtensions
queryable = queryable.Include(p => p.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; return queryable;
} }
} }

View file

@ -14,11 +14,12 @@ public interface IPersonService
/// </summary> /// </summary>
/// <param name="dst">Remaining person</param> /// <param name="dst">Remaining person</param>
/// <param name="src">Merged person</param> /// <param name="src">Merged person</param>
/// <remarks>The entities passed as arguments **must** include all relations</remarks>
/// <returns></returns> /// <returns></returns>
Task MergePeopleAsync(Person dst, Person src); Task MergePeopleAsync(Person dst, Person src);
/// <summary> /// <summary>
/// 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
/// </summary> /// </summary>
/// <remarks>This method does NOT commit changes</remarks> /// <remarks>This method does NOT commit changes</remarks>
/// <param name="person"></param> /// <param name="person"></param>
@ -32,6 +33,7 @@ public class PersonService(IUnitOfWork unitOfWork): IPersonService
public async Task MergePeopleAsync(Person dst, Person src) public async Task MergePeopleAsync(Person dst, Person src)
{ {
if (dst.Id == src.Id) return;
if (string.IsNullOrWhiteSpace(dst.Description) && !string.IsNullOrWhiteSpace(src.Description)) if (string.IsNullOrWhiteSpace(dst.Description) && !string.IsNullOrWhiteSpace(src.Description))
{ {
@ -68,7 +70,7 @@ public class PersonService(IUnitOfWork unitOfWork): IPersonService
dst.ChapterPeople.Add(new ChapterPeople dst.ChapterPeople.Add(new ChapterPeople
{ {
Role = chapter.Role, Role = chapter.Role,
Chapter = chapter.Chapter, ChapterId = chapter.ChapterId,
Person = dst, Person = dst,
KavitaPlusConnection = chapter.KavitaPlusConnection, KavitaPlusConnection = chapter.KavitaPlusConnection,
OrderWeight = chapter.OrderWeight, OrderWeight = chapter.OrderWeight,
@ -79,6 +81,7 @@ public class PersonService(IUnitOfWork unitOfWork): IPersonService
{ {
dst.SeriesMetadataPeople.Add(new SeriesMetadataPeople dst.SeriesMetadataPeople.Add(new SeriesMetadataPeople
{ {
SeriesMetadataId = series.SeriesMetadataId,
Role = series.Role, Role = series.Role,
Person = dst, Person = dst,
KavitaPlusConnection = series.KavitaPlusConnection, KavitaPlusConnection = series.KavitaPlusConnection,
@ -92,6 +95,11 @@ public class PersonService(IUnitOfWork unitOfWork): IPersonService
NormalizedAlias = src.NormalizedName, NormalizedAlias = src.NormalizedName,
}); });
foreach (var alias in src.Aliases)
{
dst.Aliases.Add(alias);
}
unitOfWork.PersonRepository.Remove(src); unitOfWork.PersonRepository.Remove(src);
unitOfWork.PersonRepository.Update(dst); unitOfWork.PersonRepository.Update(dst);
await unitOfWork.CommitAsync(); await unitOfWork.CommitAsync();

View file

@ -116,7 +116,11 @@ export enum Action {
/** /**
* Match an entity with an upstream system * Match an entity with an upstream system
*/ */
Match = 28 Match = 28,
/**
* Merge two (or more?) entities
*/
Merge = 29,
} }
/** /**
@ -819,6 +823,14 @@ export class ActionFactoryService {
callback: this.dummyCallback, callback: this.dummyCallback,
requiresAdmin: true, requiresAdmin: true,
children: [], children: [],
},
{
action: Action.Merge,
title: 'merge',
description: 'merge-person-tooltip',
callback: this.dummyCallback,
requiresAdmin: true,
children: [],
} }
]; ];

View file

@ -61,4 +61,8 @@ export class PersonService {
return this.httpClient.get<boolean>(this.baseUrl + `person/alias/${personId}/${alias}`); return this.httpClient.get<boolean>(this.baseUrl + `person/alias/${personId}/${alias}`);
} }
mergePerson(destId: number, srcId: number) {
return this.httpClient.post<Person>(this.baseUrl + 'person/merge', {destId, srcId});
}
} }

View file

@ -0,0 +1,56 @@
<ng-container *transloco="let t; prefix:'merge-person-modal'">
<div class="modal-container">
<div class="modal-header">
<h4 class="modal-title">
{{t('title', {personName: this.person.name})}}</h4>
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
</div>
</div>
<div class="modal-body scrollable-modal d-flex flex-column" style="min-height: 300px;" >
<div class="row">
<div class="col-lg-12 col-md-12 pe-2">
<div class="mb-3">
<app-setting-item [title]="t('src')" [subtitle]="t('merge-warning')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<app-typeahead [settings]="typeAheadSettings" (selectedData)="updatePerson($event)" [unFocus]="typeAheadUnfocus">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</ng-template>
</app-setting-item>
</div>
</div>
</div>
@if (mergee) {
<div class="row">
<div class="col-lg-12 col-md-12 pe-2">
<div class="mb-3">
<h5>{{t('alias-title')}}</h5>
<app-badge-expander [items]="allNewAliases()">
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
{{item}}
</ng-template>
</app-badge-expander>
</div>
</div>
</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="close()">{{t('close')}}</button>
<button type="submit" class="btn btn-primary" (click)="save()" [disabled]="mergee === null" >{{t('save')}}</button>
</div>
</ng-container>

View file

@ -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<Person>;
typeAheadUnfocus = new EventEmitter<string>();
@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<Person>();
this.typeAheadSettings.minCharacters = 0;
this.typeAheadSettings.multiple = false;
this.typeAheadSettings.addIfNonExisting = false;
this.typeAheadSettings.id = "merge-person-modal-typeahead";
this.typeAheadSettings.compareFn = (options: Person[], filter: string) => {
return options.filter(m => this.utilityService.filter(m.name, filter));
}
this.typeAheadSettings.selectionCompareFn = (a: Person, b: Person) => {
return a.name == b.name;
}
this.typeAheadSettings.fetchFn = (filter: string) => {
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]
}
}

View file

@ -1,17 +1,17 @@
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, DestroyRef, Component,
DestroyRef,
ElementRef, ElementRef,
Inject, inject,
inject, OnInit,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import {ActivatedRoute, Router} from "@angular/router"; import {ActivatedRoute, Router} from "@angular/router";
import {PersonService} from "../_services/person.service"; import {PersonService} from "../_services/person.service";
import {BehaviorSubject, EMPTY, Observable, switchMap, tap} from "rxjs"; import {BehaviorSubject, EMPTY, Observable, switchMap, tap} from "rxjs";
import {Person, PersonRole} from "../_models/metadata/person"; import {Person, PersonRole} from "../_models/metadata/person";
import {AsyncPipe, NgStyle} from "@angular/common"; import {AsyncPipe} from "@angular/common";
import {ImageComponent} from "../shared/image/image.component"; import {ImageComponent} from "../shared/image/image.component";
import {ImageService} from "../_services/image.service"; import {ImageService} from "../_services/image.service";
import { import {
@ -21,7 +21,6 @@ import {ReadMoreComponent} from "../shared/read-more/read-more.component";
import {TagBadgeComponent, TagBadgeCursor} from "../shared/tag-badge/tag-badge.component"; import {TagBadgeComponent, TagBadgeCursor} from "../shared/tag-badge/tag-badge.component";
import {PersonRolePipe} from "../_pipes/person-role.pipe"; import {PersonRolePipe} from "../_pipes/person-role.pipe";
import {CarouselReelComponent} from "../carousel/_components/carousel-reel/carousel-reel.component"; import {CarouselReelComponent} from "../carousel/_components/carousel-reel/carousel-reel.component";
import {SeriesCardComponent} from "../cards/series-card/series-card.component";
import {FilterComparison} from "../_models/metadata/v2/filter-comparison"; import {FilterComparison} from "../_models/metadata/v2/filter-comparison";
import {FilterUtilitiesService} from "../shared/_services/filter-utilities.service"; import {FilterUtilitiesService} from "../shared/_services/filter-utilities.service";
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2"; import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
@ -42,6 +41,7 @@ import {DefaultModalOptions} from "../_models/default-modal-options";
import {ToastrService} from "ngx-toastr"; import {ToastrService} from "ngx-toastr";
import {LicenseService} from "../_services/license.service"; import {LicenseService} from "../_services/license.service";
import {SafeUrlPipe} from "../_pipes/safe-url.pipe"; import {SafeUrlPipe} from "../_pipes/safe-url.pipe";
import {MergePersonModalComponent} from "./_modal/merge-person-modal/merge-person-modal.component";
@Component({ @Component({
selector: 'app-person-detail', selector: 'app-person-detail',
@ -117,13 +117,19 @@ export class PersonDetailComponent {
return this.personService.get(personName); return this.personService.get(personName);
}), }),
tap((person) => { tap((person) => {
if (person == null) { if (person == null) {
this.toastr.error(translate('toasts.unauthorized-1')); this.toastr.error(translate('toasts.unauthorized-1'));
this.router.navigateByUrl('/home'); this.router.navigateByUrl('/home');
return; return;
} }
this.setPerson(person);
}),
takeUntilDestroyed(this.destroyRef)
).subscribe();
}
private setPerson(person: Person) {
this.person = person; this.person = person;
this.personSubject.next(person); // emit the person data for subscribers this.personSubject.next(person); // emit the person data for subscribers
this.themeService.setColorScape(person.primaryColor || '', person.secondaryColor); this.themeService.setColorScape(person.primaryColor || '', person.secondaryColor);
@ -149,9 +155,6 @@ export class PersonDetailComponent {
this.works$ = this.personService.getSeriesMostKnownFor(person.id).pipe( this.works$ = this.personService.getSeriesMostKnownFor(person.id).pipe(
takeUntilDestroyed(this.destroyRef) takeUntilDestroyed(this.destroyRef)
); );
}),
takeUntilDestroyed(this.destroyRef)
).subscribe();
} }
createFilter(roles: PersonRole[]) { createFilter(roles: PersonRole[]) {
@ -228,11 +231,29 @@ export class PersonDetailComponent {
} }
}); });
break; break;
case (Action.Merge):
this.mergePersonAction();
break;
default: default:
break; break;
} }
} }
private mergePersonAction() {
const ref = this.modalService.open(MergePersonModalComponent, DefaultModalOptions);
ref.componentInstance.person = this.person;
ref.closed.subscribe(r => {
if (r.success) {
// Reload the person data, as relations may have changed
this.personService.get(r.person.name).subscribe(person => {
this.setPerson(person!);
this.cdRef.markForCheck();
})
}
});
}
performAction(action: ActionItem<any>) { performAction(action: ActionItem<any>) {
if (typeof action.callback === 'function') { if (typeof action.callback === 'function') {
action.callback(action, this.person); action.callback(action, this.person);

View file

@ -72,6 +72,10 @@ export class TypeaheadComponent implements OnInit {
* When triggered, will focus the input if the passed string matches the id * When triggered, will focus the input if the passed string matches the id
*/ */
@Input() focus: EventEmitter<string> | undefined; @Input() focus: EventEmitter<string> | undefined;
/**
* When triggered, will unfocus the input if the passed string matches the id
*/
@Input() unFocus: EventEmitter<string> | undefined;
@Output() selectedData = new EventEmitter<any[] | any>(); @Output() selectedData = new EventEmitter<any[] | any>();
@Output() newItemAdded = new EventEmitter<any[] | any>(); @Output() newItemAdded = new EventEmitter<any[] | any>();
// eslint-disable-next-line @angular-eslint/no-output-on-prefix // eslint-disable-next-line @angular-eslint/no-output-on-prefix
@ -113,6 +117,13 @@ export class TypeaheadComponent implements OnInit {
}); });
} }
if (this.unFocus) {
this.unFocus.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((id: string) => {
if (this.settings.id !== id) return;
this.hasFocus = false;
});
}
this.init(); this.init();
} }

View file

@ -2271,6 +2271,15 @@
"alias-overlap": "This alias already points towards another person, consider merging them." "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": { "day-breakdown": {
"title": "Day Breakdown", "title": "Day Breakdown",
"no-data": "No progress, get to reading", "no-data": "No progress, get to reading",
@ -2786,7 +2795,8 @@
"match-tooltip": "Match Series with Kavita+ manually", "match-tooltip": "Match Series with Kavita+ manually",
"reorder": "Reorder", "reorder": "Reorder",
"rename": "Rename", "rename": "Rename",
"rename-tooltip": "Rename the Smart Filter" "rename-tooltip": "Rename the Smart Filter",
"merge": "Merge"
}, },
"preferences": { "preferences": {