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",
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);
}

View file

@ -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
/// <param name="dto"></param>
/// <returns></returns>
[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();
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<PersonDto>(dst));
}
[HttpGet("alias/{personId}/{alias}")]

View file

@ -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

View file

@ -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;
}
}

View file

@ -14,11 +14,12 @@ public interface IPersonService
/// </summary>
/// <param name="dst">Remaining person</param>
/// <param name="src">Merged person</param>
/// <remarks>The entities passed as arguments **must** include all relations</remarks>
/// <returns></returns>
Task MergePeopleAsync(Person dst, Person src);
/// <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>
/// <remarks>This method does NOT commit changes</remarks>
/// <param name="person"></param>
@ -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();

View file

@ -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: [],
}
];

View file

@ -61,4 +61,8 @@ export class PersonService {
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 {
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<any>) {
if (typeof action.callback === 'function') {
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
*/
@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() newItemAdded = new EventEmitter<any[] | any>();
// 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();
}

View file

@ -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": {