diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 8936b8a34..edfdecaac 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -373,6 +373,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 diff --git a/graphql/schema/types/performer.graphql b/graphql/schema/types/performer.graphql index fbb67ce8f..e788b91a8 100644 --- a/graphql/schema/types/performer.graphql +++ b/graphql/schema/types/performer.graphql @@ -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 +} diff --git a/internal/api/resolver_mutation_performer.go b/internal/api/resolver_mutation_performer.go index c54e3ca93..ab9abf6cf 100644 --- a/internal/api/resolver_mutation_performer.go +++ b/internal/api/resolver_mutation_performer.go @@ -2,13 +2,16 @@ package api import ( "context" + "errors" "fmt" + "slices" "strconv" "strings" "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" ) @@ -136,7 +139,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") @@ -151,7 +154,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 legacyPerformerURLs, updatedPerformer *models.PerformerPartial) error { qb := r.repository.Performer // we need to be careful with URL/Twitter/Instagram @@ -170,23 +173,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 { @@ -201,9 +204,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) { @@ -226,16 +229,25 @@ 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 legacyPerformerURLs struct { + URL models.OptionalString + Twitter models.OptionalString + Instagram models.OptionalString +} - translator := changesetTranslator{ - inputMap: getUpdateInputMap(ctx), - } +func (u *legacyPerformerURLs) AnySet() bool { + return u.URL.Set || u.Twitter.Set || u.Instagram.Set +} +func legacyPerformerURLsFromInput(input models.PerformerUpdateInput, translator changesetTranslator) legacyPerformerURLs { + return legacyPerformerURLs{ + URL: translator.optionalString(input.URL, "url"), + Twitter: translator.optionalString(input.Twitter, "twitter"), + Instagram: translator.optionalString(input.Instagram, "instagram"), + } +} + +func performerPartialFromInput(input models.PerformerUpdateInput, translator changesetTranslator) (*models.PerformerPartial, error) { // Populate performer from the input updatedPerformer := models.NewPerformerPartial() @@ -260,19 +272,17 @@ 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 { + if err := validateNoLegacyURLs(translator); err != nil { return 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") - updatedPerformer.Birthdate, err = translator.optionalDate(input.Birthdate, "birthdate") if err != nil { return nil, fmt.Errorf("converting birthdate: %w", err) @@ -299,6 +309,26 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per updatedPerformer.CustomFields = handleUpdateCustomFields(input.CustomFields) + return &updatedPerformer, 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, err := performerPartialFromInput(input, translator) + if err != nil { + return nil, err + } + + legacyURLs := legacyPerformerURLsFromInput(input, translator) + var imageData []byte imageIncluded := translator.hasField("image") if input.Image != nil { @@ -312,17 +342,17 @@ 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 } } - if err := performer.ValidateUpdate(ctx, performerID, updatedPerformer, qb); err != nil { + if err := performer.ValidateUpdate(ctx, performerID, *updatedPerformer, qb); err != nil { return err } - _, err = qb.UpdatePartial(ctx, performerID, updatedPerformer) + _, err = qb.UpdatePartial(ctx, performerID, *updatedPerformer) if err != nil { return err } @@ -379,16 +409,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") + legacyURLs := legacyPerformerURLs{ + 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 { @@ -425,8 +457,8 @@ 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 } } @@ -506,3 +538,87 @@ 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 performer cannot be in source list") + } + + var values *models.PerformerPartial + var imageData []byte + + if input.Values != nil { + translator := changesetTranslator{ + inputMap: getNamedUpdateInputMap(ctx, "input.values"), + } + + values, err = performerPartialFromInput(*input.Values, translator) + if err != nil { + return nil, err + } + legacyURLs := legacyPerformerURLsFromInput(*input.Values, translator) + if 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 performer ID %d: %w", destID, err) + } + + // ensure source performers exist + if _, err := qb.FindMany(ctx, srcIDs); err != nil { + return fmt.Errorf("finding source performers: %w", 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 +} diff --git a/internal/api/resolver_mutation_studio.go b/internal/api/resolver_mutation_studio.go index 4b3316111..da3aa1983 100644 --- a/internal/api/resolver_mutation_studio.go +++ b/internal/api/resolver_mutation_studio.go @@ -134,7 +134,7 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio if translator.hasField("urls") { // ensure url not included in the input - if err := r.validateNoLegacyURLs(translator); err != nil { + if err := validateNoLegacyURLs(translator); err != nil { return nil, err } @@ -211,7 +211,7 @@ func (r *mutationResolver) BulkStudioUpdate(ctx context.Context, input BulkStudi 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 } diff --git a/pkg/models/mocks/PerformerReaderWriter.go b/pkg/models/mocks/PerformerReaderWriter.go index dbf19a3cd..6487bc5a5 100644 --- a/pkg/models/mocks/PerformerReaderWriter.go +++ b/pkg/models/mocks/PerformerReaderWriter.go @@ -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) diff --git a/pkg/models/repository_performer.go b/pkg/models/repository_performer.go index ad0b61da0..175208c9d 100644 --- a/pkg/models/repository_performer.go +++ b/pkg/models/repository_performer.go @@ -92,6 +92,8 @@ type PerformerWriter interface { PerformerCreator PerformerUpdater PerformerDestroyer + + Merge(ctx context.Context, source []int, destination int) error } // PerformerReaderWriter provides all performer methods. diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index bf6b780b2..4e06b5b29 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -893,3 +893,58 @@ 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 each table, update source performer ids to destination performer id, ignoring duplicates + 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 +} diff --git a/pkg/sqlite/performer_test.go b/pkg/sqlite/performer_test.go index 190d80e31..a88166657 100644 --- a/pkg/sqlite/performer_test.go +++ b/pkg/sqlite/performer_test.go @@ -2524,6 +2524,146 @@ func TestPerformerStore_FindByStashIDStatus(t *testing.T) { } } +func TestPerformerMerge(t *testing.T) { + tests := []struct { + name string + srcIdxs []int + destIdx int + wantErr bool + }{ + { + name: "merge into self", + srcIdxs: []int{performerIdx1WithDupName}, + destIdx: performerIdx1WithDupName, + wantErr: true, + }, + { + name: "merge multiple", + srcIdxs: []int{ + performerIdx2WithScene, + performerIdxWithTwoScenes, + performerIdx1WithImage, + performerIdxWithTwoImages, + performerIdxWithGallery, + performerIdxWithTwoGalleries, + performerIdxWithTag, + performerIdxWithTwoTags, + }, + destIdx: tagIdxWithPerformer, + wantErr: false, + }, + } + + qb := db.Performer + + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + // load src tag ids to compare after merge + performerTagIds := make(map[int][]int) + for _, srcIdx := range tt.srcIdxs { + srcPerformer, err := qb.Find(ctx, performerIDs[srcIdx]) + if err != nil { + t.Errorf("Error finding performer: %s", err.Error()) + } + if err := srcPerformer.LoadTagIDs(ctx, qb); err != nil { + t.Errorf("Error loading performer tag IDs: %s", err.Error()) + } + srcTagIDs := srcPerformer.TagIDs.List() + performerTagIds[srcIdx] = srcTagIDs + } + + err := qb.Merge(ctx, indexesToIDs(tagIDs, tt.srcIdxs), tagIDs[tt.destIdx]) + + if (err != nil) != tt.wantErr { + t.Errorf("PerformerStore.Merge() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if err != nil { + return + } + + // ensure source performers are destroyed + for _, srcIdx := range tt.srcIdxs { + p, err := qb.Find(ctx, performerIDs[srcIdx]) + + // not found returns nil performer and nil error + if err != nil { + t.Errorf("Error finding performer: %s", err.Error()) + continue + } + assert.Nil(p) + } + + // ensure items point to new performer + for _, srcIdx := range tt.srcIdxs { + sceneIdxs := scenePerformers.reverseLookup(srcIdx) + for _, sceneIdx := range sceneIdxs { + s, err := db.Scene.Find(ctx, sceneIDs[sceneIdx]) + if err != nil { + t.Errorf("Error finding scene: %s", err.Error()) + } + if err := s.LoadPerformerIDs(ctx, db.Scene); err != nil { + t.Errorf("Error loading scene performer IDs: %s", err.Error()) + } + scenePerformerIDs := s.PerformerIDs.List() + + assert.Contains(scenePerformerIDs, performerIDs[tt.destIdx]) + assert.NotContains(scenePerformerIDs, performerIDs[srcIdx]) + } + + imageIdxs := imagePerformers.reverseLookup(srcIdx) + for _, imageIdx := range imageIdxs { + i, err := db.Image.Find(ctx, imageIDs[imageIdx]) + if err != nil { + t.Errorf("Error finding image: %s", err.Error()) + } + if err := i.LoadPerformerIDs(ctx, db.Image); err != nil { + t.Errorf("Error loading image performer IDs: %s", err.Error()) + } + imagePerformerIDs := i.PerformerIDs.List() + + assert.Contains(imagePerformerIDs, performerIDs[tt.destIdx]) + assert.NotContains(imagePerformerIDs, performerIDs[srcIdx]) + } + + galleryIdxs := galleryPerformers.reverseLookup(srcIdx) + for _, galleryIdx := range galleryIdxs { + g, err := db.Gallery.Find(ctx, galleryIDs[galleryIdx]) + if err != nil { + t.Errorf("Error finding gallery: %s", err.Error()) + } + if err := g.LoadPerformerIDs(ctx, db.Gallery); err != nil { + t.Errorf("Error loading gallery performer IDs: %s", err.Error()) + } + galleryPerformerIDs := g.PerformerIDs.List() + + assert.Contains(galleryPerformerIDs, performerIDs[tt.destIdx]) + assert.NotContains(galleryPerformerIDs, performerIDs[srcIdx]) + } + } + + // ensure tags were merged + destPerformer, err := qb.Find(ctx, performerIDs[tt.destIdx]) + if err != nil { + t.Errorf("Error finding performer: %s", err.Error()) + } + if err := destPerformer.LoadTagIDs(ctx, qb); err != nil { + t.Errorf("Error loading performer tag IDs: %s", err.Error()) + } + destTagIDs := destPerformer.TagIDs.List() + + for _, srcIdx := range tt.srcIdxs { + for _, tagID := range performerTagIds[srcIdx] { + assert.Contains(destTagIDs, tagID) + } + } + }) + } +} + // TODO Update // TODO Destroy // TODO Find diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index 977ac0433..dd730c62c 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -859,6 +859,8 @@ func (qb *TagStore) Merge(ctx context.Context, source []int, destination int) er } args = append(args, destination) + + // for each table, update source tag ids to destination tag id, ignoring duplicates for table, idColumn := range tagTables { _, err := dbWrapper.Exec(ctx, `UPDATE OR IGNORE `+table+` SET tag_id = ? diff --git a/ui/v2.5/graphql/data/tag.graphql b/ui/v2.5/graphql/data/tag.graphql index 4b0c0aef9..e640af0c9 100644 --- a/ui/v2.5/graphql/data/tag.graphql +++ b/ui/v2.5/graphql/data/tag.graphql @@ -67,6 +67,11 @@ fragment TagListData on Tag { aliases ignore_auto_tag favorite + stash_ids { + endpoint + stash_id + updated_at + } image_path # Direct counts only - no recursive depth queries scene_count diff --git a/ui/v2.5/graphql/mutations/performer.graphql b/ui/v2.5/graphql/mutations/performer.graphql index a4fa341ed..2082281fc 100644 --- a/ui/v2.5/graphql/mutations/performer.graphql +++ b/ui/v2.5/graphql/mutations/performer.graphql @@ -23,3 +23,9 @@ mutation PerformerDestroy($id: ID!) { mutation PerformersDestroy($ids: [ID!]!) { performersDestroy(ids: $ids) } + +mutation PerformerMerge($input: PerformerMergeInput!) { + performerMerge(input: $input) { + id + } +} diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx index dd72d0025..92a563a81 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx @@ -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"; @@ -250,6 +251,7 @@ const PerformerPage: React.FC = PatchComponent( const [collapsed, setCollapsed] = useState(!showAllDetails); const [isEditing, setIsEditing] = useState(false); + const [isMerging, setIsMerging] = useState(false); const [image, setImage] = useState(); const [encodingImage, setEncodingImage] = useState(false); const loadStickyHeader = useLoadStickyHeader(); @@ -285,6 +287,33 @@ const PerformerPage: React.FC = PatchComponent( } } + function renderMergeButton() { + return ( + + ); + } + + function renderMergeDialog() { + if (!performer.id) return; + return ( + { + setIsMerging(false); + if (mergedId !== undefined && mergedId !== performer.id) { + // By default, the merge destination is the current performer, but + // the user can change it, in which case we need to redirect. + history.replace(`/performers/${mergedId}`); + } + }} + performers={[performer]} + /> + ); + } + useRatingKeybinds( true, configuration?.ui.ratingSystemOptions?.type, @@ -469,9 +498,12 @@ const PerformerPage: React.FC = PatchComponent( onImageChange={() => {}} classNames="mb-2" customButtons={ -
- -
+ <> + {renderMergeButton()} +
+ +
+ } > @@ -499,6 +531,7 @@ const PerformerPage: React.FC = PatchComponent( + {renderMergeDialog()} ); } diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx index b3ec4bff6..afb57a66e 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx @@ -56,7 +56,7 @@ function renderScrapedGender( ); } -function renderScrapedGenderRow( +export function renderScrapedGenderRow( title: string, result: ScrapeResult, onChange: (value: ScrapeResult) => void @@ -104,7 +104,7 @@ function renderScrapedCircumcised( ); } -function renderScrapedCircumcisedRow( +export function renderScrapedCircumcisedRow( title: string, result: ScrapeResult, onChange: (value: ScrapeResult) => void diff --git a/ui/v2.5/src/components/Performers/PerformerList.tsx b/ui/v2.5/src/components/Performers/PerformerList.tsx index b2c6dc36e..ef465fb38 100644 --- a/ui/v2.5/src/components/Performers/PerformerList.tsx +++ b/ui/v2.5/src/components/Performers/PerformerList.tsx @@ -21,6 +21,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"; import { IItemListOperation } from "../List/FilteredListToolbar"; import { PatchComponent } from "src/patch"; @@ -169,6 +170,9 @@ export const PerformerList: React.FC = PatchComponent( ({ filterHook, view, alterQuery, extraCriteria, extraOperations = [] }) => { const intl = useIntl(); const history = useHistory(); + const [mergePerformers, setMergePerformers] = useState< + GQL.SelectPerformerDataFragment[] | undefined + >(undefined); const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); const [isExportAll, setIsExportAll] = useState(false); @@ -180,6 +184,11 @@ export const PerformerList: React.FC = PatchComponent( 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, @@ -222,6 +231,18 @@ export const PerformerList: React.FC = PatchComponent( } } + async function merge( + result: GQL.FindPerformersQueryResult, + filter: ListFilterModel, + selectedIds: Set + ) { + const selected = + result.data?.findPerformers.performers.filter((p) => + selectedIds.has(p.id) + ) ?? []; + setMergePerformers(selected); + } + async function onExport() { setIsExportAll(false); setIsExportDialogOpen(true); @@ -238,6 +259,23 @@ export const PerformerList: React.FC = PatchComponent( selectedIds: Set, onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void ) { + function renderMergeDialog() { + if (mergePerformers) { + return ( + { + setMergePerformers(undefined); + if (mergedId) { + history.push(`/performers/${mergedId}`); + } + }} + show + /> + ); + } + } + function maybeRenderPerformerExportDialog() { if (isExportDialogOpen) { return ( @@ -290,6 +328,7 @@ export const PerformerList: React.FC = PatchComponent( return ( <> + {renderMergeDialog()} {maybeRenderPerformerExportDialog()} {renderPerformers()} diff --git a/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx b/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx new file mode 100644 index 000000000..834d2ac76 --- /dev/null +++ b/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx @@ -0,0 +1,876 @@ +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, + 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 } from "../Shared/ScrapeDialog/ScrapeDialog"; +import { + ScrapedImageRow, + ScrapedInputGroupRow, + ScrapedStringListRow, + ScrapedTextAreaRow, +} from "../Shared/ScrapeDialog/ScrapeDialogRow"; +import { ModalComponent } from "../Shared/Modal"; +import { sortStoredIdObjects } from "src/utils/data"; +import { + ObjectListScrapeResult, + ScrapeResult, + ZeroableScrapeResult, + hasScrapedValues, +} from "../Shared/ScrapeDialog/scrapeResult"; +import { ScrapedTagsRow } from "../Shared/ScrapeDialog/ScrapedObjectsRow"; +import { + renderScrapedGenderRow, + renderScrapedCircumcisedRow, +} from "./PerformerDetails/PerformerScrapeDialog"; +import { PerformerSelect } from "./PerformerSelect"; +import { uniq } from "lodash-es"; + +/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ +type CustomFieldScrapeResults = Map>; + +// There are a bunch of similar functions in PerformerScrapeDialog, but since we don't support +// scraping custom fields, this one is only needed here. The `renderScraped` naming is kept the same +// for consistency. +function renderScrapedCustomFieldRows( + results: CustomFieldScrapeResults, + onChange: (newCustomFields: CustomFieldScrapeResults) => void +) { + return ( + <> + {Array.from(results.entries()).map(([field, result]) => { + const fieldName = `custom_${field}`; + return ( + { + const newResults = new Map(results); + newResults.set(field, newResult); + onChange(newResults); + }} + /> + ); + })} + + ); +} + +type MergeOptions = { + values: GQL.PerformerUpdateInput; +}; + +interface IPerformerMergeDetailsProps { + sources: GQL.PerformerDataFragment[]; + dest: GQL.PerformerDataFragment; + onClose: (options?: MergeOptions) => void; +} + +const PerformerMergeDetails: React.FC = ({ + sources, + dest, + onClose, +}) => { + const intl = useIntl(); + + const [loading, setLoading] = useState(true); + + const [name, setName] = useState>( + new ScrapeResult(dest.name) + ); + const [disambiguation, setDisambiguation] = useState>( + new ScrapeResult(dest.disambiguation) + ); + const [aliases, setAliases] = useState>( + new ScrapeResult(dest.alias_list) + ); + const [birthdate, setBirthdate] = useState>( + new ScrapeResult(dest.birthdate) + ); + const [deathDate, setDeathDate] = useState>( + new ScrapeResult(dest.death_date) + ); + const [ethnicity, setEthnicity] = useState>( + new ScrapeResult(dest.ethnicity) + ); + const [country, setCountry] = useState>( + new ScrapeResult(dest.country) + ); + const [hairColor, setHairColor] = useState>( + new ScrapeResult(dest.hair_color) + ); + const [eyeColor, setEyeColor] = useState>( + new ScrapeResult(dest.eye_color) + ); + const [height, setHeight] = useState>( + new ScrapeResult(dest.height_cm?.toString()) + ); + const [weight, setWeight] = useState>( + new ScrapeResult(dest.weight?.toString()) + ); + const [penisLength, setPenisLength] = useState>( + new ScrapeResult(dest.penis_length?.toString()) + ); + const [measurements, setMeasurements] = useState>( + new ScrapeResult(dest.measurements) + ); + const [fakeTits, setFakeTits] = useState>( + new ScrapeResult(dest.fake_tits) + ); + const [careerLength, setCareerLength] = useState>( + new ScrapeResult(dest.career_length) + ); + const [tattoos, setTattoos] = useState>( + new ScrapeResult(dest.tattoos) + ); + const [piercings, setPiercings] = useState>( + new ScrapeResult(dest.piercings) + ); + const [urls, setURLs] = useState>( + new ScrapeResult(dest.urls) + ); + const [gender, setGender] = useState>( + new ScrapeResult(genderToString(dest.gender)) + ); + const [circumcised, setCircumcised] = useState>( + new ScrapeResult(circumcisedToString(dest.circumcised)) + ); + const [details, setDetails] = useState>( + new ScrapeResult(dest.details) + ); + const [tags, setTags] = useState>( + new ObjectListScrapeResult( + sortStoredIdObjects(dest.tags.map(idToStoredID)) + ) + ); + + const [image, setImage] = useState>( + new ScrapeResult(dest.image_path) + ); + + const [customFields, setCustomFields] = useState( + new Map() + ); + + 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 + ) + ); + + // default alias list should be the existing aliases, plus the names of all sources, + // plus all source aliases, deduplicated + const allAliases = uniq( + dest.alias_list.concat( + sources.map((s) => s.name), + sources.flatMap((s) => s.alias_list) + ) + ); + + setAliases( + new ScrapeResult(dest.alias_list, allAliases, !!allAliases.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 + ) + ); + + const customFieldNames = new Set(Object.keys(dest.custom_fields)); + + for (const s of sources) { + for (const n of Object.keys(s.custom_fields)) { + customFieldNames.add(n); + } + } + + setCustomFields( + new Map( + Array.from(customFieldNames) + .sort() + .map((field) => { + return [ + field, + new ScrapeResult( + dest.custom_fields?.[field], + sources.find((s) => s.custom_fields?.[field])?.custom_fields?.[ + field + ], + dest.custom_fields?.[field] === undefined + ), + ]; + }) + ) + ); + + loadImages(); + }, [sources, dest]); + + const hasCustomFieldValues = useMemo(() => { + return hasScrapedValues(Array.from(customFields.values())); + }, [customFields]); + + // ensure this is updated if fields are changed + const hasValues = useMemo(() => { + return ( + hasCustomFieldValues || + 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, + hasCustomFieldValues, + ]); + + function renderScrapeRows() { + if (loading) { + return ( +
+ +
+ ); + } + + if (!hasValues) { + return ( +
+ +
+ ); + } + + return ( + <> + setName(value)} + /> + setDisambiguation(value)} + /> + setAliases(value)} + /> + setBirthdate(value)} + /> + setDeathDate(value)} + /> + setEthnicity(value)} + /> + setCountry(value)} + /> + setHairColor(value)} + /> + setEyeColor(value)} + /> + setHeight(value)} + /> + setWeight(value)} + /> + setPenisLength(value)} + /> + setMeasurements(value)} + /> + setFakeTits(value)} + /> + setCareerLength(value)} + /> + setTattoos(value)} + /> + setPiercings(value)} + /> + setURLs(value)} + /> + {renderScrapedGenderRow( + intl.formatMessage({ id: "gender" }), + gender, + (value) => setGender(value) + )} + {renderScrapedCircumcisedRow( + intl.formatMessage({ id: "circumcised" }), + circumcised, + (value) => setCircumcised(value) + )} + setTags(value)} + /> + setDetails(value)} + /> + setImage(value)} + /> + {hasCustomFieldValues && + renderScrapedCustomFieldRows(customFields, (newCustomFields) => + setCustomFields(newCustomFields) + )} + + ); + } + + 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() + ?.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, + custom_fields: { + partial: Object.fromEntries( + Array.from(customFields.entries()).flatMap(([field, v]) => + v.useNewValue ? [[field, v.getNewValue()]] : [] + ) + ), + }, + }, + }; + } + + 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 ( + { + if (!apply) { + onClose(); + } else { + onClose(createValues()); + } + }} + > + {renderScrapeRows()} + + ); +}; + +interface IPerformerMergeModalProps { + show: boolean; + onClose: (mergedId?: string) => void; + performers: GQL.SelectPerformerDataFragment[]; +} + +export const PerformerMergeModal: React.FC = ({ + 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(); + + 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" })); + 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 ( + { + setSecondStep(false); + if (values) { + onMerge(values); + } else { + onClose(); + } + }} + /> + ); + } + + return ( + loadPerformers(), + }} + disabled={!canMerge()} + cancel={{ + variant: "secondary", + onClick: () => onClose(), + }} + isRunning={running} + > +
+
+ + {FormUtils.renderLabel({ + title: intl.formatMessage({ id: "dialogs.merge.source" }), + labelProps: { + column: true, + sm: 3, + xl: 12, + }, + })} + + setSourcePerformers(items)} + values={sourcePerformers} + menuPortalTarget={document.body} + /> + + + + + + + {FormUtils.renderLabel({ + title: intl.formatMessage({ + id: "dialogs.merge.destination", + }), + labelProps: { + column: true, + sm: 3, + xl: 12, + }, + })} + + setDestPerformer(items)} + values={destPerformer} + menuPortalTarget={document.body} + /> + + +
+
+
+ ); +}; diff --git a/ui/v2.5/src/components/Performers/styles.scss b/ui/v2.5/src/components/Performers/styles.scss index 1840ad960..c3cebf997 100644 --- a/ui/v2.5/src/components/Performers/styles.scss +++ b/ui/v2.5/src/components/Performers/styles.scss @@ -302,3 +302,11 @@ overflow-y: auto; padding-right: 1.5rem; } + +.performer-merge-dialog .custom-field { + // ensure we don't catch the destination/source labels + & > .form-label, + .form-control { + font-family: "Courier New", Courier, monospace; + } +} diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx index 3615f1327..ee38ebd47 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx @@ -7,7 +7,7 @@ import React, { useLayoutEffect, } from "react"; import { FormattedMessage, useIntl } from "react-intl"; -import { Link, RouteComponentProps } from "react-router-dom"; +import { useHistory, Link, RouteComponentProps } from "react-router-dom"; import { Helmet } from "react-helmet"; import * as GQL from "src/core/generated-graphql"; import { @@ -50,6 +50,7 @@ import { lazyComponent } from "src/utils/lazyComponent"; import cx from "classnames"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { PatchComponent, PatchContainerComponent } from "src/patch"; +import { SceneMergeModal } from "../SceneMergeDialog"; import { goBackOrReplace } from "src/utils/history"; import { FormattedDate } from "src/components/Shared/Date"; @@ -182,6 +183,7 @@ const ScenePage: React.FC = PatchComponent("ScenePage", (props) => { const Toast = useToast(); const intl = useIntl(); + const history = useHistory(); const [updateScene] = useSceneUpdate(); const [generateScreenshot] = useSceneGenerateScreenshot(); const { configuration } = useConfigurationContext(); @@ -205,6 +207,7 @@ const ScenePage: React.FC = PatchComponent("ScenePage", (props) => { const [activeTabKey, setActiveTabKey] = useState("scene-details-panel"); + const [isMerging, setIsMerging] = useState(false); const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); const [isGenerateDialogOpen, setIsGenerateDialogOpen] = useState(false); @@ -347,6 +350,24 @@ const ScenePage: React.FC = PatchComponent("ScenePage", (props) => { } } + function maybeRenderMergeDialog() { + if (!scene.id) return; + return ( + { + setIsMerging(false); + if (mergedId !== undefined && mergedId !== scene.id) { + // By default, the merge destination is the current scene, but + // the user can change it, in which case we need to redirect. + history.replace(`/scenes/${mergedId}`); + } + }} + scenes={[{ id: scene.id, title: objectTitle(scene) }]} + /> + ); + } + function maybeRenderDeleteDialog() { if (isDeleteAlertOpen) { return ( @@ -419,6 +440,14 @@ const ScenePage: React.FC = PatchComponent("ScenePage", (props) => { )} + setIsMerging(true)} + > + + ... + = PatchComponent("ScenePage", (props) => { {title} {maybeRenderSceneGenerateDialog()} + {maybeRenderMergeDialog()} {maybeRenderDeleteDialog()}
= ({ ); if (result.data?.sceneMerge) { Toast.success(intl.formatMessage({ id: "toast.merged_scenes" })); - // refetch the scene - await queryFindScenesByID([parseInt(destScene[0].id)]); onClose(destScene[0].id); } onClose(); @@ -735,6 +733,7 @@ export const SceneMergeModal: React.FC = ({ sources={loadedSources} dest={loadedDest!} onClose={(values) => { + setSecondStep(false); if (values) { onMerge(values); } else { diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialog.tsx b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialog.tsx index 98699cbb6..ecf95541f 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialog.tsx +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialog.tsx @@ -14,6 +14,7 @@ export const ScrapeDialogContext = React.createContext({}); interface IScrapeDialogProps { + className?: string; title: string; existingLabel?: React.ReactNode; scrapedLabel?: React.ReactNode; @@ -68,7 +69,9 @@ export const ScrapeDialog: React.FC< }} modalProps={{ size: "lg", - dialogClassName: `scrape-dialog ${sfwContentMode ? "sfw-mode" : ""}`, + dialogClassName: `${props.className ?? ""} scrape-dialog ${ + sfwContentMode ? "sfw-mode" : "" + }`, }} >
diff --git a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx index e0bc11e37..76442b639 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx @@ -1,4 +1,4 @@ -import { Tabs, Tab, Dropdown, Form } from "react-bootstrap"; +import { Button, Tabs, Tab, Form } from "react-bootstrap"; import React, { useEffect, useMemo, useState } from "react"; import { useHistory, Redirect, RouteComponentProps } from "react-router-dom"; import { FormattedMessage, useIntl } from "react-intl"; @@ -17,7 +17,6 @@ import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar"; import { ErrorMessage } from "src/components/Shared/ErrorMessage"; import { ModalComponent } from "src/components/Shared/Modal"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; -import { Icon } from "src/components/Shared/Icon"; import { useToast } from "src/hooks/Toast"; import { useConfigurationContext } from "src/hooks/Config"; import { tagRelationHook } from "src/core/tags"; @@ -29,12 +28,8 @@ import { TagStudiosPanel } from "./TagStudiosPanel"; import { TagGalleriesPanel } from "./TagGalleriesPanel"; import { CompressedTagDetailsPanel, TagDetailsPanel } from "./TagDetailsPanel"; import { TagEditPanel } from "./TagEditPanel"; -import { TagMergeModal } from "./TagMergeDialog"; -import { - faSignInAlt, - faSignOutAlt, - faTrashAlt, -} from "@fortawesome/free-solid-svg-icons"; +import { TagMergeModal } from "../TagMergeDialog"; +import { faTrashAlt } from "@fortawesome/free-solid-svg-icons"; import { DetailImage } from "src/components/Shared/DetailImage"; import { useLoadStickyHeader } from "src/hooks/detailsPanel"; import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; @@ -306,7 +301,7 @@ const TagPage: React.FC = ({ tag, tabKey }) => { // Editing state const [isEditing, setIsEditing] = useState(false); const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); - const [mergeType, setMergeType] = useState<"from" | "into" | undefined>(); + const [isMerging, setIsMerging] = useState(false); // Editing tag state const [image, setImage] = useState(); @@ -461,41 +456,27 @@ const TagPage: React.FC = ({ tag, tabKey }) => { function renderMergeButton() { return ( - - - - ... - - - setMergeType("from")} - > - - - ... - - setMergeType("into")} - > - - - ... - - - + ); } function renderMergeDialog() { - if (!tag || !mergeType) return; + if (!tag.id) return; return ( setMergeType(undefined)} - show={!!mergeType} - mergeType={mergeType} + show={isMerging} + onClose={(mergedId) => { + setIsMerging(false); + if (mergedId !== undefined && mergedId !== tag.id) { + // By default, the merge destination is the current tag, but + // the user can change it, in which case we need to redirect. + history.replace(`/tags/${mergedId}`); + } + }} + tags={[tag]} /> ); } diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagMergeDialog.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagMergeDialog.tsx deleted file mode 100644 index d6ed87c41..000000000 --- a/ui/v2.5/src/components/Tags/TagDetails/TagMergeDialog.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import { Form, Col, Row } from "react-bootstrap"; -import React, { useState } from "react"; -import * as GQL from "src/core/generated-graphql"; -import { ModalComponent } from "src/components/Shared/Modal"; -import * as FormUtils from "src/utils/form"; -import { useTagsMerge } from "src/core/StashService"; -import { useIntl } from "react-intl"; -import { useToast } from "src/hooks/Toast"; -import { useHistory } from "react-router-dom"; -import { faSignInAlt, faSignOutAlt } from "@fortawesome/free-solid-svg-icons"; -import { Tag, TagSelect } from "../TagSelect"; - -interface ITagMergeModalProps { - show: boolean; - onClose: () => void; - tag: Pick; - mergeType: "from" | "into"; -} - -export const TagMergeModal: React.FC = ({ - show, - onClose, - tag, - mergeType, -}) => { - const [src, setSrc] = useState([]); - const [dest, setDest] = useState(null); - - const [running, setRunning] = useState(false); - - const [mergeTags] = useTagsMerge(); - - const intl = useIntl(); - const Toast = useToast(); - const history = useHistory(); - - const title = intl.formatMessage({ - id: mergeType === "from" ? "actions.merge_from" : "actions.merge_into", - }); - - async function onMerge() { - const source = mergeType === "from" ? src.map((s) => s.id) : [tag.id]; - const destination = mergeType === "from" ? tag.id : dest?.id ?? null; - - if (!destination) return; - - try { - setRunning(true); - const result = await mergeTags({ - variables: { - source, - destination, - }, - }); - if (result.data?.tagsMerge) { - Toast.success(intl.formatMessage({ id: "toast.merged_tags" })); - onClose(); - history.replace(`/tags/${destination}`); - } - } catch (e) { - Toast.error(e); - } finally { - setRunning(false); - } - } - - function canMerge() { - return ( - (mergeType === "from" && src.length > 0) || - (mergeType === "into" && dest !== null) - ); - } - - return ( - onMerge(), - }} - disabled={!canMerge()} - cancel={{ - variant: "secondary", - onClick: () => onClose(), - }} - isRunning={running} - > -
-
- {mergeType === "from" && ( - - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "dialogs.merge_tags.source" }), - labelProps: { - column: true, - sm: 3, - xl: 12, - }, - })} - - setSrc(items)} - values={src} - excludeIds={tag?.id ? [tag.id] : []} - menuPortalTarget={document.body} - /> - - - )} - {mergeType === "into" && ( - - {FormUtils.renderLabel({ - title: intl.formatMessage({ - id: "dialogs.merge_tags.destination", - }), - labelProps: { - column: true, - sm: 3, - xl: 12, - }, - })} - - setDest(items[0])} - values={dest ? [dest] : undefined} - excludeIds={tag?.id ? [tag.id] : []} - menuPortalTarget={document.body} - /> - - - )} -
-
-
- ); -}; diff --git a/ui/v2.5/src/components/Tags/TagList.tsx b/ui/v2.5/src/components/Tags/TagList.tsx index eb57435ad..e30f6071b 100644 --- a/ui/v2.5/src/components/Tags/TagList.tsx +++ b/ui/v2.5/src/components/Tags/TagList.tsx @@ -23,6 +23,8 @@ import { DeleteEntityDialog } from "../Shared/DeleteEntityDialog"; import { ExportDialog } from "../Shared/ExportDialog"; import { tagRelationHook } from "../../core/tags"; import { faTrashAlt } from "@fortawesome/free-solid-svg-icons"; +import { TagMergeModal } from "./TagMergeDialog"; +import { Tag } from "./TagSelect"; import { TagCardGrid } from "./TagCardGrid"; import { EditTagsDialog } from "./EditTagsDialog"; import { View } from "../List/views"; @@ -64,6 +66,7 @@ export const TagList: React.FC = PatchComponent( const intl = useIntl(); const history = useHistory(); + const [mergeTags, setMergeTags] = useState(undefined); const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); const [isExportAll, setIsExportAll] = useState(false); @@ -73,6 +76,11 @@ export const TagList: React.FC = PatchComponent( text: intl.formatMessage({ id: "actions.view_random" }), onClick: viewRandom, }, + { + text: `${intl.formatMessage({ id: "actions.merge" })}…`, + onClick: merge, + isDisplayed: showWhenSelected, + }, { text: intl.formatMessage({ id: "actions.export" }), onClick: onExport, @@ -118,6 +126,16 @@ export const TagList: React.FC = PatchComponent( } } + async function merge( + result: GQL.FindTagsForListQueryResult, + filter: ListFilterModel, + selectedIds: Set + ) { + const selected = + result.data?.findTags.tags.filter((t) => selectedIds.has(t.id)) ?? []; + setMergeTags(selected); + } + async function onExport() { setIsExportAll(false); setIsExportDialogOpen(true); @@ -171,6 +189,23 @@ export const TagList: React.FC = PatchComponent( selectedIds: Set, onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void ) { + function renderMergeDialog() { + if (mergeTags) { + return ( + { + setMergeTags(undefined); + if (mergedId) { + history.push(`/tags/${mergedId}`); + } + }} + show + /> + ); + } + } + function maybeRenderExportDialog() { if (isExportDialogOpen) { return ( @@ -323,6 +358,7 @@ export const TagList: React.FC = PatchComponent( } return ( <> + {renderMergeDialog()} {maybeRenderExportDialog()} {renderTags()} diff --git a/ui/v2.5/src/components/Tags/TagMergeDialog.tsx b/ui/v2.5/src/components/Tags/TagMergeDialog.tsx new file mode 100644 index 000000000..15b648af5 --- /dev/null +++ b/ui/v2.5/src/components/Tags/TagMergeDialog.tsx @@ -0,0 +1,157 @@ +import { Button, Form, Col, Row } from "react-bootstrap"; +import React, { useEffect, useState } from "react"; +import { Icon } from "../Shared/Icon"; +import { ModalComponent } from "src/components/Shared/Modal"; +import * as FormUtils from "src/utils/form"; +import { useTagsMerge } from "src/core/StashService"; +import { useIntl } from "react-intl"; +import { useToast } from "src/hooks/Toast"; +import { faExchangeAlt, faSignInAlt } from "@fortawesome/free-solid-svg-icons"; +import { Tag, TagSelect } from "./TagSelect"; + +interface ITagMergeModalProps { + show: boolean; + onClose: (mergedID?: string) => void; + tags: Tag[]; +} + +export const TagMergeModal: React.FC = ({ + show, + onClose, + tags, +}) => { + const [src, setSrc] = useState([]); + const [dest, setDest] = useState(null); + + const [running, setRunning] = useState(false); + + const [mergeTags] = useTagsMerge(); + + const intl = useIntl(); + const Toast = useToast(); + + const title = intl.formatMessage({ + id: "actions.merge", + }); + + useEffect(() => { + if (tags.length > 0) { + setDest(tags[0]); + setSrc(tags.slice(1)); + } + }, [tags]); + + async function onMerge() { + if (!dest) return; + + const source = src.map((s) => s.id); + const destination = dest.id; + + try { + setRunning(true); + const result = await mergeTags({ + variables: { + source, + destination, + }, + }); + if (result.data?.tagsMerge) { + Toast.success(intl.formatMessage({ id: "toast.merged_tags" })); + onClose(dest.id); + } + } catch (e) { + Toast.error(e); + } finally { + setRunning(false); + } + } + + function canMerge() { + return src.length > 0 && dest !== null; + } + + function switchTags() { + if (src.length && dest !== null) { + const newDest = src[0]; + setSrc([...src.slice(1), dest]); + setDest(newDest); + } + } + + return ( + onMerge(), + }} + disabled={!canMerge()} + cancel={{ + variant: "secondary", + onClick: () => onClose(), + }} + isRunning={running} + > +
+
+ + {FormUtils.renderLabel({ + title: intl.formatMessage({ id: "dialogs.merge.source" }), + labelProps: { + column: true, + sm: 3, + xl: 12, + }, + })} + + setSrc(items)} + values={src} + menuPortalTarget={document.body} + /> + + + + + + + {FormUtils.renderLabel({ + title: intl.formatMessage({ + id: "dialogs.merge.destination", + }), + labelProps: { + column: true, + sm: 3, + xl: 12, + }, + })} + + setDest(items[0])} + values={dest ? [dest] : undefined} + menuPortalTarget={document.body} + /> + + +
+
+
+ ); +}; diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index c4f3d4732..6aaf17125 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -352,6 +352,14 @@ export const queryFindPerformers = (filter: ListFilterModel) => }, }); +export const queryFindPerformersByID = (performerIDs: number[]) => + client.query({ + query: GQL.FindPerformersDocument, + variables: { + performer_ids: performerIDs, + }, + }); + export const queryFindPerformersByIDForSelect = (performerIDs: string[]) => client.query({ query: GQL.FindPerformersForSelectDocument, @@ -420,6 +428,12 @@ export const useFindTag = (id: string) => { return GQL.useFindTagQuery({ variables: { id }, skip }); }; +export const queryFindTag = (id: string) => + client.query({ + query: GQL.FindTagDocument, + variables: { id }, + }); + export const useFindTags = (filter?: ListFilterModel) => GQL.useFindTagsQuery({ skip: filter === undefined, @@ -903,6 +917,10 @@ export const mutateSceneMerge = ( deleteObject(cache, obj, GQL.FindSceneDocument); } + cache.evict({ + id: cache.identify({ __typename: "Scene", id: destination }), + }); + evictTypeFields(cache, sceneMutationImpactedTypeFields); evictQueries(cache, [ ...sceneMutationImpactedQueries, @@ -1844,7 +1862,6 @@ export const usePerformerDestroy = () => }); evictQueries(cache, [ ...performerMutationImpactedQueries, - GQL.FindPerformersDocument, // appears with GQL.FindGroupsDocument, // filter by performers GQL.FindSceneMarkersDocument, // filter by performers ]); @@ -1884,13 +1901,48 @@ 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({ + 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); + } + + cache.evict({ + id: cache.identify({ __typename: "Performer", id: destination }), + }); + + 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"], }; @@ -1999,6 +2051,8 @@ const tagMutationImpactedTypeFields = { }; const tagMutationImpactedQueries = [ + GQL.FindGroupsDocument, // filter by tags + GQL.FindSceneMarkersDocument, // filter by tags GQL.FindScenesDocument, // filter by tags GQL.FindImagesDocument, // filter by tags GQL.FindGalleriesDocument, // filter by tags @@ -2106,16 +2160,14 @@ export const useTagsMerge = () => deleteObject(cache, obj, GQL.FindTagDocument); } - updateStats(cache, "tag_count", -source.length); + cache.evict({ + id: cache.identify({ __typename: "Tag", id: destination }), + }); - const obj = { __typename: "Tag", id: destination }; - evictTypeFields( - cache, - tagMutationImpactedTypeFields, - cache.identify(obj) // don't evict destination tag - ); - - evictQueries(cache, tagMutationImpactedQueries); + evictQueries(cache, [ + ...tagMutationImpactedQueries, + GQL.StatsDocument, // tag count + ]); }, }); diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 158164c8d..426440fad 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -75,8 +75,6 @@ "logout": "Log out", "make_primary": "Make Primary", "merge": "Merge", - "merge_from": "Merge from", - "merge_into": "Merge into", "migrate_blobs": "Migrate Blobs", "migrate_scene_screenshots": "Migrate Scene Screenshots", "next_action": "Next", @@ -972,10 +970,6 @@ "empty_results": "Destination field values will be unchanged.", "source": "Source" }, - "merge_tags": { - "destination": "Destination", - "source": "Source" - }, "overwrite_filter_warning": "Saved filter \"{entityName}\" will be overwritten.", "performers_found": "{count} performers found", "reassign_entity_title": "{count, plural, one {Reassign {singularEntity}} other {Reassign {pluralEntity}}}", @@ -1565,6 +1559,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",