mirror of
https://github.com/stashapp/stash.git
synced 2026-01-09 00:53:01 +01:00
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:
parent
d962247016
commit
65e82a0cf6
25 changed files with 1657 additions and 242 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -92,6 +92,8 @@ type PerformerWriter interface {
|
|||
PerformerCreator
|
||||
PerformerUpdater
|
||||
PerformerDestroyer
|
||||
|
||||
Merge(ctx context.Context, source []int, destination int) error
|
||||
}
|
||||
|
||||
// PerformerReaderWriter provides all performer methods.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = ?
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -23,3 +23,9 @@ mutation PerformerDestroy($id: ID!) {
|
|||
mutation PerformersDestroy($ids: [ID!]!) {
|
||||
performersDestroy(ids: $ids)
|
||||
}
|
||||
|
||||
mutation PerformerMerge($input: PerformerMergeInput!) {
|
||||
performerMerge(input: $input) {
|
||||
id
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
</>
|
||||
|
|
|
|||
876
ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx
Normal file
876
ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ${
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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()}
|
||||
</>
|
||||
|
|
|
|||
157
ui/v2.5/src/components/Tags/TagMergeDialog.tsx
Normal file
157
ui/v2.5/src/components/Tags/TagMergeDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue