Performer merge (#5910)

* Implement merging of performers
* Make the tag merge UI consistent with other types of merges
* Add merge action in scene menu
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
sezzim 2026-01-04 20:54:19 -08:00 committed by GitHub
parent d962247016
commit 65e82a0cf6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1657 additions and 242 deletions

View file

@ -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

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,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
}

View file

@ -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
}

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)

View file

@ -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

@ -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
}

View file

@ -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

View file

@ -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 = ?

View file

@ -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

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";
@ -250,6 +251,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();
@ -285,6 +287,33 @@ 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={(mergedId) => {
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<IProps> = PatchComponent(
onImageChange={() => {}}
classNames="mb-2"
customButtons={
<div>
<PerformerSubmitButton performer={performer} />
</div>
<>
{renderMergeButton()}
<div>
<PerformerSubmitButton performer={performer} />
</div>
</>
}
></DetailsEditNavbar>
</Row>
@ -499,6 +531,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
@ -104,7 +104,7 @@ function renderScrapedCircumcised(
);
}
function renderScrapedCircumcisedRow(
export function renderScrapedCircumcisedRow(
title: string,
result: ScrapeResult<string>,
onChange: (value: ScrapeResult<string>) => void

View file

@ -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<IPerformerList> = 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<IPerformerList> = 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<IPerformerList> = PatchComponent(
}
}
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);
@ -238,6 +259,23 @@ export const PerformerList: React.FC<IPerformerList> = PatchComponent(
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(`/performers/${mergedId}`);
}
}}
show
/>
);
}
}
function maybeRenderPerformerExportDialog() {
if (isExportDialogOpen) {
return (
@ -290,6 +328,7 @@ export const PerformerList: React.FC<IPerformerList> = PatchComponent(
return (
<>
{renderMergeDialog()}
{maybeRenderPerformerExportDialog()}
{renderPerformers()}
</>

View file

@ -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<string, ZeroableScrapeResult<any>>;
// 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 (
<ScrapedInputGroupRow
className="custom-field"
title={field}
field={fieldName}
key={fieldName}
result={result}
onChange={(newResult) => {
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<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)
);
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)
);
const [customFields, setCustomFields] = useState<CustomFieldScrapeResults>(
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<string>(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 (
<div>
<LoadingIndicator />
</div>
);
}
if (!hasValues) {
return (
<div>
<FormattedMessage id="dialogs.merge.empty_results" />
</div>
);
}
return (
<>
<ScrapedInputGroupRow
field="name"
title={intl.formatMessage({ id: "name" })}
result={name}
onChange={(value) => setName(value)}
/>
<ScrapedInputGroupRow
field="disambiguation"
title={intl.formatMessage({ id: "disambiguation" })}
result={disambiguation}
onChange={(value) => setDisambiguation(value)}
/>
<ScrapedStringListRow
field="aliases"
title={intl.formatMessage({ id: "aliases" })}
result={aliases}
onChange={(value) => setAliases(value)}
/>
<ScrapedInputGroupRow
field="birthdate"
title={intl.formatMessage({ id: "birthdate" })}
result={birthdate}
onChange={(value) => setBirthdate(value)}
/>
<ScrapedInputGroupRow
field="death_date"
title={intl.formatMessage({ id: "death_date" })}
result={deathDate}
onChange={(value) => setDeathDate(value)}
/>
<ScrapedInputGroupRow
field="ethnicity"
title={intl.formatMessage({ id: "ethnicity" })}
result={ethnicity}
onChange={(value) => setEthnicity(value)}
/>
<ScrapedInputGroupRow
field="country"
title={intl.formatMessage({ id: "country" })}
result={country}
onChange={(value) => setCountry(value)}
/>
<ScrapedInputGroupRow
field="hair_color"
title={intl.formatMessage({ id: "hair_color" })}
result={hairColor}
onChange={(value) => setHairColor(value)}
/>
<ScrapedInputGroupRow
field="eye_color"
title={intl.formatMessage({ id: "eye_color" })}
result={eyeColor}
onChange={(value) => setEyeColor(value)}
/>
<ScrapedInputGroupRow
field="height"
title={intl.formatMessage({ id: "height" })}
result={height}
onChange={(value) => setHeight(value)}
/>
<ScrapedInputGroupRow
field="weight"
title={intl.formatMessage({ id: "weight" })}
result={weight}
onChange={(value) => setWeight(value)}
/>
<ScrapedInputGroupRow
field="penis_length"
title={intl.formatMessage({ id: "penis_length" })}
result={penisLength}
onChange={(value) => setPenisLength(value)}
/>
<ScrapedInputGroupRow
field="measurements"
title={intl.formatMessage({ id: "measurements" })}
result={measurements}
onChange={(value) => setMeasurements(value)}
/>
<ScrapedInputGroupRow
field="fake_tits"
title={intl.formatMessage({ id: "fake_tits" })}
result={fakeTits}
onChange={(value) => setFakeTits(value)}
/>
<ScrapedInputGroupRow
field="career_length"
title={intl.formatMessage({ id: "career_length" })}
result={careerLength}
onChange={(value) => setCareerLength(value)}
/>
<ScrapedTextAreaRow
field="tattoos"
title={intl.formatMessage({ id: "tattoos" })}
result={tattoos}
onChange={(value) => setTattoos(value)}
/>
<ScrapedTextAreaRow
field="piercings"
title={intl.formatMessage({ id: "piercings" })}
result={piercings}
onChange={(value) => setPiercings(value)}
/>
<ScrapedStringListRow
field="urls"
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
field="tags"
title={intl.formatMessage({ id: "tags" })}
result={tags}
onChange={(value) => setTags(value)}
/>
<ScrapedTextAreaRow
field="details"
title={intl.formatMessage({ id: "details" })}
result={details}
onChange={(value) => setDetails(value)}
/>
<ScrapedImageRow
field="image"
title={intl.formatMessage({ id: "performer_image" })}
className="performer-image"
result={image}
onChange={(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 (
<ScrapeDialog
className="performer-merge-dialog"
title={dialogTitle}
existingLabel={destinationLabel}
scrapedLabel={sourceLabel}
onClose={(apply) => {
if (!apply) {
onClose();
} else {
onClose(createValues());
}
}}
>
{renderScrapeRows()}
</ScrapeDialog>
);
};
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" }));
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
dialogClassName="performer-merge-dialog"
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

@ -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;
}
}

View file

@ -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<IProps> = 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<IProps> = PatchComponent("ScenePage", (props) => {
const [activeTabKey, setActiveTabKey] = useState("scene-details-panel");
const [isMerging, setIsMerging] = useState(false);
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
const [isGenerateDialogOpen, setIsGenerateDialogOpen] = useState(false);
@ -347,6 +350,24 @@ const ScenePage: React.FC<IProps> = PatchComponent("ScenePage", (props) => {
}
}
function maybeRenderMergeDialog() {
if (!scene.id) return;
return (
<SceneMergeModal
show={isMerging}
onClose={(mergedId) => {
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<IProps> = PatchComponent("ScenePage", (props) => {
<FormattedMessage id="actions.submit_stash_box" />
</Dropdown.Item>
)}
<Dropdown.Item
key="merge-scene"
className="bg-secondary text-white"
onClick={() => setIsMerging(true)}
>
<FormattedMessage id="actions.merge" />
...
</Dropdown.Item>
<Dropdown.Item
key="delete-scene"
className="bg-secondary text-white"
@ -588,6 +617,7 @@ const ScenePage: React.FC<IProps> = PatchComponent("ScenePage", (props) => {
<title>{title}</title>
</Helmet>
{maybeRenderSceneGenerateDialog()}
{maybeRenderMergeDialog()}
{maybeRenderDeleteDialog()}
<div
className={`scene-tabs order-xl-first order-last ${

View file

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

View file

@ -14,6 +14,7 @@ export const ScrapeDialogContext =
React.createContext<IScrapeDialogContextState>({});
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" : ""
}`,
}}
>
<div className="dialog-container">

View file

@ -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<IProps> = ({ tag, tabKey }) => {
// Editing state
const [isEditing, setIsEditing] = useState<boolean>(false);
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
const [mergeType, setMergeType] = useState<"from" | "into" | undefined>();
const [isMerging, setIsMerging] = useState<boolean>(false);
// Editing tag state
const [image, setImage] = useState<string | null>();
@ -461,41 +456,27 @@ const TagPage: React.FC<IProps> = ({ tag, tabKey }) => {
function renderMergeButton() {
return (
<Dropdown>
<Dropdown.Toggle variant="secondary">
<FormattedMessage id="actions.merge" />
...
</Dropdown.Toggle>
<Dropdown.Menu className="bg-secondary text-white" id="tag-merge-menu">
<Dropdown.Item
className="bg-secondary text-white"
onClick={() => setMergeType("from")}
>
<Icon icon={faSignInAlt} />
<FormattedMessage id="actions.merge_from" />
...
</Dropdown.Item>
<Dropdown.Item
className="bg-secondary text-white"
onClick={() => setMergeType("into")}
>
<Icon icon={faSignOutAlt} />
<FormattedMessage id="actions.merge_into" />
...
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
<Button variant="secondary" onClick={() => setIsMerging(true)}>
<FormattedMessage id="actions.merge" />
...
</Button>
);
}
function renderMergeDialog() {
if (!tag || !mergeType) return;
if (!tag.id) return;
return (
<TagMergeModal
tag={tag}
onClose={() => 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]}
/>
);
}

View file

@ -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<GQL.Tag, "id">;
mergeType: "from" | "into";
}
export const TagMergeModal: React.FC<ITagMergeModalProps> = ({
show,
onClose,
tag,
mergeType,
}) => {
const [src, setSrc] = useState<Tag[]>([]);
const [dest, setDest] = useState<Tag | null>(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 (
<ModalComponent
show={show}
header={title}
icon={mergeType === "from" ? faSignInAlt : faSignOutAlt}
accept={{
text: intl.formatMessage({ id: "actions.merge" }),
onClick: () => onMerge(),
}}
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">
{mergeType === "from" && (
<Form.Group controlId="source" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "dialogs.merge_tags.source" }),
labelProps: {
column: true,
sm: 3,
xl: 12,
},
})}
<Col sm={9} xl={12}>
<TagSelect
isMulti
creatable={false}
onSelect={(items) => setSrc(items)}
values={src}
excludeIds={tag?.id ? [tag.id] : []}
menuPortalTarget={document.body}
/>
</Col>
</Form.Group>
)}
{mergeType === "into" && (
<Form.Group controlId="destination" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({
id: "dialogs.merge_tags.destination",
}),
labelProps: {
column: true,
sm: 3,
xl: 12,
},
})}
<Col sm={9} xl={12}>
<TagSelect
isMulti={false}
creatable={false}
onSelect={(items) => setDest(items[0])}
values={dest ? [dest] : undefined}
excludeIds={tag?.id ? [tag.id] : []}
menuPortalTarget={document.body}
/>
</Col>
</Form.Group>
)}
</div>
</div>
</ModalComponent>
);
};

View file

@ -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<ITagList> = PatchComponent(
const intl = useIntl();
const history = useHistory();
const [mergeTags, setMergeTags] = useState<Tag[] | undefined>(undefined);
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [isExportAll, setIsExportAll] = useState(false);
@ -73,6 +76,11 @@ export const TagList: React.FC<ITagList> = 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<ITagList> = PatchComponent(
}
}
async function merge(
result: GQL.FindTagsForListQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>
) {
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<ITagList> = PatchComponent(
selectedIds: Set<string>,
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void
) {
function renderMergeDialog() {
if (mergeTags) {
return (
<TagMergeModal
tags={mergeTags}
onClose={(mergedId?: string) => {
setMergeTags(undefined);
if (mergedId) {
history.push(`/tags/${mergedId}`);
}
}}
show
/>
);
}
}
function maybeRenderExportDialog() {
if (isExportDialogOpen) {
return (
@ -323,6 +358,7 @@ export const TagList: React.FC<ITagList> = PatchComponent(
}
return (
<>
{renderMergeDialog()}
{maybeRenderExportDialog()}
{renderTags()}
</>

View file

@ -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<ITagMergeModalProps> = ({
show,
onClose,
tags,
}) => {
const [src, setSrc] = useState<Tag[]>([]);
const [dest, setDest] = useState<Tag | null>(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 (
<ModalComponent
show={show}
header={title}
icon={faSignInAlt}
accept={{
text: intl.formatMessage({ id: "actions.merge" }),
onClick: () => onMerge(),
}}
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}>
<TagSelect
isMulti
creatable={false}
onSelect={(items) => setSrc(items)}
values={src}
menuPortalTarget={document.body}
/>
</Col>
</Form.Group>
<Form.Group
controlId="switch"
as={Row}
className="justify-content-center"
>
<Button
variant="secondary"
onClick={() => switchTags()}
disabled={!src.length || !dest}
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}>
<TagSelect
isMulti={false}
creatable={false}
onSelect={(items) => setDest(items[0])}
values={dest ? [dest] : undefined}
menuPortalTarget={document.body}
/>
</Col>
</Form.Group>
</div>
</div>
</ModalComponent>
);
};

View file

@ -352,6 +352,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,
@ -420,6 +428,12 @@ export const useFindTag = (id: string) => {
return GQL.useFindTagQuery({ variables: { id }, skip });
};
export const queryFindTag = (id: string) =>
client.query<GQL.FindTagQuery>({
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<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);
}
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
]);
},
});

View file

@ -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",