Display aliases in the UI, allow adding and removing aliases from the Person Edit modal

Adds forgotten translation string
This commit is contained in:
Amelia 2025-05-06 19:51:28 +02:00
parent c36123a58f
commit d1c5f4a377
No known key found for this signature in database
GPG key ID: D6D0ECE365407EAA
21 changed files with 228 additions and 18 deletions

View file

@ -144,6 +144,45 @@ public class PersonServiceTests: AbstractDbTest
Assert.Equal(chapter.People.First().PersonId, chapter2.People.First().PersonId); Assert.Equal(chapter.People.First().PersonId, chapter2.People.First().PersonId);
} }
[Fact]
public async Task PersonAddAlias_NoOverlap()
{
await ResetDb();
UnitOfWork.PersonRepository.Attach(new PersonBuilder("Jillian Cowan").Build());
UnitOfWork.PersonRepository.Attach(new PersonBuilder("Jilly Cowan").WithAlias("Jolly Cowan").Build());
await UnitOfWork.CommitAsync();
var ps = new PersonService(UnitOfWork);
var person1 = await UnitOfWork.PersonRepository.GetPersonByNameOrAliasAsync("Jillian Cowan");
var person2 = await UnitOfWork.PersonRepository.GetPersonByNameOrAliasAsync("Jilly Cowan");
Assert.NotNull(person1);
Assert.NotNull(person2);
// Overlap on Name
var success = await ps.UpdatePersonAliasesAsync(person1, ["Jilly Cowan"]);
Assert.False(success);
// Overlap on alias
success = await ps.UpdatePersonAliasesAsync(person1, ["Jolly Cowan"]);
Assert.False(success);
// No overlap
success = await ps.UpdatePersonAliasesAsync(person2, ["Jilly Joy Cowan"]);
Assert.True(success);
// Some overlap
success = await ps.UpdatePersonAliasesAsync(person1, ["Jolly Cowan", "Jilly Joy Cowan"]);
Assert.False(success);
// Some overlap
success = await ps.UpdatePersonAliasesAsync(person1, ["Jolly Cowan", "Jilly Joy Cowan"]);
Assert.True(success);
Assert.Single(person2.Aliases);
}
protected override async Task ResetDb() protected override async Task ResetDb()
{ {
Context.Person.RemoveRange(Context.Person.ToList()); Context.Person.RemoveRange(Context.Person.ToList());

View file

@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Data; using API.Data;
using API.DTOs; using API.DTOs;
@ -24,9 +25,10 @@ public class PersonController : BaseApiController
private readonly ICoverDbService _coverDbService; private readonly ICoverDbService _coverDbService;
private readonly IImageService _imageService; private readonly IImageService _imageService;
private readonly IEventHub _eventHub; private readonly IEventHub _eventHub;
private readonly IPersonService _personService;
public PersonController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IMapper mapper, public PersonController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IMapper mapper,
ICoverDbService coverDbService, IImageService imageService, IEventHub eventHub) ICoverDbService coverDbService, IImageService imageService, IEventHub eventHub, IPersonService personService)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_localizationService = localizationService; _localizationService = localizationService;
@ -34,6 +36,7 @@ public class PersonController : BaseApiController
_coverDbService = coverDbService; _coverDbService = coverDbService;
_imageService = imageService; _imageService = imageService;
_eventHub = eventHub; _eventHub = eventHub;
_personService = personService;
} }
@ -90,6 +93,10 @@ public class PersonController : BaseApiController
return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-name-unique")); return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-name-unique"));
} }
var success = await _personService.UpdatePersonAliasesAsync(person, dto.Aliases);
if (!success) return BadRequest(_localizationService.Translate(User.GetUserId(), "aliases-have-overlap"));
person.Name = dto.Name?.Trim(); person.Name = dto.Name?.Trim();
person.Description = dto.Description ?? string.Empty; person.Description = dto.Description ?? string.Empty;
person.CoverImageLocked = dto.CoverImageLocked; person.CoverImageLocked = dto.CoverImageLocked;
@ -173,5 +180,36 @@ public class PersonController : BaseApiController
return Ok(await _unitOfWork.PersonRepository.GetChaptersForPersonByRole(personId, User.GetUserId(), role)); return Ok(await _unitOfWork.PersonRepository.GetChaptersForPersonByRole(personId, User.GetUserId(), role));
} }
/// <summary>
/// Merges Persons into one, this action is irreversible
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("merge")]
public async Task<ActionResult> MergePersons(PersonMergeDto dto)
{
var dst = await _unitOfWork.PersonRepository.GetPersonById(dto.DestId);
if (dst == null) return BadRequest();
var src = await _unitOfWork.PersonRepository.GetPersonById(dto.SrcId);
if (src == null) return BadRequest();
await _personService.MergePeopleAsync(dst, src);
return Ok();
}
[HttpGet("alias/{personId}/{alias}")]
public async Task<ActionResult<bool>> IsValidAlias(int personId, string alias)
{
// Remove and just check against the passed value?
var person = await _unitOfWork.PersonRepository.GetPersonById(personId);
if (person == null) return NotFound();
var other = await _unitOfWork.PersonRepository.GetPersonByNameOrAliasAsync(alias);
if (other == null) return Ok(true);
return Ok(other.Id == person.Id);
}
} }

View file

@ -1,4 +1,4 @@
using System.Runtime.Serialization; using System.Collections.Generic;
namespace API.DTOs; namespace API.DTOs;
#nullable enable #nullable enable
@ -7,6 +7,7 @@ public class PersonDto
{ {
public int Id { get; set; } public int Id { get; set; }
public required string Name { get; set; } public required string Name { get; set; }
public IList<string> Aliases { get; set; } = [];
public bool CoverImageLocked { get; set; } public bool CoverImageLocked { get; set; }
public string? PrimaryColor { get; set; } public string? PrimaryColor { get; set; }

View file

@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations;
namespace API.DTOs;
public sealed record PersonMergeDto
{
/// <summary>
/// The id of the person being merged into
/// </summary>
[Required]
public int DestId { get; init; }
/// <summary>
/// The id of the person being merged. This person will be removed, and become an alias of <see cref="DestId"/>
/// </summary>
[Required]
public int SrcId { get; init; }
}

View file

@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace API.DTOs; namespace API.DTOs;
#nullable enable #nullable enable
@ -11,6 +12,7 @@ public sealed record UpdatePersonDto
public bool CoverImageLocked { get; set; } public bool CoverImageLocked { get; set; }
[Required] [Required]
public string Name {get; set;} public string Name {get; set;}
public IList<string> Aliases { get; set; } = [];
public string? Description { get; set; } public string? Description { get; set; }
public int? AniListId { get; set; } public int? AniListId { get; set; }

View file

@ -49,7 +49,7 @@ public interface IPersonRepository
/// <param name="name"></param> /// <param name="name"></param>
/// <param name="includes"></param> /// <param name="includes"></param>
/// <returns></returns> /// <returns></returns>
Task<Person?> GetPersonByNameAsync(string name, PersonIncludes includes = PersonIncludes.Aliases); Task<Person?> GetPersonByNameOrAliasAsync(string name, PersonIncludes includes = PersonIncludes.Aliases);
Task<bool> IsNameUnique(string name); Task<bool> IsNameUnique(string name);
Task<IEnumerable<SeriesDto>> GetSeriesKnownFor(int personId); Task<IEnumerable<SeriesDto>> GetSeriesKnownFor(int personId);
@ -235,7 +235,7 @@ public class PersonRepository : IPersonRepository
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
} }
public Task<Person?> GetPersonByNameAsync(string name, PersonIncludes includes = PersonIncludes.Aliases) public Task<Person?> GetPersonByNameOrAliasAsync(string name, PersonIncludes includes = PersonIncludes.Aliases)
{ {
var normalized = name.ToNormalized(); var normalized = name.ToNormalized();
return _context.Person return _context.Person
@ -246,7 +246,10 @@ public class PersonRepository : IPersonRepository
public async Task<bool> IsNameUnique(string name) public async Task<bool> IsNameUnique(string name)
{ {
return !(await _context.Person.AnyAsync(p => p.Name == name)); // Should this use Normalized to check?
return !(await _context.Person
.Includes(PersonIncludes.Aliases)
.AnyAsync(p => p.Name == name || p.Aliases.Any(pa => pa.Alias == name)));
} }
public async Task<IEnumerable<SeriesDto>> GetSeriesKnownFor(int personId) public async Task<IEnumerable<SeriesDto>> GetSeriesKnownFor(int personId)

View file

@ -53,6 +53,7 @@ public static class ApplicationServiceExtensions
services.AddScoped<IMediaConversionService, MediaConversionService>(); services.AddScoped<IMediaConversionService, MediaConversionService>();
services.AddScoped<IStreamService, StreamService>(); services.AddScoped<IStreamService, StreamService>();
services.AddScoped<IRatingService, RatingService>(); services.AddScoped<IRatingService, RatingService>();
services.AddScoped<IPersonService, PersonService>();
services.AddScoped<IScannerService, ScannerService>(); services.AddScoped<IScannerService, ScannerService>();
services.AddScoped<IProcessSeries, ProcessSeries>(); services.AddScoped<IProcessSeries, ProcessSeries>();

View file

@ -68,7 +68,9 @@ public class AutoMapperProfiles : Profile
CreateMap<AppUserCollection, AppUserCollectionDto>() CreateMap<AppUserCollection, AppUserCollectionDto>()
.ForMember(dest => dest.Owner, opt => opt.MapFrom(src => src.AppUser.UserName)) .ForMember(dest => dest.Owner, opt => opt.MapFrom(src => src.AppUser.UserName))
.ForMember(dest => dest.ItemCount, opt => opt.MapFrom(src => src.Items.Count)); .ForMember(dest => dest.ItemCount, opt => opt.MapFrom(src => src.Items.Count));
CreateMap<Person, PersonDto>(); CreateMap<Person, PersonDto>()
.ForMember(dst => dst.Aliases, opt =>
opt.MapFrom(src => src.Aliases.Select(pa => pa.Alias).ToList()));
CreateMap<Genre, GenreTagDto>(); CreateMap<Genre, GenreTagDto>();
CreateMap<Tag, TagDto>(); CreateMap<Tag, TagDto>();
CreateMap<AgeRating, AgeRatingDto>(); CreateMap<AgeRating, AgeRatingDto>();

View file

@ -212,6 +212,7 @@
"user-no-access-library-from-series": "User does not have access to the library this series belongs to", "user-no-access-library-from-series": "User does not have access to the library this series belongs to",
"series-restricted-age-restriction": "User is not allowed to view this series due to age restrictions", "series-restricted-age-restriction": "User is not allowed to view this series due to age restrictions",
"kavitaplus-restricted": "This is restricted to Kavita+ only", "kavitaplus-restricted": "This is restricted to Kavita+ only",
"aliases-have-overlap": "One or more of the aliases have overlap with other people, cannot update",
"volume-num": "Volume {0}", "volume-num": "Volume {0}",
"book-num": "Book {0}", "book-num": "Book {0}",

View file

@ -1,6 +1,9 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Data; using API.Data;
using API.Entities.Person; using API.Entities.Person;
using API.Extensions;
namespace API.Services; namespace API.Services;
@ -13,6 +16,15 @@ public interface IPersonService
/// <param name="src">Merged person</param> /// <param name="src">Merged person</param>
/// <returns></returns> /// <returns></returns>
Task MergePeopleAsync(Person dst, Person src); Task MergePeopleAsync(Person dst, Person src);
/// <summary>
/// Adds the alias to the person, requires that the alias is not shared with anyone else
/// </summary>
/// <remarks>This method does NOT commit changes</remarks>
/// <param name="person"></param>
/// <param name="aliases"></param>
/// <returns></returns>
Task<bool> UpdatePersonAliasesAsync(Person person, IList<string> aliases);
} }
public class PersonService(IUnitOfWork unitOfWork): IPersonService public class PersonService(IUnitOfWork unitOfWork): IPersonService
@ -84,4 +96,32 @@ public class PersonService(IUnitOfWork unitOfWork): IPersonService
unitOfWork.PersonRepository.Update(dst); unitOfWork.PersonRepository.Update(dst);
await unitOfWork.CommitAsync(); await unitOfWork.CommitAsync();
} }
public async Task<bool> UpdatePersonAliasesAsync(Person person, IList<string> aliases)
{
var normalizedAliases = aliases
.Select(a => a.ToNormalized().Trim())
.Where(a => !string.IsNullOrWhiteSpace(a))
.Where(a => a != person.NormalizedName)
.ToList();
if (normalizedAliases.Count == 0)
{
person.Aliases = [];
return true;
}
var others = await unitOfWork.PersonRepository.GetPeopleByNames(normalizedAliases);
others = others.Where(p => p.Id != person.Id).ToList();
if (others.Count != 0) return false;
person.Aliases = aliases.Select(a => new PersonAlias
{
Alias = a.Trim(),
NormalizedAlias = a.Trim().ToNormalized()
}).ToList();
return true;
}
} }

View file

@ -21,6 +21,7 @@ export enum PersonRole {
export interface Person extends IHasCover { export interface Person extends IHasCover {
id: number; id: number;
name: string; name: string;
aliases: string[];
description: string; description: string;
coverImage?: string; coverImage?: string;
coverImageLocked: boolean; coverImageLocked: boolean;

View file

@ -11,6 +11,7 @@ import {BrowsePerson} from "../_models/person/browse-person";
import {Chapter} from "../_models/chapter"; import {Chapter} from "../_models/chapter";
import {StandaloneChapter} from "../_models/standalone-chapter"; import {StandaloneChapter} from "../_models/standalone-chapter";
import {TextResonse} from "../_types/text-response"; import {TextResonse} from "../_types/text-response";
import {al} from "@angular/router/router_module.d-6zbCxc1T";
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -55,4 +56,9 @@ export class PersonService {
downloadCover(personId: number) { downloadCover(personId: number) {
return this.httpClient.post<string>(this.baseUrl + 'person/fetch-cover?personId=' + personId, {}, TextResonse); 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/alias/${personId}/${alias}`);
}
} }

View file

@ -483,7 +483,7 @@ export class EditChapterModalComponent implements OnInit {
}; };
personSettings.addTransformFn = ((title: string) => { 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 + ''); personSettings.trackByIdentityFn = (index, value) => value.name + (value.id + '');

View file

@ -521,7 +521,7 @@ export class EditSeriesModalComponent implements OnInit {
}; };
personSettings.addTransformFn = ((title: string) => { 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 + ''); personSettings.trackByIdentityFn = (index, value) => value.name + (value.id + '');

View file

@ -96,6 +96,17 @@
</ng-template> </ng-template>
</li> </li>
<li [ngbNavItem]="TabID.Aliases">
<a ngbNavLink>{{t(TabID.Aliases)}}</a>
<ng-template ngbNavContent>
<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"> <li [ngbNavItem]="TabID.CoverImage">
<a ngbNavLink>{{t(TabID.CoverImage)}}</a> <a ngbNavLink>{{t(TabID.CoverImage)}}</a>

View file

@ -1,6 +1,13 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core'; import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core';
import {Breakpoint, UtilityService} from "../../../shared/_services/utility.service"; 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 {Person} from "../../../_models/metadata/person";
import { import {
NgbActiveModal, NgbActiveModal,
@ -14,14 +21,17 @@ import {
import {PersonService} from "../../../_services/person.service"; import {PersonService} from "../../../_services/person.service";
import {translate, TranslocoDirective} from '@jsverse/transloco'; import {translate, TranslocoDirective} from '@jsverse/transloco';
import {CoverImageChooserComponent} from "../../../cards/cover-image-chooser/cover-image-chooser.component"; 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 {UploadService} from "../../../_services/upload.service";
import {SettingItemComponent} from "../../../settings/_components/setting-item/setting-item.component"; import {SettingItemComponent} from "../../../settings/_components/setting-item/setting-item.component";
import {AccountService} from "../../../_services/account.service"; import {AccountService} from "../../../_services/account.service";
import {ToastrService} from "ngx-toastr"; import {ToastrService} from "ngx-toastr";
import {EditListComponent} from "../../../shared/edit-list/edit-list.component";
import {al} from "@angular/router/router_module.d-6zbCxc1T";
enum TabID { enum TabID {
General = 'general-tab', General = 'general-tab',
Aliases = 'aliases-tab',
CoverImage = 'cover-image-tab', CoverImage = 'cover-image-tab',
} }
@ -37,7 +47,8 @@ enum TabID {
NgbNavOutlet, NgbNavOutlet,
CoverImageChooserComponent, CoverImageChooserComponent,
SettingItemComponent, SettingItemComponent,
NgbNavLink NgbNavLink,
EditListComponent
], ],
templateUrl: './edit-person-modal.component.html', templateUrl: './edit-person-modal.component.html',
styleUrl: './edit-person-modal.component.scss', styleUrl: './edit-person-modal.component.scss',
@ -110,6 +121,7 @@ export class EditPersonModalComponent implements OnInit {
id: this.person.id, id: this.person.id,
coverImageLocked: this.person.coverImageLocked, coverImageLocked: this.person.coverImageLocked,
name: this.editForm.get('name')!.value || '', name: this.editForm.get('name')!.value || '',
aliases: this.person.aliases,
description: this.editForm.get('description')!.value || '', description: this.editForm.get('description')!.value || '',
asin: this.editForm.get('asin')!.value || '', asin: this.editForm.get('asin')!.value || '',
// @ts-ignore // @ts-ignore
@ -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;
}));
}
}
} }

View file

@ -43,7 +43,11 @@
<div class="col-xl-10 col-lg-7 col-md-12 col-xs-12 col-sm-12 mt-2"> <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"> <div class="row g-0 mt-2">
<app-read-more [text]="person.description || defaultSummaryText"></app-read-more> @if (person.aliases.length > 0) {
<!-- I can't UI, help! -->
<span>{{t('aka')}} {{person.aliases.join(", ")}}</span>
}
<app-read-more [text]="person.description || t('no-info')"></app-read-more>
@if (roles$ | async; as roles) { @if (roles$ | async; as roles) {
<div class="mt-1"> <div class="mt-1">

View file

@ -88,7 +88,6 @@ export class PersonDetailComponent {
roles$: Observable<PersonRole[]> | null = null; roles$: Observable<PersonRole[]> | null = null;
roles: PersonRole[] | null = null; roles: PersonRole[] | null = null;
works$: Observable<Series[]> | null = null; works$: Observable<Series[]> | null = null;
defaultSummaryText = 'No information about this Person';
filter: SeriesFilterV2 | null = null; filter: SeriesFilterV2 | null = null;
personActions: Array<ActionItem<Person>> = this.actionService.getPersonActions(this.handleAction.bind(this)); personActions: Array<ActionItem<Person>> = this.actionService.getPersonActions(this.handleAction.bind(this));
chaptersByRole: any = {}; chaptersByRole: any = {};

View file

@ -12,6 +12,13 @@
[formControlName]="i" [formControlName]="i"
id="item--{{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> </div>
<div class="col-lg-2"> <div class="col-lg-2">

View file

@ -9,7 +9,7 @@ import {
OnInit, OnInit,
Output Output
} from '@angular/core'; } 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 {TranslocoDirective} from "@jsverse/transloco";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {debounceTime, distinctUntilChanged, tap} from "rxjs/operators"; 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}) items: Array<string> = [];
@Input({required: true}) label = ''; @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>>(); @Output() updateItems = new EventEmitter<Array<string>>();
form: FormGroup = new FormGroup({items: new FormArray([])}); form: FormGroup = new FormGroup({items: new FormArray([])});
@ -51,7 +55,7 @@ export class EditListComponent implements OnInit {
} }
createItemControl(value: string = ''): FormControl { createItemControl(value: string = ''): FormControl {
return new FormControl(value, []); return new FormControl(value, this.validators, this.asyncValidators);
} }
add() { add() {

View file

@ -1103,12 +1103,14 @@
}, },
"person-detail": { "person-detail": {
"aka": "Also known as ",
"known-for-title": "Known For", "known-for-title": "Known For",
"individual-role-title": "As a {{role}}", "individual-role-title": "As a {{role}}",
"browse-person-title": "All Works of {{name}}", "browse-person-title": "All Works of {{name}}",
"browse-person-by-role-title": "All Works of {{name}} as a {{role}}", "browse-person-by-role-title": "All Works of {{name}} as a {{role}}",
"all-roles": "Roles", "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": { "library-settings-modal": {
@ -2246,6 +2248,7 @@
"title": "{{personName}} Details", "title": "{{personName}} Details",
"general-tab": "{{edit-series-modal.general-tab}}", "general-tab": "{{edit-series-modal.general-tab}}",
"cover-image-tab": "{{edit-series-modal.cover-image-tab}}", "cover-image-tab": "{{edit-series-modal.cover-image-tab}}",
"aliases-tab": "Aliases",
"loading": "{{common.loading}}", "loading": "{{common.loading}}",
"close": "{{common.close}}", "close": "{{common.close}}",
"name-label": "{{edit-series-modal.name-label}}", "name-label": "{{edit-series-modal.name-label}}",
@ -2263,7 +2266,9 @@
"cover-image-description": "{{edit-series-modal.cover-image-description}}", "cover-image-description": "{{edit-series-modal.cover-image-description}}",
"cover-image-description-extra": "Alternatively you can download a cover from CoversDB if available.", "cover-image-description-extra": "Alternatively you can download a cover from CoversDB if available.",
"save": "{{common.save}}", "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, consider merging them."
}, },
"day-breakdown": { "day-breakdown": {