Implement merging of performers

This commit is contained in:
sezzim 2025-05-31 21:49:35 -07:00
parent 15bf28d5be
commit eeb97d55e2
17 changed files with 1129 additions and 57 deletions

View file

@ -366,6 +366,7 @@ type Mutation {
performerDestroy(input: PerformerDestroyInput!): Boolean!
performersDestroy(ids: [ID!]!): Boolean!
bulkPerformerUpdate(input: BulkPerformerUpdateInput!): [Performer!]
performerMerge(input: PerformerMergeInput!): Performer
studioCreate(input: StudioCreateInput!): Studio
studioUpdate(input: StudioUpdateInput!): Studio

View file

@ -185,3 +185,10 @@ type FindPerformersResultType {
count: Int!
performers: [Performer!]!
}
input PerformerMergeInput {
source: [ID!]!
destination: ID!
# values defined here will override values in the destination
values: PerformerUpdateInput
}

View file

@ -2,12 +2,15 @@ package api
import (
"context"
"errors"
"fmt"
"slices"
"strconv"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/performer"
"github.com/stashapp/stash/pkg/plugin/hook"
"github.com/stashapp/stash/pkg/sliceutil"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
"github.com/stashapp/stash/pkg/utils"
)
@ -135,7 +138,7 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
return r.getPerformer(ctx, newPerformer.ID)
}
func (r *mutationResolver) validateNoLegacyURLs(translator changesetTranslator) error {
func validateNoLegacyURLs(translator changesetTranslator) error {
// ensure url/twitter/instagram are not included in the input
if translator.hasField("url") {
return fmt.Errorf("url field must not be included if urls is included")
@ -150,7 +153,7 @@ func (r *mutationResolver) validateNoLegacyURLs(translator changesetTranslator)
return nil
}
func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int, legacyURL, legacyTwitter, legacyInstagram models.OptionalString, updatedPerformer *models.PerformerPartial) error {
func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int, legacyURLs *LegacyURLs, updatedPerformer *models.PerformerPartial) error {
qb := r.repository.Performer
// we need to be careful with URL/Twitter/Instagram
@ -169,23 +172,23 @@ func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int
existingURLs := p.URLs.List()
// performer partial URLs should be empty
if legacyURL.Set {
if legacyURLs.URL.Set {
replaced := false
for i, url := range existingURLs {
if !performer.IsTwitterURL(url) && !performer.IsInstagramURL(url) {
existingURLs[i] = legacyURL.Value
existingURLs[i] = legacyURLs.URL.Value
replaced = true
break
}
}
if !replaced {
existingURLs = append(existingURLs, legacyURL.Value)
existingURLs = append(existingURLs, legacyURLs.URL.Value)
}
}
if legacyTwitter.Set {
value := utils.URLFromHandle(legacyTwitter.Value, twitterURL)
if legacyURLs.Twitter.Set {
value := utils.URLFromHandle(legacyURLs.Twitter.Value, twitterURL)
found := false
// find and replace the first twitter URL
for i, url := range existingURLs {
@ -200,9 +203,9 @@ func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int
existingURLs = append(existingURLs, value)
}
}
if legacyInstagram.Set {
if legacyURLs.Instagram.Set {
found := false
value := utils.URLFromHandle(legacyInstagram.Value, instagramURL)
value := utils.URLFromHandle(legacyURLs.Instagram.Value, instagramURL)
// find and replace the first instagram URL
for i, url := range existingURLs {
if performer.IsInstagramURL(url) {
@ -225,16 +228,17 @@ func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int
return nil
}
func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.PerformerUpdateInput) (*models.Performer, error) {
performerID, err := strconv.Atoi(input.ID)
if err != nil {
return nil, fmt.Errorf("converting id: %w", err)
}
type LegacyURLs struct {
URL models.OptionalString
Twitter models.OptionalString
Instagram models.OptionalString
}
translator := changesetTranslator{
inputMap: getUpdateInputMap(ctx),
}
func (u *LegacyURLs) AnySet() bool {
return u.URL.Set || u.Twitter.Set || u.Instagram.Set
}
func performerPartialFromInput(input models.PerformerUpdateInput, translator changesetTranslator) (*models.PerformerPartial, *LegacyURLs, error) {
// Populate performer from the input
updatedPerformer := models.NewPerformerPartial()
@ -259,26 +263,30 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
updatedPerformer.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag")
updatedPerformer.StashIDs = translator.updateStashIDs(input.StashIds, "stash_ids")
var err error
if translator.hasField("urls") {
// ensure url/twitter/instagram are not included in the input
if err := r.validateNoLegacyURLs(translator); err != nil {
return nil, err
if err := validateNoLegacyURLs(translator); err != nil {
return nil, nil, err
}
updatedPerformer.URLs = translator.updateStrings(input.Urls, "urls")
}
legacyURL := translator.optionalString(input.URL, "url")
legacyTwitter := translator.optionalString(input.Twitter, "twitter")
legacyInstagram := translator.optionalString(input.Instagram, "instagram")
var legacyURLs = LegacyURLs{
URL: translator.optionalString(input.URL, "url"),
Twitter: translator.optionalString(input.Twitter, "twitter"),
Instagram: translator.optionalString(input.Instagram, "instagram"),
}
updatedPerformer.Birthdate, err = translator.optionalDate(input.Birthdate, "birthdate")
if err != nil {
return nil, fmt.Errorf("converting birthdate: %w", err)
return nil, nil, fmt.Errorf("converting birthdate: %w", err)
}
updatedPerformer.DeathDate, err = translator.optionalDate(input.DeathDate, "death_date")
if err != nil {
return nil, fmt.Errorf("converting death date: %w", err)
return nil, nil, fmt.Errorf("converting death date: %w", err)
}
// prefer height_cm over height
@ -293,7 +301,7 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
updatedPerformer.TagIDs, err = translator.updateIds(input.TagIds, "tag_ids")
if err != nil {
return nil, fmt.Errorf("converting tag ids: %w", err)
return nil, nil, fmt.Errorf("converting tag ids: %w", err)
}
updatedPerformer.CustomFields = input.CustomFields
@ -301,6 +309,24 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
updatedPerformer.CustomFields.Full = convertMapJSONNumbers(updatedPerformer.CustomFields.Full)
updatedPerformer.CustomFields.Partial = convertMapJSONNumbers(updatedPerformer.CustomFields.Partial)
return &updatedPerformer, &legacyURLs, nil
}
func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.PerformerUpdateInput) (*models.Performer, error) {
performerID, err := strconv.Atoi(input.ID)
if err != nil {
return nil, fmt.Errorf("converting id: %w", err)
}
translator := changesetTranslator{
inputMap: getUpdateInputMap(ctx),
}
updatedPerformer, legacyURLs, err := performerPartialFromInput(input, translator)
if err != nil {
return nil, err
}
var imageData []byte
imageIncluded := translator.hasField("image")
if input.Image != nil {
@ -314,8 +340,8 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Performer
if legacyURL.Set || legacyTwitter.Set || legacyInstagram.Set {
if err := r.handleLegacyURLs(ctx, performerID, legacyURL, legacyTwitter, legacyInstagram, &updatedPerformer); err != nil {
if legacyURLs.AnySet() {
if err := r.handleLegacyURLs(ctx, performerID, legacyURLs, updatedPerformer); err != nil {
return err
}
}
@ -381,16 +407,18 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe
if translator.hasField("urls") {
// ensure url/twitter/instagram are not included in the input
if err := r.validateNoLegacyURLs(translator); err != nil {
if err := validateNoLegacyURLs(translator); err != nil {
return nil, err
}
updatedPerformer.URLs = translator.updateStringsBulk(input.Urls, "urls")
}
legacyURL := translator.optionalString(input.URL, "url")
legacyTwitter := translator.optionalString(input.Twitter, "twitter")
legacyInstagram := translator.optionalString(input.Instagram, "instagram")
var legacyURLs = LegacyURLs{
URL: translator.optionalString(input.URL, "url"),
Twitter: translator.optionalString(input.Twitter, "twitter"),
Instagram: translator.optionalString(input.Instagram, "instagram"),
}
updatedPerformer.Birthdate, err = translator.optionalDate(input.Birthdate, "birthdate")
if err != nil {
@ -423,17 +451,17 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe
qb := r.repository.Performer
for _, performerID := range performerIDs {
if legacyURL.Set || legacyTwitter.Set || legacyInstagram.Set {
if err := r.handleLegacyURLs(ctx, performerID, legacyURL, legacyTwitter, legacyInstagram, &updatedPerformer); err != nil {
if legacyURLs.AnySet() {
if err := r.handleLegacyURLs(ctx, performerID, &legacyURLs, &updatedPerformer); err != nil {
return err
}
}
if err := performer.ValidateUpdate(ctx, performerID, updatedPerformer, qb); err != nil {
if err := performer.ValidateUpdate(ctx, performerID, &updatedPerformer, qb); err != nil {
return err
}
performer, err := qb.UpdatePartial(ctx, performerID, updatedPerformer)
performer, err := qb.UpdatePartial(ctx, performerID, &updatedPerformer)
if err != nil {
return err
}
@ -504,3 +532,93 @@ func (r *mutationResolver) PerformersDestroy(ctx context.Context, performerIDs [
return true, nil
}
func (r *mutationResolver) PerformerMerge(ctx context.Context, input PerformerMergeInput) (*models.Performer, error) {
srcIDs, err := stringslice.StringSliceToIntSlice(input.Source)
if err != nil {
return nil, fmt.Errorf("converting source ids: %w", err)
}
// ensure source ids are unique
srcIDs = sliceutil.AppendUniques(nil, srcIDs)
destID, err := strconv.Atoi(input.Destination)
if err != nil {
return nil, fmt.Errorf("converting destination id: %w", err)
}
// ensure destination is not in source list
if slices.Contains(srcIDs, destID) {
return nil, errors.New("destination scene cannot be in source list")
}
var values *models.PerformerPartial
var imageData []byte
var legacyURLs *LegacyURLs
if input.Values != nil {
translator := changesetTranslator{
inputMap: getNamedUpdateInputMap(ctx, "input.values"),
}
values, legacyURLs, err = performerPartialFromInput(*input.Values, translator)
if err != nil {
return nil, err
}
if legacyURLs != nil && legacyURLs.AnySet() {
return nil, errors.New("Merging legacy performer URLs is not supported")
}
if input.Values.Image != nil {
var err error
imageData, err = utils.ProcessImageInput(ctx, *input.Values.Image)
if err != nil {
return nil, fmt.Errorf("processing cover image: %w", err)
}
}
} else {
v := models.NewPerformerPartial()
values = &v
}
var dest *models.Performer
if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Performer
dest, err = qb.Find(ctx, destID)
if err != nil {
return fmt.Errorf("finding destination scene ID %d: %w", destID, err)
}
sources, err := qb.FindMany(ctx, srcIDs)
if err != nil {
return fmt.Errorf("finding source scenes: %w", err)
}
for _, src := range sources {
if err := src.LoadRelationships(ctx, qb); err != nil {
return fmt.Errorf("loading performer relationships from %d: %w", src.ID, err)
}
}
if _, err := qb.UpdatePartial(ctx, destID, values); err != nil {
return fmt.Errorf("updating performer: %w", err)
}
if err := qb.Merge(ctx, srcIDs, destID); err != nil {
return fmt.Errorf("merging performers: %w", err)
}
if len(imageData) > 0 {
if err := qb.UpdateImage(ctx, destID, imageData); err != nil {
return err
}
}
return nil
}); err != nil {
return nil, err
}
return dest, nil
}

View file

@ -205,11 +205,11 @@ func (t *StashBoxBatchTagTask) processMatchedPerformer(ctx context.Context, p *m
}
}
if err := performer.ValidateUpdate(ctx, t.performer.ID, partial, qb); err != nil {
if err := performer.ValidateUpdate(ctx, t.performer.ID, &partial, qb); err != nil {
return err
}
if _, err := qb.UpdatePartial(ctx, t.performer.ID, partial); err != nil {
if _, err := qb.UpdatePartial(ctx, t.performer.ID, &partial); err != nil {
return err
}

View file

@ -473,6 +473,20 @@ func (_m *PerformerReaderWriter) HasImage(ctx context.Context, performerID int)
return r0, r1
}
// Merge provides a mock function with given fields: ctx, source, destination
func (_m *PerformerReaderWriter) Merge(ctx context.Context, source []int, destination int) error {
ret := _m.Called(ctx, source, destination)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, []int, int) error); ok {
r0 = rf(ctx, source, destination)
} else {
r0 = ret.Error(0)
}
return r0
}
// Query provides a mock function with given fields: ctx, performerFilter, findFilter
func (_m *PerformerReaderWriter) Query(ctx context.Context, performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) ([]*models.Performer, int, error) {
ret := _m.Called(ctx, performerFilter, findFilter)
@ -576,11 +590,11 @@ func (_m *PerformerReaderWriter) UpdateImage(ctx context.Context, performerID in
}
// UpdatePartial provides a mock function with given fields: ctx, id, updatedPerformer
func (_m *PerformerReaderWriter) UpdatePartial(ctx context.Context, id int, updatedPerformer models.PerformerPartial) (*models.Performer, error) {
func (_m *PerformerReaderWriter) UpdatePartial(ctx context.Context, id int, updatedPerformer *models.PerformerPartial) (*models.Performer, error) {
ret := _m.Called(ctx, id, updatedPerformer)
var r0 *models.Performer
if rf, ok := ret.Get(0).(func(context.Context, int, models.PerformerPartial) *models.Performer); ok {
if rf, ok := ret.Get(0).(func(context.Context, int, *models.PerformerPartial) *models.Performer); ok {
r0 = rf(ctx, id, updatedPerformer)
} else {
if ret.Get(0) != nil {
@ -589,7 +603,7 @@ func (_m *PerformerReaderWriter) UpdatePartial(ctx context.Context, id int, upda
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, int, models.PerformerPartial) error); ok {
if rf, ok := ret.Get(1).(func(context.Context, int, *models.PerformerPartial) error); ok {
r1 = rf(ctx, id, updatedPerformer)
} else {
r1 = ret.Error(1)

View file

@ -49,7 +49,7 @@ type PerformerCreator interface {
// PerformerUpdater provides methods to update performers.
type PerformerUpdater interface {
Update(ctx context.Context, updatedPerformer *UpdatePerformerInput) error
UpdatePartial(ctx context.Context, id int, updatedPerformer PerformerPartial) (*Performer, error)
UpdatePartial(ctx context.Context, id int, updatedPerformer *PerformerPartial) (*Performer, error)
UpdateImage(ctx context.Context, performerID int, image []byte) error
}
@ -92,6 +92,8 @@ type PerformerWriter interface {
PerformerCreator
PerformerUpdater
PerformerDestroyer
Merge(ctx context.Context, source []int, destination int) error
}
// PerformerReaderWriter provides all performer methods.

View file

@ -66,7 +66,7 @@ func ValidateCreate(ctx context.Context, performer models.Performer, qb models.P
return nil
}
func ValidateUpdate(ctx context.Context, id int, partial models.PerformerPartial, qb models.PerformerReader) error {
func ValidateUpdate(ctx context.Context, id int, partial *models.PerformerPartial, qb models.PerformerReader) error {
existing, err := qb.Find(ctx, id)
if err != nil {
return err

View file

@ -138,7 +138,7 @@ type performerRowRecord struct {
updateRecord
}
func (r *performerRowRecord) fromPartial(o models.PerformerPartial) {
func (r *performerRowRecord) fromPartial(o *models.PerformerPartial) {
r.setString("name", o.Name)
r.setNullString("disambiguation", o.Disambiguation)
r.setNullString("gender", o.Gender)
@ -302,7 +302,7 @@ func (qb *PerformerStore) Create(ctx context.Context, newObject *models.CreatePe
return nil
}
func (qb *PerformerStore) UpdatePartial(ctx context.Context, id int, partial models.PerformerPartial) (*models.Performer, error) {
func (qb *PerformerStore) UpdatePartial(ctx context.Context, id int, partial *models.PerformerPartial) (*models.Performer, error) {
r := performerRowRecord{
updateRecord{
Record: make(exp.Record),
@ -864,3 +864,56 @@ func (qb *PerformerStore) FindByStashIDStatus(ctx context.Context, hasStashID bo
return ret, nil
}
func (qb *PerformerStore) Merge(ctx context.Context, source []int, destination int) error {
if len(source) == 0 {
return nil
}
inBinding := getInBinding(len(source))
args := []interface{}{destination}
srcArgs := make([]interface{}, len(source))
for i, id := range source {
if id == destination {
return errors.New("cannot merge where source == destination")
}
srcArgs[i] = id
}
args = append(args, srcArgs...)
performerTables := map[string]string{
performersScenesTable: sceneIDColumn,
performersGalleriesTable: galleryIDColumn,
performersImagesTable: imageIDColumn,
performersTagsTable: tagIDColumn,
}
args = append(args, destination)
for table, idColumn := range performerTables {
_, err := dbWrapper.Exec(ctx, `UPDATE OR IGNORE `+table+`
SET performer_id = ?
WHERE performer_id IN `+inBinding+`
AND NOT EXISTS(SELECT 1 FROM `+table+` o WHERE o.`+idColumn+` = `+table+`.`+idColumn+` AND o.performer_id = ?)`,
args...,
)
if err != nil {
return err
}
// delete source performer ids from the table where they couldn't be set
if _, err := dbWrapper.Exec(ctx, `DELETE FROM `+table+` WHERE performer_id IN `+inBinding, srcArgs...); err != nil {
return err
}
}
for _, id := range source {
err := qb.Destroy(ctx, id)
if err != nil {
return err
}
}
return nil
}

View file

@ -611,7 +611,7 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) {
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
assert := assert.New(t)
got, err := qb.UpdatePartial(ctx, tt.id, tt.partial)
got, err := qb.UpdatePartial(ctx, tt.id, &tt.partial)
if (err != nil) != tt.wantErr {
t.Errorf("PerformerStore.UpdatePartial() error = %v, wantErr %v", err, tt.wantErr)
return
@ -696,7 +696,7 @@ func Test_PerformerStore_UpdatePartialCustomFields(t *testing.T) {
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
assert := assert.New(t)
_, err := qb.UpdatePartial(ctx, tt.id, tt.partial)
_, err := qb.UpdatePartial(ctx, tt.id, &tt.partial)
if err != nil {
t.Errorf("PerformerStore.UpdatePartial() error = %v", err)
return
@ -2092,7 +2092,7 @@ func testPerformerStashIDs(ctx context.Context, t *testing.T, s *models.Performe
// update stash ids and ensure was updated
var err error
s, err = qb.UpdatePartial(ctx, s.ID, models.PerformerPartial{
s, err = qb.UpdatePartial(ctx, s.ID, &models.PerformerPartial{
StashIDs: &models.UpdateStashIDs{
StashIDs: []models.StashID{stashID},
Mode: models.RelationshipUpdateModeSet,
@ -2110,7 +2110,7 @@ func testPerformerStashIDs(ctx context.Context, t *testing.T, s *models.Performe
assert.Equal(t, []models.StashID{stashID}, s.StashIDs.List())
// remove stash ids and ensure was updated
s, err = qb.UpdatePartial(ctx, s.ID, models.PerformerPartial{
s, err = qb.UpdatePartial(ctx, s.ID, &models.PerformerPartial{
StashIDs: &models.UpdateStashIDs{
StashIDs: []models.StashID{stashID},
Mode: models.RelationshipUpdateModeRemove,

View file

@ -23,3 +23,9 @@ mutation PerformerDestroy($id: ID!) {
mutation PerformersDestroy($ids: [ID!]!) {
performersDestroy(ids: $ids)
}
mutation PerformerMerge($input: PerformerMergeInput!) {
performerMerge(input: $input) {
id
}
}

View file

@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useState } from "react";
import { Tabs, Tab, Col, Row } from "react-bootstrap";
import { useIntl } from "react-intl";
import { Button, Tabs, Tab, Col, Row } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import { useHistory, Redirect, RouteComponentProps } from "react-router-dom";
import { Helmet } from "react-helmet";
import cx from "classnames";
@ -28,6 +28,7 @@ import { PerformerGroupsPanel } from "./PerformerGroupsPanel";
import { PerformerImagesPanel } from "./PerformerImagesPanel";
import { PerformerAppearsWithPanel } from "./performerAppearsWithPanel";
import { PerformerEditPanel } from "./PerformerEditPanel";
import { PerformerMergeModal } from "../PerformerMergeDialog";
import { PerformerSubmitButton } from "./PerformerSubmitButton";
import { useRatingKeybinds } from "src/hooks/keybinds";
import { DetailImage } from "src/components/Shared/DetailImage";
@ -248,6 +249,7 @@ const PerformerPage: React.FC<IProps> = PatchComponent(
const [collapsed, setCollapsed] = useState<boolean>(!showAllDetails);
const [isEditing, setIsEditing] = useState<boolean>(false);
const [isMerging, setIsMerging] = useState<boolean>(false);
const [image, setImage] = useState<string | null>();
const [encodingImage, setEncodingImage] = useState<boolean>(false);
const loadStickyHeader = useLoadStickyHeader();
@ -283,6 +285,26 @@ const PerformerPage: React.FC<IProps> = PatchComponent(
}
}
function renderMergeButton() {
return (
<Button variant="secondary" onClick={() => setIsMerging(true)}>
<FormattedMessage id="actions.merge" />
...
</Button>
);
}
function renderMergeDialog() {
if (!performer.id) return;
return (
<PerformerMergeModal
show={isMerging}
onClose={() => setIsMerging(false)}
performers={[performer]}
/>
);
}
useRatingKeybinds(
true,
configuration?.ui.ratingSystemOptions?.type,
@ -462,9 +484,12 @@ const PerformerPage: React.FC<IProps> = PatchComponent(
onImageChange={() => {}}
classNames="mb-2"
customButtons={
<div>
<PerformerSubmitButton performer={performer} />
</div>
<>
{renderMergeButton()}
<div>
<PerformerSubmitButton performer={performer} />
</div>
</>
}
></DetailsEditNavbar>
</Row>
@ -492,6 +517,7 @@ const PerformerPage: React.FC<IProps> = PatchComponent(
</div>
</div>
</div>
{renderMergeDialog()}
</div>
);
}

View file

@ -56,7 +56,7 @@ function renderScrapedGender(
);
}
function renderScrapedGenderRow(
export function renderScrapedGenderRow(
title: string,
result: ScrapeResult<string>,
onChange: (value: ScrapeResult<string>) => void
@ -105,7 +105,7 @@ function renderScrapedCircumcised(
);
}
function renderScrapedCircumcisedRow(
export function renderScrapedCircumcisedRow(
title: string,
result: ScrapeResult<string>,
onChange: (value: ScrapeResult<string>) => void

View file

@ -12,6 +12,7 @@ import {
import { ItemList, ItemListContext, showWhenSelected } from "../List/ItemList";
import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types";
import NavUtils from "src/utils/navigation";
import { PerformerTagger } from "../Tagger/performers/PerformerTagger";
import { ExportDialog } from "../Shared/ExportDialog";
import { DeleteEntityDialog } from "../Shared/DeleteEntityDialog";
@ -21,6 +22,7 @@ import { EditPerformersDialog } from "./EditPerformersDialog";
import { cmToImperial, cmToInches, kgToLbs } from "src/utils/units";
import TextUtils from "src/utils/text";
import { PerformerCardGrid } from "./PerformerCardGrid";
import { PerformerMergeModal } from "./PerformerMergeDialog";
import { View } from "../List/views";
function getItems(result: GQL.FindPerformersQueryResult) {
@ -169,6 +171,9 @@ export const PerformerList: React.FC<IPerformerList> = ({
}) => {
const intl = useIntl();
const history = useHistory();
const [mergePerformers, setMergePerformers] = useState<
GQL.SelectPerformerDataFragment[] | undefined
>(undefined);
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [isExportAll, setIsExportAll] = useState(false);
@ -179,6 +184,11 @@ export const PerformerList: React.FC<IPerformerList> = ({
text: intl.formatMessage({ id: "actions.open_random" }),
onClick: openRandom,
},
{
text: `${intl.formatMessage({ id: "actions.merge" })}…`,
onClick: merge,
isDisplayed: showWhenSelected,
},
{
text: intl.formatMessage({ id: "actions.export" }),
onClick: onExport,
@ -221,6 +231,18 @@ export const PerformerList: React.FC<IPerformerList> = ({
}
}
async function merge(
result: GQL.FindPerformersQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>
) {
const selected =
result.data?.findPerformers.performers.filter((p) =>
selectedIds.has(p.id)
) ?? [];
setMergePerformers(selected);
}
async function onExport() {
setIsExportAll(false);
setIsExportDialogOpen(true);
@ -237,6 +259,23 @@ export const PerformerList: React.FC<IPerformerList> = ({
selectedIds: Set<string>,
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void
) {
function renderMergeDialog() {
if (mergePerformers) {
return (
<PerformerMergeModal
performers={mergePerformers}
onClose={(mergedID?: string) => {
setMergePerformers(undefined);
if (mergedID) {
history.push(NavUtils.makePerformerScenesUrl({ id: mergedID }));
}
}}
show
/>
);
}
}
function maybeRenderPerformerExportDialog() {
if (isExportDialogOpen) {
return (
@ -287,6 +326,7 @@ export const PerformerList: React.FC<IPerformerList> = ({
return (
<>
{renderMergeDialog()}
{maybeRenderPerformerExportDialog()}
{renderPerformers()}
</>

View file

@ -0,0 +1,765 @@
import { Form, Col, Row, Button } from "react-bootstrap";
import React, { useEffect, useMemo, useState } from "react";
import * as GQL from "src/core/generated-graphql";
import { Icon } from "../Shared/Icon";
import { LoadingIndicator } from "../Shared/LoadingIndicator";
import {
circumcisedToString,
stringToCircumcised,
} from "src/utils/circumcised";
import * as FormUtils from "src/utils/form";
import { genderToString, stringToGender } from "src/utils/gender";
import ImageUtils from "src/utils/image";
import {
mutatePerformerMerge,
queryFindPerformer,
queryFindPerformersByID,
} from "src/core/StashService";
import { FormattedMessage, useIntl } from "react-intl";
import { useToast } from "src/hooks/Toast";
import { faExchangeAlt, faSignInAlt } from "@fortawesome/free-solid-svg-icons";
import {
ScrapeDialog,
ScrapedImageRow,
ScrapedInputGroupRow,
ScrapedStringListRow,
ScrapedTextAreaRow,
} from "../Shared/ScrapeDialog/ScrapeDialog";
import { ModalComponent } from "../Shared/Modal";
import { sortStoredIdObjects } from "src/utils/data";
import {
ObjectListScrapeResult,
ScrapeResult,
hasScrapedValues,
} from "../Shared/ScrapeDialog/scrapeResult";
import { ScrapedTagsRow } from "../Shared/ScrapeDialog/ScrapedObjectsRow";
import {
renderScrapedGenderRow,
renderScrapedCircumcisedRow,
} from "./PerformerDetails/PerformerScrapeDialog";
import { PerformerSelect } from "./PerformerSelect";
type MergeOptions = {
values: GQL.PerformerUpdateInput;
};
interface IPerformerMergeDetailsProps {
sources: GQL.PerformerDataFragment[];
dest: GQL.PerformerDataFragment;
onClose: (options?: MergeOptions) => void;
}
const PerformerMergeDetails: React.FC<IPerformerMergeDetailsProps> = ({
sources,
dest,
onClose,
}) => {
const intl = useIntl();
const [loading, setLoading] = useState(true);
const [name, setName] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.name)
);
const [disambiguation, setDisambiguation] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.disambiguation)
);
const [aliases, setAliases] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.alias_list?.join(", "))
);
const [birthdate, setBirthdate] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.birthdate)
);
const [deathDate, setDeathDate] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.death_date)
);
const [ethnicity, setEthnicity] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.ethnicity)
);
const [country, setCountry] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.country)
);
const [hairColor, setHairColor] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.hair_color)
);
const [eyeColor, setEyeColor] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.eye_color)
);
const [height, setHeight] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.height_cm?.toString())
);
const [weight, setWeight] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.weight?.toString())
);
const [penisLength, setPenisLength] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.penis_length?.toString())
);
const [measurements, setMeasurements] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.measurements)
);
const [fakeTits, setFakeTits] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.fake_tits)
);
const [careerLength, setCareerLength] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.career_length)
);
const [tattoos, setTattoos] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.tattoos)
);
const [piercings, setPiercings] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.piercings)
);
const [urls, setURLs] = useState<ScrapeResult<string[]>>(
new ScrapeResult<string[]>(dest.urls)
);
const [gender, setGender] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(genderToString(dest.gender))
);
const [circumcised, setCircumcised] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(circumcisedToString(dest.circumcised))
);
const [details, setDetails] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.details)
);
const [tags, setTags] = useState<ObjectListScrapeResult<GQL.ScrapedTag>>(
new ObjectListScrapeResult<GQL.ScrapedTag>(
sortStoredIdObjects(dest.tags.map(idToStoredID))
)
);
const [image, setImage] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.image_path)
);
function idToStoredID(o: { id: string; name: string }) {
return {
stored_id: o.id,
name: o.name,
};
}
// calculate the values for everything
// uses the first set value for single value fields, and combines all
useEffect(() => {
async function loadImages() {
const src = sources.find((s) => s.image_path);
if (!dest.image_path || !src) return;
setLoading(true);
const destData = await ImageUtils.imageToDataURL(dest.image_path);
const srcData = await ImageUtils.imageToDataURL(src.image_path!);
// keep destination image by default
const useNewValue = false;
setImage(new ScrapeResult(destData, srcData, useNewValue));
setLoading(false);
}
setName(
new ScrapeResult(dest.name, sources.find((s) => s.name)?.name, !dest.name)
);
setDisambiguation(
new ScrapeResult(
dest.disambiguation,
sources.find((s) => s.disambiguation)?.disambiguation,
!dest.disambiguation
)
);
setAliases(
new ScrapeResult(
dest.alias_list?.join(", "),
sources.find((s) => s.alias_list)?.alias_list.join(", "),
!dest.alias_list?.length
)
);
setBirthdate(
new ScrapeResult(
dest.birthdate,
sources.find((s) => s.birthdate)?.birthdate,
!dest.birthdate
)
);
setDeathDate(
new ScrapeResult(
dest.death_date,
sources.find((s) => s.death_date)?.death_date,
!dest.death_date
)
);
setEthnicity(
new ScrapeResult(
dest.ethnicity,
sources.find((s) => s.ethnicity)?.ethnicity,
!dest.ethnicity
)
);
setCountry(
new ScrapeResult(
dest.country,
sources.find((s) => s.country)?.country,
!dest.country
)
);
setHairColor(
new ScrapeResult(
dest.hair_color,
sources.find((s) => s.hair_color)?.hair_color,
!dest.hair_color
)
);
setEyeColor(
new ScrapeResult(
dest.eye_color,
sources.find((s) => s.eye_color)?.eye_color,
!dest.eye_color
)
);
setHeight(
new ScrapeResult(
dest.height_cm?.toString(),
sources.find((s) => s.height_cm)?.height_cm?.toString(),
!dest.height_cm
)
);
setWeight(
new ScrapeResult(
dest.weight?.toString(),
sources.find((s) => s.weight)?.weight?.toString(),
!dest.weight
)
);
setPenisLength(
new ScrapeResult(
dest.penis_length?.toString(),
sources.find((s) => s.penis_length)?.penis_length?.toString(),
!dest.penis_length
)
);
setMeasurements(
new ScrapeResult(
dest.measurements,
sources.find((s) => s.measurements)?.measurements,
!dest.measurements
)
);
setFakeTits(
new ScrapeResult(
dest.fake_tits,
sources.find((s) => s.fake_tits)?.fake_tits,
!dest.fake_tits
)
);
setCareerLength(
new ScrapeResult(
dest.career_length,
sources.find((s) => s.career_length)?.career_length,
!dest.career_length
)
);
setTattoos(
new ScrapeResult(
dest.tattoos,
sources.find((s) => s.tattoos)?.tattoos,
!dest.tattoos
)
);
setPiercings(
new ScrapeResult(
dest.piercings,
sources.find((s) => s.piercings)?.piercings,
!dest.piercings
)
);
setURLs(
new ScrapeResult(
dest.urls,
sources.find((s) => s.urls)?.urls,
!dest.urls?.length
)
);
setGender(
new ScrapeResult(
genderToString(dest.gender),
sources.find((s) => s.gender)?.gender
? genderToString(sources.find((s) => s.gender)?.gender)
: undefined,
!dest.gender
)
);
setCircumcised(
new ScrapeResult(
circumcisedToString(dest.circumcised),
sources.find((s) => s.circumcised)?.circumcised
? circumcisedToString(sources.find((s) => s.circumcised)?.circumcised)
: undefined,
!dest.circumcised
)
);
setDetails(
new ScrapeResult(
dest.details,
sources.find((s) => s.details)?.details,
!dest.details
)
);
setImage(
new ScrapeResult(
dest.image_path,
sources.find((s) => s.image_path)?.image_path,
!dest.image_path
)
);
loadImages();
}, [sources, dest]);
// ensure this is updated if fields are changed
const hasValues = useMemo(() => {
return hasScrapedValues([
name,
disambiguation,
aliases,
birthdate,
deathDate,
ethnicity,
country,
hairColor,
eyeColor,
height,
weight,
penisLength,
measurements,
fakeTits,
careerLength,
tattoos,
piercings,
urls,
gender,
circumcised,
details,
tags,
image,
]);
}, [
name,
disambiguation,
aliases,
birthdate,
deathDate,
ethnicity,
country,
hairColor,
eyeColor,
height,
weight,
penisLength,
measurements,
fakeTits,
careerLength,
tattoos,
piercings,
urls,
gender,
circumcised,
details,
tags,
image,
]);
function renderScrapeRows() {
if (loading) {
return (
<div>
<LoadingIndicator />
</div>
);
}
if (!hasValues) {
return (
<div>
<FormattedMessage id="dialogs.merge.empty_results" />
</div>
);
}
return (
<>
<ScrapedInputGroupRow
title={intl.formatMessage({ id: "name" })}
result={name}
onChange={(value) => setName(value)}
/>
<ScrapedInputGroupRow
title={intl.formatMessage({ id: "disambiguation" })}
result={disambiguation}
onChange={(value) => setDisambiguation(value)}
/>
<ScrapedTextAreaRow
title={intl.formatMessage({ id: "aliases" })}
result={aliases}
onChange={(value) => setAliases(value)}
/>
<ScrapedInputGroupRow
title={intl.formatMessage({ id: "birthdate" })}
result={birthdate}
onChange={(value) => setBirthdate(value)}
/>
<ScrapedInputGroupRow
title={intl.formatMessage({ id: "death_date" })}
result={deathDate}
onChange={(value) => setDeathDate(value)}
/>
<ScrapedInputGroupRow
title={intl.formatMessage({ id: "ethnicity" })}
result={ethnicity}
onChange={(value) => setEthnicity(value)}
/>
<ScrapedInputGroupRow
title={intl.formatMessage({ id: "country" })}
result={country}
onChange={(value) => setCountry(value)}
/>
<ScrapedInputGroupRow
title={intl.formatMessage({ id: "hair_color" })}
result={hairColor}
onChange={(value) => setHairColor(value)}
/>
<ScrapedInputGroupRow
title={intl.formatMessage({ id: "eye_color" })}
result={eyeColor}
onChange={(value) => setEyeColor(value)}
/>
<ScrapedInputGroupRow
title={intl.formatMessage({ id: "height" })}
result={height}
onChange={(value) => setHeight(value)}
/>
<ScrapedInputGroupRow
title={intl.formatMessage({ id: "weight" })}
result={weight}
onChange={(value) => setWeight(value)}
/>
<ScrapedInputGroupRow
title={intl.formatMessage({ id: "penis_length" })}
result={penisLength}
onChange={(value) => setPenisLength(value)}
/>
<ScrapedInputGroupRow
title={intl.formatMessage({ id: "measurements" })}
result={measurements}
onChange={(value) => setMeasurements(value)}
/>
<ScrapedInputGroupRow
title={intl.formatMessage({ id: "fake_tits" })}
result={fakeTits}
onChange={(value) => setFakeTits(value)}
/>
<ScrapedInputGroupRow
title={intl.formatMessage({ id: "career_length" })}
result={careerLength}
onChange={(value) => setCareerLength(value)}
/>
<ScrapedTextAreaRow
title={intl.formatMessage({ id: "tattoos" })}
result={tattoos}
onChange={(value) => setTattoos(value)}
/>
<ScrapedTextAreaRow
title={intl.formatMessage({ id: "piercings" })}
result={piercings}
onChange={(value) => setPiercings(value)}
/>
<ScrapedStringListRow
title={intl.formatMessage({ id: "urls" })}
result={urls}
onChange={(value) => setURLs(value)}
/>
{renderScrapedGenderRow(
intl.formatMessage({ id: "gender" }),
gender,
(value) => setGender(value)
)}
{renderScrapedCircumcisedRow(
intl.formatMessage({ id: "circumcised" }),
circumcised,
(value) => setCircumcised(value)
)}
<ScrapedTagsRow
title={intl.formatMessage({ id: "tags" })}
result={tags}
onChange={(value) => setTags(value)}
/>
<ScrapedTextAreaRow
title={intl.formatMessage({ id: "details" })}
result={details}
onChange={(value) => setDetails(value)}
/>
<ScrapedImageRow
title={intl.formatMessage({ id: "performer_image" })}
className="performer-image"
result={image}
onChange={(value) => setImage(value)}
/>
</>
);
}
function createValues(): MergeOptions {
// only set the cover image if it's different from the existing cover image
const coverImage = image.useNewValue ? image.getNewValue() : undefined;
return {
values: {
id: dest.id,
name: name.getNewValue(),
disambiguation: disambiguation.getNewValue(),
alias_list: aliases
.getNewValue()
?.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0),
birthdate: birthdate.getNewValue(),
death_date: deathDate.getNewValue(),
ethnicity: ethnicity.getNewValue(),
country: country.getNewValue(),
hair_color: hairColor.getNewValue(),
eye_color: eyeColor.getNewValue(),
height_cm: height.getNewValue()
? parseFloat(height.getNewValue()!)
: undefined,
weight: weight.getNewValue()
? parseFloat(weight.getNewValue()!)
: undefined,
penis_length: penisLength.getNewValue()
? parseFloat(penisLength.getNewValue()!)
: undefined,
measurements: measurements.getNewValue(),
fake_tits: fakeTits.getNewValue(),
career_length: careerLength.getNewValue(),
tattoos: tattoos.getNewValue(),
piercings: piercings.getNewValue(),
urls: urls.getNewValue(),
gender: stringToGender(gender.getNewValue()),
circumcised: stringToCircumcised(circumcised.getNewValue()),
tag_ids: tags.getNewValue()?.map((t) => t.stored_id!),
details: details.getNewValue(),
image: coverImage,
},
};
}
const dialogTitle = intl.formatMessage({
id: "actions.merge",
});
const destinationLabel = !hasValues
? ""
: intl.formatMessage({ id: "dialogs.merge.destination" });
const sourceLabel = !hasValues
? ""
: intl.formatMessage({ id: "dialogs.merge.source" });
return (
<ScrapeDialog
title={dialogTitle}
existingLabel={destinationLabel}
scrapedLabel={sourceLabel}
renderScrapeRows={renderScrapeRows}
onClose={(apply) => {
if (!apply) {
onClose();
} else {
onClose(createValues());
}
}}
/>
);
};
interface IPerformerMergeModalProps {
show: boolean;
onClose: (mergedID?: string) => void;
performers: GQL.SelectPerformerDataFragment[];
}
export const PerformerMergeModal: React.FC<IPerformerMergeModalProps> = ({
show,
onClose,
performers,
}) => {
const [sourcePerformers, setSourcePerformers] = useState<
GQL.SelectPerformerDataFragment[]
>([]);
const [destPerformer, setDestPerformer] = useState<
GQL.SelectPerformerDataFragment[]
>([]);
const [loadedSources, setLoadedSources] = useState<
GQL.PerformerDataFragment[]
>([]);
const [loadedDest, setLoadedDest] = useState<GQL.PerformerDataFragment>();
const [running, setRunning] = useState(false);
const [secondStep, setSecondStep] = useState(false);
const intl = useIntl();
const Toast = useToast();
const title = intl.formatMessage({
id: "actions.merge",
});
useEffect(() => {
if (performers.length > 0) {
// set the first performer as the destination, others as source
setDestPerformer([performers[0]]);
if (performers.length > 1) {
setSourcePerformers(performers.slice(1));
}
}
}, [performers]);
async function loadPerformers() {
const performerIDs = sourcePerformers.map((s) => parseInt(s.id));
performerIDs.push(parseInt(destPerformer[0].id));
const query = await queryFindPerformersByID(performerIDs);
const { performers: loadedPerformers } = query.data.findPerformers;
setLoadedDest(loadedPerformers.find((s) => s.id === destPerformer[0].id));
setLoadedSources(
loadedPerformers.filter((s) => s.id !== destPerformer[0].id)
);
setSecondStep(true);
}
async function onMerge(options: MergeOptions) {
const { values } = options;
try {
setRunning(true);
const result = await mutatePerformerMerge(
destPerformer[0].id,
sourcePerformers.map((s) => s.id),
values
);
if (result.data?.performerMerge) {
Toast.success(intl.formatMessage({ id: "toast.merged_performers" }));
// refetch the performer
await queryFindPerformer(destPerformer[0].id);
onClose(destPerformer[0].id);
}
onClose();
} catch (e) {
Toast.error(e);
} finally {
setRunning(false);
}
}
function canMerge() {
return sourcePerformers.length > 0 && destPerformer.length !== 0;
}
function switchPerformers() {
if (sourcePerformers.length && destPerformer.length) {
const newDest = sourcePerformers[0];
setSourcePerformers([...sourcePerformers.slice(1), destPerformer[0]]);
setDestPerformer([newDest]);
}
}
if (secondStep && destPerformer.length > 0) {
return (
<PerformerMergeDetails
sources={loadedSources}
dest={loadedDest!}
onClose={(values) => {
setSecondStep(false);
if (values) {
onMerge(values);
} else {
onClose();
}
}}
/>
);
}
return (
<ModalComponent
show={show}
header={title}
icon={faSignInAlt}
accept={{
text: intl.formatMessage({ id: "actions.next_action" }),
onClick: () => loadPerformers(),
}}
disabled={!canMerge()}
cancel={{
variant: "secondary",
onClick: () => onClose(),
}}
isRunning={running}
>
<div className="form-container row px-3">
<div className="col-12 col-lg-6 col-xl-12">
<Form.Group controlId="source" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "dialogs.merge.source" }),
labelProps: {
column: true,
sm: 3,
xl: 12,
},
})}
<Col sm={9} xl={12}>
<PerformerSelect
isMulti
onSelect={(items) => setSourcePerformers(items)}
values={sourcePerformers}
menuPortalTarget={document.body}
/>
</Col>
</Form.Group>
<Form.Group
controlId="switch"
as={Row}
className="justify-content-center"
>
<Button
variant="secondary"
onClick={() => switchPerformers()}
disabled={!sourcePerformers.length || !destPerformer.length}
title={intl.formatMessage({ id: "actions.swap" })}
>
<Icon className="fa-fw" icon={faExchangeAlt} />
</Button>
</Form.Group>
<Form.Group controlId="destination" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({
id: "dialogs.merge.destination",
}),
labelProps: {
column: true,
sm: 3,
xl: 12,
},
})}
<Col sm={9} xl={12}>
<PerformerSelect
onSelect={(items) => setDestPerformer(items)}
values={destPerformer}
menuPortalTarget={document.body}
/>
</Col>
</Form.Group>
</div>
</div>
</ModalComponent>
);
};

View file

@ -730,6 +730,7 @@ export const SceneMergeModal: React.FC<ISceneMergeModalProps> = ({
sources={loadedSources}
dest={loadedDest!}
onClose={(values) => {
setSecondStep(false);
if (values) {
onMerge(values);
} else {

View file

@ -343,6 +343,14 @@ export const queryFindPerformers = (filter: ListFilterModel) =>
},
});
export const queryFindPerformersByID = (performerIDs: number[]) =>
client.query<GQL.FindPerformersQuery>({
query: GQL.FindPerformersDocument,
variables: {
performer_ids: performerIDs,
},
});
export const queryFindPerformersByIDForSelect = (performerIDs: string[]) =>
client.query<GQL.FindPerformersForSelectQuery>({
query: GQL.FindPerformersForSelectDocument,
@ -1805,7 +1813,6 @@ export const usePerformerDestroy = () =>
});
evictQueries(cache, [
...performerMutationImpactedQueries,
GQL.FindPerformersDocument, // appears with
GQL.FindGroupsDocument, // filter by performers
GQL.FindSceneMarkersDocument, // filter by performers
]);
@ -1845,13 +1852,44 @@ export const usePerformersDestroy = (
});
evictQueries(cache, [
...performerMutationImpactedQueries,
GQL.FindPerformersDocument, // appears with
GQL.FindGroupsDocument, // filter by performers
GQL.FindSceneMarkersDocument, // filter by performers
]);
},
});
export const mutatePerformerMerge = (
destination: string,
source: string[],
values: GQL.PerformerUpdateInput
) =>
client.mutate<GQL.PerformerMergeMutation>({
mutation: GQL.PerformerMergeDocument,
variables: {
input: {
source,
destination,
values,
},
},
update(cache, result) {
if (!result.data?.performerMerge) return;
for (const id of source) {
const obj = { __typename: "Performer", id };
deleteObject(cache, obj, GQL.FindPerformerDocument);
}
evictTypeFields(cache, performerMutationImpactedTypeFields);
evictQueries(cache, [
...performerMutationImpactedQueries,
GQL.FindGroupsDocument, // filter by performers
GQL.FindSceneMarkersDocument, // filter by performers
GQL.StatsDocument, // performer count
]);
},
});
const studioMutationImpactedTypeFields = {
Studio: ["child_studios"],
};

View file

@ -1515,6 +1515,7 @@
"delete_past_tense": "Deleted {count, plural, one {{singularEntity}} other {{pluralEntity}}}",
"generating_screenshot": "Generating screenshot…",
"image_index_too_large": "Error: Image index is larger than the number of images in the Gallery",
"merged_performers": "Merged performers",
"merged_scenes": "Merged scenes",
"merged_tags": "Merged tags",
"reassign_past_tense": "File reassigned",