People Aliases and Merging (#3795)
Co-authored-by: Joseph Milazzo <josephmajora@gmail.com>
This commit is contained in:
parent
cd2a6af6f2
commit
7ce36bfc44
67 changed files with 5288 additions and 284 deletions
|
@ -13,7 +13,7 @@
|
|||
}
|
||||
|
||||
.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;
|
||||
|
|
|
@ -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: [],
|
||||
}
|
||||
];
|
||||
|
||||
|
|
|
@ -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});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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 + '');
|
||||
|
|
|
@ -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 + '');
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -138,3 +138,7 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.small-text {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
|
|
@ -96,6 +96,19 @@
|
|||
</ng-template>
|
||||
</li>
|
||||
|
||||
<li [ngbNavItem]="TabID.Aliases">
|
||||
<a ngbNavLink>{{t(TabID.Aliases)}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<h5>{{t('aliases-label')}}</h5>
|
||||
<div class="text-muted mb-2">{{t('aliases-tooltip')}}</div>
|
||||
<app-edit-list [items]="person.aliases"
|
||||
[asyncValidators]="[aliasValidator()]"
|
||||
(updateItems)="person.aliases = $event"
|
||||
[errorMessage]="t('alias-overlap')"
|
||||
[label]="t('aliases-label')"/>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
|
||||
<li [ngbNavItem]="TabID.CoverImage">
|
||||
<a ngbNavLink>{{t(TabID.CoverImage)}}</a>
|
||||
|
|
|
@ -1,6 +1,14 @@
|
|||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core';
|
||||
import {Breakpoint, UtilityService} from "../../../shared/_services/utility.service";
|
||||
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
|
||||
import {
|
||||
AbstractControl,
|
||||
AsyncValidatorFn,
|
||||
FormControl,
|
||||
FormGroup,
|
||||
ReactiveFormsModule,
|
||||
ValidationErrors,
|
||||
Validators
|
||||
} from "@angular/forms";
|
||||
import {Person} from "../../../_models/metadata/person";
|
||||
import {
|
||||
NgbActiveModal,
|
||||
|
@ -14,14 +22,16 @@ import {
|
|||
import {PersonService} from "../../../_services/person.service";
|
||||
import {translate, TranslocoDirective} from '@jsverse/transloco';
|
||||
import {CoverImageChooserComponent} from "../../../cards/cover-image-chooser/cover-image-chooser.component";
|
||||
import {forkJoin} from "rxjs";
|
||||
import {forkJoin, map, of} from "rxjs";
|
||||
import {UploadService} from "../../../_services/upload.service";
|
||||
import {SettingItemComponent} from "../../../settings/_components/setting-item/setting-item.component";
|
||||
import {AccountService} from "../../../_services/account.service";
|
||||
import {ToastrService} from "ngx-toastr";
|
||||
import {EditListComponent} from "../../../shared/edit-list/edit-list.component";
|
||||
|
||||
enum TabID {
|
||||
General = 'general-tab',
|
||||
Aliases = 'aliases-tab',
|
||||
CoverImage = 'cover-image-tab',
|
||||
}
|
||||
|
||||
|
@ -37,7 +47,8 @@ enum TabID {
|
|||
NgbNavOutlet,
|
||||
CoverImageChooserComponent,
|
||||
SettingItemComponent,
|
||||
NgbNavLink
|
||||
NgbNavLink,
|
||||
EditListComponent
|
||||
],
|
||||
templateUrl: './edit-person-modal.component.html',
|
||||
styleUrl: './edit-person-modal.component.scss',
|
||||
|
@ -117,6 +128,7 @@ export class EditPersonModalComponent implements OnInit {
|
|||
// @ts-ignore
|
||||
malId: this.editForm.get('malId')!.value === '' ? null : parseInt(this.editForm.get('malId').value, 10),
|
||||
hardcoverId: this.editForm.get('hardcoverId')!.value || '',
|
||||
aliases: this.person.aliases,
|
||||
};
|
||||
apis.push(this.personService.updatePerson(person));
|
||||
|
||||
|
@ -165,4 +177,21 @@ export class EditPersonModalComponent implements OnInit {
|
|||
});
|
||||
}
|
||||
|
||||
aliasValidator(): AsyncValidatorFn {
|
||||
return (control: AbstractControl) => {
|
||||
const name = control.value;
|
||||
if (!name || name.trim().length === 0) {
|
||||
return of(null);
|
||||
}
|
||||
|
||||
return this.personService.isValidAlias(this.person.id, name).pipe(map(valid => {
|
||||
if (valid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { 'invalidAlias': {'alias': name} } as ValidationErrors;
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
<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>
|
||||
|
||||
@if (knownFor$ | async; as knownFor) {
|
||||
<h5 class="mt-2">{{t('known-for-title')}}</h5>
|
||||
|
||||
<app-badge-expander [items]="knownFor">
|
||||
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
|
||||
{{item.name}}
|
||||
</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,101 @@
|
|||
import {Component, DestroyRef, 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 {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";
|
||||
import {Observable, of} from "rxjs";
|
||||
import {Series} from "../../../_models/series";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {AsyncPipe} from "@angular/common";
|
||||
|
||||
@Component({
|
||||
selector: 'app-merge-person-modal',
|
||||
imports: [
|
||||
TranslocoDirective,
|
||||
TypeaheadComponent,
|
||||
SettingItemComponent,
|
||||
BadgeExpanderComponent,
|
||||
AsyncPipe
|
||||
],
|
||||
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 destroyRef = inject(DestroyRef);
|
||||
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;
|
||||
knownFor$: Observable<Series[]> | 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) => {
|
||||
if (filter.length == 0) return of([]);
|
||||
|
||||
return this.personService.searchPerson(filter).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];
|
||||
this.knownFor$ = this.personService.getSeriesMostKnownFor(this.mergee.id)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef));
|
||||
}
|
||||
|
||||
protected readonly FilterField = FilterField;
|
||||
|
||||
allNewAliases() {
|
||||
if (!this.mergee) return [];
|
||||
|
||||
return [this.mergee.name, ...this.mergee.aliases]
|
||||
}
|
||||
}
|
|
@ -43,15 +43,43 @@
|
|||
|
||||
<div class="col-xl-10 col-lg-7 col-md-12 col-xs-12 col-sm-12 mt-2">
|
||||
<div class="row g-0 mt-2">
|
||||
<app-read-more [text]="person.description || defaultSummaryText"></app-read-more>
|
||||
<app-read-more [maxLength]="500" [text]="person.description || t('no-info')"></app-read-more>
|
||||
|
||||
|
||||
@if (person.aliases.length > 0) {
|
||||
<span class="fw-bold mt-2">{{t('aka-title')}}</span>
|
||||
<div>
|
||||
<app-badge-expander [items]="person.aliases"
|
||||
[itemsTillExpander]="6">
|
||||
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
|
||||
<span>{{item}}</span>
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
@if (roles$ | async; as roles) {
|
||||
<div class="mt-1">
|
||||
<h5>{{t('all-roles')}}</h5>
|
||||
@for(role of roles; track role) {
|
||||
<app-tag-badge [selectionMode]="TagBadgeCursor.Clickable" (click)="loadFilterByRole(role)">{{role | personRole}}</app-tag-badge>
|
||||
}
|
||||
</div>
|
||||
@if (roles.length > 0) {
|
||||
<span class="fw-bold mt-2">{{t('all-roles')}}</span>
|
||||
<div>
|
||||
<app-badge-expander [items]="roles"
|
||||
[itemsTillExpander]="6">
|
||||
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
|
||||
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="loadFilterByRole(item)">{{item | personRole}}</a>
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
<!-- -->
|
||||
<!-- <div class="mt-1">-->
|
||||
<!-- <h5>{{t('all-roles')}}</h5>-->
|
||||
<!-- @for(role of roles; track role) {-->
|
||||
<!-- <app-tag-badge [selectionMode]="TagBadgeCursor.Clickable" (click)="loadFilterByRole(role)">{{role | personRole}}</app-tag-badge>-->
|
||||
<!-- }-->
|
||||
<!-- </div>-->
|
||||
}
|
||||
|
||||
</div>
|
||||
|
|
|
@ -1,31 +1,31 @@
|
|||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component, DestroyRef,
|
||||
Component,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
Inject,
|
||||
inject, OnInit,
|
||||
inject,
|
||||
OnInit,
|
||||
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 {
|
||||
SideNavCompanionBarComponent
|
||||
} from "../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component";
|
||||
import {ReadMoreComponent} from "../shared/read-more/read-more.component";
|
||||
import {TagBadgeComponent, TagBadgeCursor} from "../shared/tag-badge/tag-badge.component";
|
||||
import {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";
|
||||
import {allPeople, personRoleForFilterField} from "../_models/metadata/v2/filter-field";
|
||||
import {allPeople, FilterField, personRoleForFilterField} from "../_models/metadata/v2/filter-field";
|
||||
import {Series} from "../_models/series";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {FilterCombination} from "../_models/metadata/v2/filter-combination";
|
||||
|
@ -42,28 +42,38 @@ 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";
|
||||
import {EVENTS, MessageHubService} from "../_services/message-hub.service";
|
||||
import {BadgeExpanderComponent} from "../shared/badge-expander/badge-expander.component";
|
||||
|
||||
interface PersonMergeEvent {
|
||||
srcId: number,
|
||||
dstId: number,
|
||||
dstName: number,
|
||||
}
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'app-person-detail',
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
ImageComponent,
|
||||
SideNavCompanionBarComponent,
|
||||
ReadMoreComponent,
|
||||
TagBadgeComponent,
|
||||
PersonRolePipe,
|
||||
CarouselReelComponent,
|
||||
CardItemComponent,
|
||||
CardActionablesComponent,
|
||||
TranslocoDirective,
|
||||
ChapterCardComponent,
|
||||
SafeUrlPipe
|
||||
],
|
||||
templateUrl: './person-detail.component.html',
|
||||
styleUrl: './person-detail.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
selector: 'app-person-detail',
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
ImageComponent,
|
||||
SideNavCompanionBarComponent,
|
||||
ReadMoreComponent,
|
||||
PersonRolePipe,
|
||||
CarouselReelComponent,
|
||||
CardItemComponent,
|
||||
CardActionablesComponent,
|
||||
TranslocoDirective,
|
||||
ChapterCardComponent,
|
||||
SafeUrlPipe,
|
||||
BadgeExpanderComponent
|
||||
],
|
||||
templateUrl: './person-detail.component.html',
|
||||
styleUrl: './person-detail.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class PersonDetailComponent {
|
||||
export class PersonDetailComponent implements OnInit {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly filterUtilityService = inject(FilterUtilitiesService);
|
||||
|
@ -77,6 +87,7 @@ export class PersonDetailComponent {
|
|||
protected readonly licenseService = inject(LicenseService);
|
||||
private readonly themeService = inject(ThemeService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly messageHubService = inject(MessageHubService)
|
||||
|
||||
protected readonly TagBadgeCursor = TagBadgeCursor;
|
||||
|
||||
|
@ -88,11 +99,11 @@ export class PersonDetailComponent {
|
|||
roles$: Observable<PersonRole[]> | null = null;
|
||||
roles: PersonRole[] | null = null;
|
||||
works$: Observable<Series[]> | null = null;
|
||||
defaultSummaryText = 'No information about this Person';
|
||||
filter: SeriesFilterV2 | null = null;
|
||||
personActions: Array<ActionItem<Person>> = this.actionService.getPersonActions(this.handleAction.bind(this));
|
||||
chaptersByRole: any = {};
|
||||
anilistUrl: string = '';
|
||||
|
||||
private readonly personSubject = new BehaviorSubject<Person | null>(null);
|
||||
protected readonly person$ = this.personSubject.asObservable().pipe(tap(p => {
|
||||
if (p?.aniListId) {
|
||||
|
@ -118,43 +129,58 @@ 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();
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.messageHubService.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(message => {
|
||||
if (message.event !== EVENTS.PersonMerged) return;
|
||||
|
||||
const event = message.payload as PersonMergeEvent;
|
||||
if (event.srcId !== this.person?.id) return;
|
||||
|
||||
this.router.navigate(['person', event.dstName]);
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
|
@ -229,14 +255,34 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
protected readonly FilterField = FilterField;
|
||||
}
|
||||
|
|
|
@ -130,7 +130,7 @@
|
|||
<span class="fw-bold">{{t('publication-status-title')}}</span>
|
||||
<div>
|
||||
@if (seriesMetadata.publicationStatus | publicationStatus; as pubStatus) {
|
||||
<a class="dark-exempt btn-icon" (click)="openFilter(FilterField.PublicationStatus, seriesMetadata.publicationStatus)"
|
||||
<a class="dark-exempt btn-icon font-size" (click)="openFilter(FilterField.PublicationStatus, seriesMetadata!.publicationStatus)"
|
||||
href="javascript:void(0);"
|
||||
[ngbTooltip]="t('publication-status-tooltip') + (seriesMetadata.totalCount === 0 ? '' : ' (' + seriesMetadata.maxCount + ' / ' + seriesMetadata.totalCount + ')')">
|
||||
{{pubStatus}}
|
||||
|
|
|
@ -30,3 +30,7 @@
|
|||
:host ::ng-deep .card-actions.btn-actions .btn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
}
|
||||
|
||||
.font-size {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
|
|
@ -5,4 +5,11 @@
|
|||
.collapsed {
|
||||
height: 35px;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep .badge-expander .content {
|
||||
a,
|
||||
span {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
<form [formGroup]="form" *transloco="let t">
|
||||
<div formArrayName="items">
|
||||
@for(item of ItemsArray.controls; let i = $index; track i) {
|
||||
<!-- We are tracking items, as the index will not always point towards the same item. -->
|
||||
@for(item of ItemsArray.controls; let i = $index; track item; let last = $last) {
|
||||
<div class="row g-0 mb-3">
|
||||
<div class="col-lg-10 col-md-12 pe-2">
|
||||
<div class="mb-3">
|
||||
|
@ -11,21 +12,30 @@
|
|||
[formControlName]="i"
|
||||
id="item--{{i}}"
|
||||
>
|
||||
@if (item.dirty && item.touched && errorMessage) {
|
||||
@if (item.status === "INVALID") {
|
||||
<div id="item--{{i}}-error" class="invalid-feedback" style="display: inline-block">
|
||||
{{errorMessage}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-2">
|
||||
<button class="btn btn-secondary me-1" (click)="add()">
|
||||
<i class="fa-solid fa-plus" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('common.add')}}</span>
|
||||
</button>
|
||||
<div class="col-lg-2">
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
class="btn btn-danger me-2"
|
||||
(click)="remove(i)"
|
||||
[disabled]="ItemsArray.length === 1"
|
||||
>
|
||||
<i class="fa-solid fa-xmark" aria-hidden="true"></i>
|
||||
<i class="fa-solid fa-trash" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('common.remove')}}</span>
|
||||
</button>
|
||||
@if (last){
|
||||
<button class="btn btn-secondary " (click)="add()">
|
||||
<i class="fa-solid fa-plus" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('common.add')}}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@ import {
|
|||
OnInit,
|
||||
Output
|
||||
} from '@angular/core';
|
||||
import {FormArray, FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
|
||||
import {AsyncValidatorFn, FormArray, FormControl, FormGroup, ReactiveFormsModule, ValidatorFn} from "@angular/forms";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {debounceTime, distinctUntilChanged, tap} from "rxjs/operators";
|
||||
|
@ -28,6 +28,10 @@ export class EditListComponent implements OnInit {
|
|||
|
||||
@Input({required: true}) items: Array<string> = [];
|
||||
@Input({required: true}) label = '';
|
||||
@Input() validators: ValidatorFn[] = []
|
||||
@Input() asyncValidators: AsyncValidatorFn[] = [];
|
||||
// TODO: Make this more dynamic based on which validator failed
|
||||
@Input() errorMessage: string | null = null;
|
||||
@Output() updateItems = new EventEmitter<Array<string>>();
|
||||
|
||||
form: FormGroup = new FormGroup({items: new FormArray([])});
|
||||
|
@ -39,6 +43,9 @@ export class EditListComponent implements OnInit {
|
|||
|
||||
ngOnInit() {
|
||||
this.items.forEach(item => this.addItem(item));
|
||||
if (this.items.length === 0) {
|
||||
this.addItem("");
|
||||
}
|
||||
|
||||
|
||||
this.form.valueChanges.pipe(
|
||||
|
@ -51,7 +58,7 @@ export class EditListComponent implements OnInit {
|
|||
}
|
||||
|
||||
createItemControl(value: string = ''): FormControl {
|
||||
return new FormControl(value, []);
|
||||
return new FormControl(value, this.validators, this.asyncValidators);
|
||||
}
|
||||
|
||||
add() {
|
||||
|
@ -69,6 +76,7 @@ export class EditListComponent implements OnInit {
|
|||
if (this.ItemsArray.length === 1) {
|
||||
this.ItemsArray.at(0).setValue('');
|
||||
this.emit();
|
||||
this.cdRef.markForCheck();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
@ -130,7 +130,8 @@ export class LibrarySettingsModalComponent implements OnInit {
|
|||
|
||||
get IsMetadataDownloadEligible() {
|
||||
const libType = parseInt(this.libraryForm.get('type')?.value + '', 10) as LibraryType;
|
||||
return libType === LibraryType.Manga || libType === LibraryType.LightNovel || libType === LibraryType.ComicVine;
|
||||
return libType === LibraryType.Manga || libType === LibraryType.LightNovel
|
||||
|| libType === LibraryType.ComicVine || libType === LibraryType.Comic;
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
@ -1003,7 +1003,7 @@
|
|||
"save": "{{common.save}}",
|
||||
"no-results": "Unable to find a match. Try adding the url from a supported provider and retry.",
|
||||
"query-label": "Query",
|
||||
"query-tooltip": "Enter series name, AniList/MyAnimeList url. Urls will use a direct lookup.",
|
||||
"query-tooltip": "Enter series name, AniList/MyAnimeList/ComicBookRoundup url. Urls will use a direct lookup.",
|
||||
"dont-match-label": "Do not Match",
|
||||
"dont-match-tooltip": "Opt this series from matching and scrobbling",
|
||||
"search": "Search"
|
||||
|
@ -1103,12 +1103,14 @@
|
|||
},
|
||||
|
||||
"person-detail": {
|
||||
"aka-title": "Also known as ",
|
||||
"known-for-title": "Known For",
|
||||
"individual-role-title": "As a {{role}}",
|
||||
"browse-person-title": "All Works of {{name}}",
|
||||
"browse-person-by-role-title": "All Works of {{name}} as a {{role}}",
|
||||
"all-roles": "Roles",
|
||||
"anilist-url": "{{edit-person-modal.anilist-tooltip}}"
|
||||
"anilist-url": "{{edit-person-modal.anilist-tooltip}}",
|
||||
"no-info": "No information about this Person"
|
||||
},
|
||||
|
||||
"library-settings-modal": {
|
||||
|
@ -1857,7 +1859,8 @@
|
|||
"logout": "Logout",
|
||||
"all-filters": "Smart Filters",
|
||||
"nav-link-header": "Navigation Options",
|
||||
"close": "{{common.close}}"
|
||||
"close": "{{common.close}}",
|
||||
"person-aka-status": "Matches an alias"
|
||||
},
|
||||
|
||||
"promoted-icon": {
|
||||
|
@ -2246,6 +2249,7 @@
|
|||
"title": "{{personName}} Details",
|
||||
"general-tab": "{{edit-series-modal.general-tab}}",
|
||||
"cover-image-tab": "{{edit-series-modal.cover-image-tab}}",
|
||||
"aliases-tab": "Aliases",
|
||||
"loading": "{{common.loading}}",
|
||||
"close": "{{common.close}}",
|
||||
"name-label": "{{edit-series-modal.name-label}}",
|
||||
|
@ -2263,7 +2267,20 @@
|
|||
"cover-image-description": "{{edit-series-modal.cover-image-description}}",
|
||||
"cover-image-description-extra": "Alternatively you can download a cover from CoversDB if available.",
|
||||
"save": "{{common.save}}",
|
||||
"download-coversdb": "Download from CoversDB"
|
||||
"download-coversdb": "Download from CoversDB",
|
||||
"aliases-label": "Edit aliases",
|
||||
"alias-overlap": "This alias already points towards another person or is the name of this person, consider merging them.",
|
||||
"aliases-tooltip": "When a series is tagged with an alias of a person, the person is assigned rather than creating a new person. When deleting an alias, you'll have to rescan the series for the change to be picked up."
|
||||
},
|
||||
|
||||
"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. The selected person's name will be added as an alias, and all their roles will be transferred.",
|
||||
"alias-title": "New aliases",
|
||||
"known-for-title": "Known for"
|
||||
},
|
||||
|
||||
"day-breakdown": {
|
||||
|
@ -2781,7 +2798,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": {
|
||||
|
|
|
@ -436,4 +436,7 @@
|
|||
--login-input-font-family: 'League Spartan', sans-serif;
|
||||
--login-input-placeholder-opacity: 0.5;
|
||||
--login-input-placeholder-color: #fff;
|
||||
|
||||
/** Series Detail **/
|
||||
--detail-subtitle-color: lightgrey;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue