Add merge UI
This commit is contained in:
parent
29e2879153
commit
6ec7e80a43
13 changed files with 275 additions and 40 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}")]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
}
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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]
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue